001/*
002 * Licensed to the Apache Software Foundation (ASF) under one or more
003 * contributor license agreements.  See the NOTICE file distributed with
004 * this work for additional information regarding copyright ownership.
005 * The ASF licenses this file to You under the Apache License, Version 2.0
006 * (the "License"); you may not use this file except in compliance with
007 * the License.  You may obtain a copy of the License at
008 *
009 *      http://www.apache.org/licenses/LICENSE-2.0
010 *
011 * Unless required by applicable law or agreed to in writing, software
012 * distributed under the License is distributed on an "AS IS" BASIS,
013 * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
014 * See the License for the specific language governing permissions and
015 * limitations under the License.
016 */
017package org.apache.juneau.console;
018
019import static org.apache.juneau.commons.utils.Shorts.*;
020
021import java.util.*;
022import java.util.regex.*;
023import java.util.stream.*;
024
025import org.apache.juneau.marshall.collections.*;
026
027/**
028 * Static check of a page contract's message-bus wiring: rule R-10 (every
029 * subscription has a publisher), rule R-11 (every {@code publisher:"server"} topic rides a bridge downstream), and the
030 * E-40..E-54 and E-58 shape rules on {@code topics}, {@code bridges}, card {@code publishes} / {@code subscribes} and ribbon
031 * {@code target} / {@code publish} items.
032 *
033 * <p>
034 * The same rule runs in the browser as {@code JuneauViews.bus.wiring.validate}, and both read the shared corpus
035 * {@code bus-wiring-corpus.json}.  The JS side reports the subset that only the running page can act on (E-JS-41,
036 * E-JS-47, E-JS-52); this class reports everything.
037 *
038 * <p>
039 * Every problem is collected; nothing is thrown.  Callers decide how loud to be: the page render, the
040 * template validator and C3's {@code PageSpec.build()} each turn the list into their own failure.
041 *
042 * <h5 class='section'>Example:</h5>
043 * <p class='bjava'>
044 *   JsonMap <jv>contract</jv> = JsonMap.<jsm>ofString</jsm>(<jv>pageJson</jv>);
045 *   <jk>for</jk> (<jk>var</jk> <jv>p</jv> : BusWiringValidator.<jsm>validate</jsm>(<jv>contract</jv>, CardTypeRegistry.<jsm>standard</jsm>()))
046 *     System.<jsf>err</jsf>.println(<jv>p</jv>.code() + <js>" "</js> + <jv>p</jv>.message());
047 *   <jc>// E-41 card 'tasks' subscribes to 'selection:changes' but nothing publishes it; card 'changes' does not enable selection</jc>
048 * </p>
049 *
050 * @since 10.0.0
051 */
052@SuppressWarnings({
053   "java:S1192", // Repeated literals are contract wire keys; named constants would obscure the contract shape.
054   "java:S3776" // Validation and rendering chains many independent guards; splitting them adds no clarity.
055})
056public final class BusWiringValidator {
057
058   /**
059    * One wiring problem.
060    *
061    * <h5 class='section'>Example:</h5>
062    * <p class='bjava'>
063    *   <jk>var</jk> <jv>p</jv> = <jk>new</jk> BusWiringValidator.Problem(<js>"E-52"</js>,
064    *     <js>"topic 'ops.jobs' is declared publisher=server but no bridge carries it downstream"</js>);
065    *   <jv>p</jv>.toString();   <jc>// "E-52 topic 'ops.jobs' is declared ..."</jc>
066    * </p>
067    *
068    * @param code The Java code, {@code E-40}..{@code E-54}, {@code E-58} or {@code E-71}..{@code E-77}.
069    * @param message The message, without the code.
070    */
071   public record Problem(String code, String message) {
072      @Override
073      public String toString() {
074         return code + " " + message;
075      }
076   }
077
078   static final String E40 = "invalid topic '%s': expected family[:key] (see the topic syntax)";
079   static final String E41 = "card '%s' subscribes to '%s' but nothing publishes it%s";
080   static final String E41_SUGGEST = "; did you mean '%s'?";
081   static final String E41_NOT_ENABLED = "; card '%s' does not enable %s";
082   static final String E42 = "topic '%s' names card '%s', which is not on this page";
083   static final String E43_PUBLISH = "card '%s' (type '%s') does not publish '%s'";
084   static final String E43_OP = "card '%s' (type '%s') does not handle op '%s'";
085   static final String E44 = "card '%s' (type '%s') has no role '%s'; accepted: %s";
086   static final String E45_FRAMEWORK = "'%s' is a framework family; framework topics are implicit and may not be declared";
087   static final String E45_NAMESPACE = "custom topic family '%s' must be namespaced (e.g. 'app.%s')";
088   static final String E46 = "topic '%s' is declared with retain=%s here and retain=%s at %s";
089   static final String E47_TOKEN = "card '%s': token '{%s}' in %s is not mapped by any params subscription";
090   static final String E47_MAP = "card '%s': params subscription needs map";
091   static final String E48 = "ribbon item %s on card '%s': %s";
092   static final String E49 = "card '%s' subscribes to its own topic '%s'; use ctx.subscribe with {echo:true} in JS if this is intended";
093   static final String E51 = "bridge '%s': %s";
094   static final String E52 = "topic '%s' is declared publisher=server but no bridge carries it downstream";
095   static final String E53 = "bridge '%s' sends '%s' upstream, but no topics entry or card publishes declares it";
096   static final String E54 = "bridge '%s' may not carry framework state topic '%s' (only job:*, and cmd:<cardId> downstream)";
097   static final String E58 = "terminal card '%s' has input: true but %s";
098   static final String E72 = "chart card '%s' on.%s targets '%s', which the card does not publish";
099   static final String E73 = "'%s' sends cmd '%s' to group '%s', which no chart card declares";
100   /** E-74 text; {@code Fn}, {@code ChartDef} and {@code ChartCardType} (console-ui-freemarker) throw it too. */
101   public static final String E74 = "'%s' is not a valid %s name";
102   static final String E76 = "data source '%s': %s";
103   /** E-77 text; {@code DashboardSpec}, {@code Section} and {@code PersistSpec} (console-ui-freemarker) throw it too. */
104   public static final String E77 = "dashboard '%s': %s";
105   static final String E71 = "topic '%s' needs exactly one source publisher; found %s";
106
107   /** Names for $fn, decorators, sections, parsers, builders and transforms (rule R-E5); juneau-charts.js has the same literal. */
108   @SuppressWarnings({
109      "java:S5998" // Pattern text is pinned to the identical literal in the JS files; possessive quantifiers are not valid JS
110   })
111   public static final Pattern EXTENSION_NAME = Pattern.compile("^[a-z][a-zA-Z0-9]*(\\.[a-z][a-zA-Z0-9-]*)*$");
112   /** ECharts event names a chart card's {@code on} may use. */
113   public static final Pattern EVENT_NAME = Pattern.compile("^[a-z][a-z0-9_]{0,31}$");
114
115   // Topic grammar: one copy, owned by Topics.
116   static final Set<String> FRAMEWORK_FAMILIES = Topics.FRAMEWORK_FAMILIES;
117   static final Pattern TOPIC = Topics.TOPIC;
118   static final Pattern CUSTOM_DECL = Topics.CUSTOM_PUBLICATION;
119   static final Pattern UNNAMESPACED = Pattern.compile("^([a-z][a-z0-9-]{0,31})(:([A-Za-z0-9_.-]{1,128}|\\*))?$");
120   static final Pattern BRIDGE_ID = Topics.ID;
121   static final Pattern SAME_ORIGIN_PATH = Pattern.compile("^/(?![/\\\\])\\S*$");
122   static final Pattern URL_TOKEN = Pattern.compile("\\{([A-Za-z0-9_-]+)\\}");
123
124   /** Families whose key is a card id. */
125   static final Set<String> CARD_FAMILIES = Set.of("card", "selection", "filter", "redraw", "detail", "bulk", "cmd", "zoom", "legend");
126   /** Roles the shell implements for every card type. */
127   static final Set<String> SHELL_ROLES = Set.of("refresh", "params");
128   /** The {@code cmd:} op each targetable ribbon item sends. */
129   static final Map<String,String> RIBBON_OPS = Map.of(
130      "refresh", "reload", "option", "set-filter", "optionGroup", "set-filter",
131      "pausePolling", "pause-polling", "collapseAll", "collapse-all");
132   /** The card type a data source reports in E-43 (R-E3a). */
133   static final String SOURCE_TYPE = "dataSource";
134   /** The cmd ops a data source handles: refresh, and reload (what a targeted ribbon refresh item sends). */
135   static final Set<String> SOURCE_OPS = Set.of("refresh", "reload");
136   /** Datatables families that need a catalog key to be published. */
137   static final Set<String> DATATABLES_OPT_IN = Set.of("selection", "detail", "bulk");
138   /** Largest edit distance a "did you mean" suggestion may have. */
139   static final int SUGGEST_MAX_DISTANCE = 3;
140
141   private BusWiringValidator() {}
142
143   /**
144    * Checks a page contract's bus wiring.
145    *
146    * @param contract The page contract (the {@code #juneau-page} JSON).
147    * @param registry Card types, for implicit topics, accepted roles and ops.  Unregistered types are skipped.
148    * @return Every problem, in contract order: {@code topics}, then each card, then {@code dataSources}
149    *    (E-74, then E-71 sorted by topic, then derived-source cycles as E-76 sorted by each cycle's smallest id),
150    *    then ids (E-76/E-77), then {@code bridges}, then R-11, then the terminal-input rule (E-58).
151    */
152   public static List<Problem> validate(JsonMap contract, CardTypeRegistry registry) {
153      return new Run(contract, registry).run();
154   }
155
156   /** {@code pattern} equals {@code topic}, or is {@code family:*} and {@code topic} is a concrete {@code family:key}. */
157   static boolean topicMatches(String pattern, String topic) {
158      if (pattern.equals(topic))
159         return true;
160      if (! pattern.endsWith(":*"))
161         return false;
162      var prefix = pattern.substring(0, pattern.length() - 1);
163      return topic.length() > prefix.length() && topic.startsWith(prefix);
164   }
165
166   /** Levenshtein distance. */
167   static int distance(String a, String b) {
168      var prev = new int[b.length() + 1];
169      var cur = new int[b.length() + 1];
170      for (var j = 0; j <= b.length(); j++)
171         prev[j] = j;
172      for (var i = 1; i <= a.length(); i++) {
173         cur[0] = i;
174         for (var j = 1; j <= b.length(); j++) {
175            var cost = a.charAt(i - 1) == b.charAt(j - 1) ? 0 : 1;
176            cur[j] = Math.min(Math.min(cur[j - 1] + 1, prev[j] + 1), prev[j - 1] + cost);
177         }
178         var t = prev;
179         prev = cur;
180         cur = t;
181      }
182      return prev[b.length()];
183   }
184
185   private static final class Run {
186      final CardTypeRegistry registry;
187      final List<Problem> problems = new ArrayList<>();
188      final List<Map<?,?>> topics;
189      final List<Map<?,?>> bridges;
190      final Map<String,Map<?,?>> cards = new LinkedHashMap<>();
191      final List<Map<?,?>> dataSources;
192      final List<Map<?,?>> sections;
193      /** The contract's {@code dashboard} object, or an empty map. */
194      final Map<?,?> dashboard;
195      /** Ids of the data sources, and of the rail sections (the sole owner of {@code filter:<sectionId>}, R-E7). */
196      final Set<String> sourceIds = new HashSet<>();
197      final Set<String> sectionIds = new HashSet<>();
198      /** {@code contract.dashboard.id}, the E-77 name slot; {@code "<none>"} when absent. */
199      final String dashId;
200      /** Ids a card-keyed family key may name: cards, data sources (R-E3a) and dashboard rail sections (R-E7). */
201      final Set<String> targets = new HashSet<>();
202      /** data:<id> topic to its number of source publishers (R-E1). */
203      final Map<String,Integer> dataPublishers = new TreeMap<>();
204      /** data:<id> topics something on the page reads (R-E1). */
205      final Set<String> dataReads = new TreeSet<>();
206      /** Every topic or pattern something on the page publishes (R-10 rules 2-6), sorted for the suggestion. */
207      final TreeSet<String> published = new TreeSet<>();
208      /** topic -> [retain, where] of its first declaration (E-46). */
209      final Map<String,Object[]> retains = new HashMap<>();
210
211      Run(JsonMap contract, CardTypeRegistry registry) {
212         this.registry = registry;
213         topics = maps(contract.get("topics"));
214         bridges = maps(contract.get("bridges"));
215         for (var c : maps(contract.get("cards")))
216            if (str(c.get("id")) != null)
217               cards.putIfAbsent(str(c.get("id")), c);
218         dataSources = maps(contract.get("dataSources"));
219         var dash = contract.get("dashboard") instanceof Map<?,?> d ? d : Map.of();
220         dashboard = dash;
221         var rail = dash.get("rail") instanceof Map<?,?> r ? r : Map.of();
222         sections = maps(rail.get("sections"));
223         dashId = str(dash.get("id")) != null ? str(dash.get("id")) : "<none>";
224         targets.addAll(cards.keySet());
225         for (var s : dataSources) if (str(s.get("id")) != null) sourceIds.add(str(s.get("id")));
226         for (var s : sections) if (str(s.get("id")) != null) sectionIds.add(str(s.get("id")));
227         targets.addAll(sourceIds);
228         targets.addAll(sectionIds);
229      }
230
231      List<Problem> run() {
232         collectPublishers();
233         for (var i = 0; i < topics.size(); i++)
234            checkTopicDecl(i, topics.get(i));
235         for (var c : cards.values())
236            checkCard(c);
237         checkDataSources();
238         checkDataTopics();
239         checkDerivedCycles();
240         checkIds();
241         checkDashboard();
242         checkBridges();
243         checkServerTopics();
244         checkTerminalInput();
245         return List.copyOf(problems);
246      }
247
248      void add(String code, String format, Object... args) {
249         problems.add(new Problem(code, f(format, args)));
250      }
251
252      // ---- R-10 publishers --------------------------------------------------------------------------------------
253
254      void collectPublishers() {
255         for (var c : cards.values()) {
256            var id = str(c.get("id"));
257            published.add("card:" + id);
258            var type = str(c.get("type"));
259            if (type != null && registry.isRegistered(type))
260               published.addAll(registry.handler(type).implicitTopics(json(c)));
261            for (var p : maps(c.get("publishes")))
262               addCustom(str(p.get("topic")));
263            for (var item : ribbon(c))
264               if ("publish".equals(str(item.get("type"))))
265                  addCustom(str(item.get("topic")));
266         }
267         for (var t : topics) {
268            var topic = str(t.get("topic"));
269            if ("job:*".equals(topic))
270               published.add(topic);
271            else if (topic != null && topic.startsWith("data:") && "script".equals(str(t.get("publisher")))) {
272               published.add(topic);
273               dataPublishers.merge(topic, 1, Integer::sum);
274            }
275            else
276               addCustom(topic);
277         }
278         for (var id : sourceIds) {
279            published.add("data:" + id);
280            dataPublishers.merge("data:" + id, 1, Integer::sum);
281         }
282         // A section's filter:<id> is read through the sectionIds check in checkPublisher; it is added here only so that
283         // the "did you mean" suggestion can offer it (pinned by corpus c67).
284         for (var s : sections)
285            if (str(s.get("id")) != null)
286               published.add("filter:" + str(s.get("id")));
287         for (var b : bridges) {
288            var id = str(b.get("id"));
289            if (id != null)
290               published.add("bridge:" + id);
291            for (var d : strings(b.get("downstream")))
292               if (d.startsWith("job:"))
293                  published.add(d);
294         }
295      }
296
297      void addCustom(String topic) {
298         if (topic != null && CUSTOM_DECL.matcher(topic).matches())
299            published.add(topic);
300      }
301
302      boolean isPublished(String topic) {
303         for (var p : published)
304            if (topicMatches(p, topic))
305               return true;
306         return false;
307      }
308
309      String suggest(String topic) {
310         String best = null;
311         var bestDistance = SUGGEST_MAX_DISTANCE + 1;
312         for (var p : published) {
313            var d = distance(topic, p);
314            if (d < bestDistance) {
315               best = p;
316               bestDistance = d;
317            }
318         }
319         return best == null ? "" : f(E41_SUGGEST, best);
320      }
321
322      // ---- declarations (E-40, E-45, E-46) ----------------------------------------------------------------------
323
324      void checkTopicDecl(int index, Map<?,?> decl) {
325         var topic = str(decl.get("topic"));
326         if ("job:*".equals(topic)) {
327            if (! "server".equals(str(decl.get("publisher"))))
328               add("E-45", E45_FRAMEWORK, "job");
329            return;
330         }
331         if (topic != null && topic.startsWith("data:") && TOPIC.matcher(topic).matches()) {
332            // A page-declared data: topic is published by a page script, and the bus keeps its last value (R-E4).
333            if (! "script".equals(str(decl.get("publisher"))) || ! Boolean.TRUE.equals(decl.get("retain")))
334               add("E-45", E45_FRAMEWORK, "data");
335            return;
336         }
337         checkDeclared(topic, decl.get("retain"), "topics[" + index + "]", null);
338      }
339
340      /** @param cardId The declaring card, or {@code null} for a page-level topics entry. */
341      void checkDeclared(String topic, Object retain, String where, String cardId) {
342         if (topic != null && cardId != null) {
343            var card = cards.get(cardId);
344            if (card != null && "chart".equals(str(card.get("type"))) && (topic.equals("zoom:" + cardId) || topic.equals("legend:" + cardId))) {
345               // Retained chart state: TopicDecl.toPublicationMap requires retain(true).
346               if (! Boolean.TRUE.equals(retain))
347                  add("E-45", E45_FRAMEWORK, family(topic));
348               return;
349            }
350         }
351         if (topic != null && CUSTOM_DECL.matcher(topic).matches()) {
352            var first = retains.putIfAbsent(topic, new Object[] { retain, where });
353            if (first != null && neq(first[0], retain))
354               add("E-46", E46, topic, retain, first[0], first[1]);
355            return;
356         }
357         var family = topic == null ? null : family(topic);
358         if (family != null && FRAMEWORK_FAMILIES.contains(family))
359            add("E-45", E45_FRAMEWORK, family);
360         else if (topic != null && UNNAMESPACED.matcher(topic).matches())
361            add("E-45", E45_NAMESPACE, family, family);
362         else
363            add("E-40", E40, topic);
364      }
365
366      // ---- cards ------------------------------------------------------------------------------------------------
367
368      void checkCard(Map<?,?> card) {
369         var id = str(card.get("id"));
370         for (var p : maps(card.get("publishes")))
371            checkDeclared(str(p.get("topic")), p.get("retain"), "card '" + id + "'", id);
372         var params = new LinkedHashSet<String>();
373         var hasParams = false;
374         for (var s : maps(card.get("subscribes"))) {
375            checkSubscription(card, s);
376            if ("params".equals(str(s.get("as")))) {
377               hasParams = true;
378               if (s.get("map") instanceof Map<?,?> m)
379                  m.keySet().forEach(k -> params.add(String.valueOf(k)));
380               else
381                  add("E-47", E47_MAP, id);
382            }
383         }
384         var items = ribbon(card);
385         for (var i = 0; i < items.size(); i++)
386            checkRibbonItem(card, i, items.get(i));
387         if (hasParams) {
388            checkTokens(id, "src", str(card.get("src")), params);
389            checkTokens(id, "dataUrl", str(view(card).get("dataUrl")), params);
390         }
391         if ("chart".equals(str(card.get("type"))))
392            checkChart(card);
393      }
394
395      // Keys a legacy flat chart card reads as helper keys (mirrors ChartDef.HELPER_KEYS in the freemarker module).
396      private static final List<String> LEGACY_HELPER_KEYS = List.of("x", "y", "series", "stacked", "orientation", "legend", "label",
397         "value", "center", "scale", "symbolSize", "groupBy", "size", "color", "tileFields");
398
399      // R-E2 (E-72), R-E5 (E-74) for one chart card.
400      void checkChart(Map<?,?> card) {
401         var id = str(card.get("id"));
402         var pubs = maps(card.get("publishes")).stream().map(p -> str(p.get("topic"))).filter(Objects::nonNull).collect(Collectors.toSet());
403         if (card.get("on") instanceof Map<?,?> on)
404            for (var e : on.entrySet()) {
405               var ev = String.valueOf(e.getKey());
406               var t = e.getValue() instanceof String ts ? ts : null;
407               if (! EVENT_NAME.matcher(ev).matches())
408                  add("E-74", E74, ev, "event");
409               if (t == null || ! pubs.contains(t))
410                  add("E-72", E72, id, ev, t != null ? t : "<not a string: " + e.getValue() + ">");
411            }
412         for (var d : maps(card.get("decorators"))) {
413            var use = str(d.get("use"));
414            if (use == null || ! EXTENSION_NAME.matcher(use).matches())
415               add("E-74", E74, use, "decorator");
416            walkFns(d);
417         }
418         walkFns(card.get("option"));
419         walkFns(card.get("patch"));
420         // helper values can be $fn markers (ChartDef.symbolSizeBy); a legacy flat card carries the helper keys at top level.
421         walkFns(card.get("helper"));
422         // A legacy flat body (only when it names a kind, as ChartCardType does) is walked as one map of its helper keys, so
423         // the series key skips series[*].data exactly like ChartCardType.
424         if (card.containsKey("kind")) {
425            var legacy = new LinkedHashMap<String,Object>();
426            for (var k : LEGACY_HELPER_KEYS)
427               if (card.containsKey(k))
428                  legacy.put(k, card.get(k));
429            walkFns(legacy);
430         }
431      }
432
433      // $fn markers: an object whose only keys are $fn and, optionally, args.  dataset[*].source and series[*].data are
434      // rows of data, never extension references, so they are not walked.
435      void walkFns(Object o) {
436         if (o instanceof Map<?,?> m) {
437            if (m.containsKey("$fn") && m.keySet().stream().allMatch(k -> "$fn".equals(k) || "args".equals(k))) {
438               var n = str(m.get("$fn"));
439               if (n == null || ! EXTENSION_NAME.matcher(n).matches())
440                  add("E-74", E74, n, "$fn");
441            }
442            for (var e : m.entrySet()) {
443               if ("dataset".equals(e.getKey()))
444                  walkSkipping(e.getValue(), "source");
445               else if ("series".equals(e.getKey()))
446                  walkSkipping(e.getValue(), "data");
447               else
448                  walkFns(e.getValue());
449            }
450         } else if (o instanceof List<?> l) {
451            l.forEach(this::walkFns);
452         }
453      }
454
455      /** Walks {@code o} (a map or a list of maps) without descending into the {@code skip} key of each map. */
456      void walkSkipping(Object o, String skip) {
457         if (o instanceof List<?> l) {
458            l.forEach(x -> walkSkipping(x, skip));
459         } else if (o instanceof Map<?,?> m) {
460            for (var e : m.entrySet())
461               if (! skip.equals(e.getKey()))
462                  walkFns(e.getValue());
463         }
464      }
465
466      // R-E3b (E-73): a cmd with target "group" needs a group some chart card declares.
467      void checkGroupCmd(String from, String key, Map<?,?> item) {
468         if (! (item.get("payload") instanceof Map<?,?> p) || ! "group".equals(str(p.get("target"))))
469            return;
470         var target = cards.get(key);
471         // Only a chart card has a group; a data source or another card type is not a group member.
472         if (target == null || ! "chart".equals(str(target.get("type"))) || str(target.get("group")) != null)
473            return;
474         var groups = cards.values().stream().filter(c -> "chart".equals(str(c.get("type")))).map(c -> str(c.get("group")))
475            .filter(Objects::nonNull).collect(Collectors.toCollection(TreeSet::new));
476         add("E-73", E73, from, str(p.get("op")), groups.isEmpty() ? "<none>" : "<none> (page groups: " + String.join(", ", groups) + ")");
477      }
478
479      // R-E6 (E-76 / E-77): ids are unique across cards, data sources and rail sections.
480      void checkIds() {
481         var seenSources = new HashSet<String>();
482         for (var s : dataSources) {
483            var sid = str(s.get("id"));
484            if (sid == null) continue;
485            if (cards.containsKey(sid))
486               add("E-76", E76, sid, "id duplicates card '" + sid + "'");
487            else if (! seenSources.add(sid))
488               add("E-76", E76, sid, "id duplicates data source '" + sid + "'");
489         }
490         var seenSections = new HashSet<String>();
491         for (var s : sections) {
492            var sid = str(s.get("id"));
493            if (sid == null) continue;
494            if (cards.containsKey(sid))
495               add("E-77", E77, dashId, "section id '" + sid + "' duplicates card '" + sid + "'");
496            else if (sourceIds.contains(sid))
497               add("E-77", E77, dashId, "section id '" + sid + "' duplicates data source '" + sid + "'");
498            else if (! seenSections.add(sid))
499               add("E-77", E77, dashId, "section id '" + sid + "' duplicates section '" + sid + "'");
500         }
501      }
502
503      // E-77 page-level dashboard rules; the rest of E-77 is checked when the dashboard is built.
504      void checkDashboard() {
505         if (dashboard.isEmpty())
506            return;
507         var declared = new HashSet<String>();
508         for (var t : topics) {
509            var name = str(t.get("topic"));
510            if (name != null && name.startsWith("data:"))
511               declared.add(name);
512         }
513         sourceIds.stream().map(id -> "data:" + id).forEach(declared::add);
514         if (dashboard.get("status") instanceof List<?> status)
515            for (var o : status)
516               if (o instanceof String t && ! declared.contains(t))
517                  add("E-77", E77, dashId, "status topic '" + t + "' is not a declared data source");
518         for (var s : sections)
519            if ("choice".equals(str(s.get("kind"))) && ! declared.contains("data:" + str(s.get("source"))))
520               add("E-77", E77, dashId, "choice section '" + str(s.get("id")) + "' names source '" + str(s.get("source"))
521                  + "', which is not a declared data source");
522      }
523
524      // R-E1 inputs from derived sources, and R-E5 (E-74) for parser, builder and transform names.
525      void checkDataSources() {
526         for (var s : dataSources) {
527            if ("derived".equals(str(s.get("kind"))) && str(s.get("from")) != null)
528               dataReads.add("data:" + str(s.get("from")));
529            checkExtensionName(s.get("parser"), "parser");
530            checkExtensionName(s.get("builder"), "builder");
531            for (var t : maps(s.get("transforms")))
532               checkExtensionName(t.get("use"), "transform");
533         }
534      }
535
536      void checkExtensionName(Object name, String what) {
537         if (name != null && ! EXTENSION_NAME.matcher(String.valueOf(name)).matches())
538            add("E-74", E74, name, what);
539      }
540
541      // R-E1 (E-71): every data topic read or published has exactly one source publisher.
542      void checkDataTopics() {
543         var all = new TreeSet<>(dataReads);
544         all.addAll(dataPublishers.keySet());
545         for (var t : all) {
546            var n = dataPublishers.getOrDefault(t, 0);
547            if (n != 1)
548               add("E-71", E71, t, n);
549         }
550      }
551
552      // E-76: a derived source's input chain must not loop back on itself.  Each cycle is reported once, named from its
553      // smallest id; an input that is not a derived source ends the walk (a missing one is E-71 above).
554      void checkDerivedCycles() {
555         var from = new TreeMap<String,String>();
556         // A duplicate id is already E-76 from the duplicate-id rule; only the first derived entry for an id is walked.
557         for (var s : dataSources)
558            if ("derived".equals(str(s.get("kind"))) && str(s.get("id")) != null && str(s.get("from")) != null)
559               from.putIfAbsent(str(s.get("id")), str(s.get("from")));
560         var done = new HashSet<String>();
561         var cycles = new TreeMap<String,List<String>>();
562         for (var start : from.keySet()) {
563            var path = new ArrayList<String>();
564            var id = start;
565            while (id != null && from.containsKey(id) && ! done.contains(id) && ! path.contains(id)) {
566               path.add(id);
567               id = from.get(id);
568            }
569            if (id != null && path.contains(id)) {
570               var cycle = path.subList(path.indexOf(id), path.size());
571               var min = Collections.min(cycle);
572               var offset = cycle.indexOf(min);
573               var rotated = new ArrayList<String>();
574               for (var i = 0; i < cycle.size(); i++)
575                  rotated.add(cycle.get((offset + i) % cycle.size()));
576               cycles.put(min, rotated);
577            }
578            done.addAll(path);
579         }
580         for (var e : cycles.entrySet()) {
581            var c = e.getValue();
582            if (c.size() == 1)
583               add("E-76", E76, e.getKey(), "derived source '" + e.getKey() + "' references itself");
584            else
585               add("E-76", E76, e.getKey(), "derived source cycle: " + String.join(" -> ", c) + " -> " + e.getKey());
586         }
587      }
588
589      void checkTokens(String id, String field, String url, Set<String> mapped) {
590         if (url == null)
591            return;
592         var seen = new HashSet<String>();
593         var m = URL_TOKEN.matcher(url);
594         while (m.find())
595            if (! mapped.contains(m.group(1)) && seen.add(m.group(1)))
596               add("E-47", E47_TOKEN, id, m.group(1), field);
597      }
598
599      void checkSubscription(Map<?,?> card, Map<?,?> sub) {
600         var id = str(card.get("id"));
601         var topic = str(sub.get("topic"));
602         if (topic == null || ! TOPIC.matcher(topic).matches())
603            add("E-40", E40, topic);
604         else
605            checkPublisher(id, topic);
606         checkRole(card, str(sub.get("as")));
607      }
608
609      void checkPublisher(String id, String topic) {
610         var family = family(topic);
611         var key = key(topic);
612         if ("data".equals(family) && key != null) {
613            dataReads.add(topic);   // R-E1 counts publishers in checkDataTopics (E-71), not E-41
614            return;
615         }
616         if (FRAMEWORK_FAMILIES.contains(family) && key != null) {
617            if (CARD_FAMILIES.contains(family)) {
618               if (key.equals(id)) {
619                  add("E-49", E49, id, topic);
620                  return;
621               }
622               var target = cards.get(key);
623               if (target == null) {
624                  if (! targets.contains(key))
625                     add("E-42", E42, topic, key);
626                  // R-E3a: any cmd: may name a data source or section; R-E7: a section owns filter:<sectionId>.
627                  // Every other family keyed to a source or section has no publisher (R-E3a), so it is E-41
628                  // unless a declared topic matches it (isPublished covers script-declared topics, e.g. a wildcard).
629                  else if (! family.equals("cmd") && ! (family.equals("filter") && sectionIds.contains(key)) && ! isPublished(topic))
630                     add("E-41", E41, id, topic, suggest(topic));
631                  return;
632               }
633               if (family.equals("card") || family.equals("cmd"))
634                  return;
635               var type = str(target.get("type"));
636               if (type == null || ! registry.isRegistered(type) || published.contains(topic))
637                  return;
638               if ("datatables".equals(type) && DATATABLES_OPT_IN.contains(family))
639                  add("E-41", E41, id, topic, f(E41_NOT_ENABLED, key, family));
640               else
641                  add("E-43", E43_PUBLISH, key, type, topic);
642               return;
643            }
644            // probe: and badge: are owned by toolkit runtimes the contract does not describe; JS checks them.
645            if (family.equals("probe") || family.equals("badge"))
646               return;
647         }
648         if (! isPublished(topic))
649            add("E-41", E41, id, topic, suggest(topic));
650      }
651
652      void checkRole(Map<?,?> card, String as) {
653         if (as == null || SHELL_ROLES.contains(as))
654            return;
655         var type = str(card.get("type"));
656         if (type == null || ! registry.isRegistered(type))
657            return;
658         var roles = registry.handler(type).acceptedRoles();
659         if (roles == null || roles.contains(as))
660            return;
661         var accepted = new TreeSet<>(SHELL_ROLES);
662         accepted.addAll(roles);
663         add("E-44", E44, str(card.get("id")), type, as, String.join(", ", accepted));
664      }
665
666      // ---- ribbon items (E-42, E-43, E-48) ----------------------------------------------------------------------
667
668      void checkRibbonItem(Map<?,?> card, int index, Map<?,?> item) {
669         var id = str(card.get("id"));
670         var type = str(item.get("type"));
671         var label = "#" + index + " (" + type + ")";
672         if ("publish".equals(type)) {
673            checkPublishItem(id, label, item);
674            return;
675         }
676         var target = item.get("target");
677         if (target == null)
678            return;
679         if (type == null || ! RIBBON_OPS.containsKey(type)) {
680            add("E-48", E48, label, id, "'target' is not allowed on a '" + type + "' item");
681            return;
682         }
683         var tid = str(target);
684         var tc = tid == null ? null : cards.get(tid);
685         var op = RIBBON_OPS.get(type);
686         if (tc == null) {
687            if (tid == null || ! targets.contains(tid))
688               add("E-42", E42, "cmd:" + tid, tid);
689            // The op the ribbon actually sends (a refresh item sends "reload") must be one a source handles (R-E3a).
690            else if (sourceIds.contains(tid) && ! SOURCE_OPS.contains(op))
691               add("E-43", E43_OP, tid, SOURCE_TYPE, op);
692            return;
693         }
694         var ttype = str(tc.get("type"));
695         if (ttype != null && registry.isRegistered(ttype)) {
696            var ops = registry.handler(ttype).acceptedOps();
697            if (ops != null && ! ops.contains(op)) {
698               add("E-43", E43_OP, tid, ttype, op);
699               return;
700            }
701         }
702         if (op.equals("set-filter") && ! tid.equals(id)) {
703            var option = str(item.get("id"));
704            if (! declaredOptions(tc).contains(option))
705               add("E-48", E48, label, id, "option '" + option + "' is not declared on target card '" + tid + "'");
706         }
707      }
708
709      void checkPublishItem(String id, String label, Map<?,?> item) {
710         if (str(item.get("title")) == null)
711            add("E-48", E48, label, id, "a publish item needs a title");
712         var topic = str(item.get("topic"));
713         if (topic == null || ! TOPIC.matcher(topic).matches()) {
714            add("E-40", E40, topic);
715            return;
716         }
717         var family = family(topic);
718         if (FRAMEWORK_FAMILIES.contains(family)) {
719            var key = key(topic);
720            if (family.equals("cmd") && key != null) {
721               if (! targets.contains(key))
722                  add("E-42", E42, topic, key);
723               else {
724                  checkSourceOp(key, item);
725                  checkGroupCmd(id, key, item);
726               }
727               return;
728            }
729            add("E-48", E48, label, id, "'" + topic + "' is a framework topic; a publish item may only publish custom topics or cmd:<cardId>");
730            return;
731         }
732         if (! declaredCustom(topic, Set.of("script", "server", "ribbon"), true))
733            add("E-48", E48, label, id, "topic '" + topic + "' is not declared in topics or by any card's publishes");
734      }
735
736      // R-E3a (E-43): a data source handles only SOURCE_OPS; a payload without an op passes.
737      void checkSourceOp(String key, Map<?,?> item) {
738         if (! sourceIds.contains(key) || cards.containsKey(key) || ! (item.get("payload") instanceof Map<?,?> p))
739            return;
740         var op = str(p.get("op"));
741         if (op != null && ! SOURCE_OPS.contains(op))
742            add("E-43", E43_OP, key, SOURCE_TYPE, op);
743      }
744
745      /**
746       * The option ids a card declares on its own ribbon: its untargeted (or self-targeted) {@code option} and
747       * {@code optionGroup} items.  C2's {@code tableCatalog} has no {@code filterOptions} key, so the ribbon is the
748       * only place an option id can be declared.
749       */
750      Set<String> declaredOptions(Map<?,?> card) {
751         var out = new HashSet<String>();
752         var cid = str(card.get("id"));
753         for (var item : ribbon(card)) {
754            var type = str(item.get("type"));
755            var target = str(item.get("target"));
756            if (("option".equals(type) || "optionGroup".equals(type)) && (target == null || target.equals(cid)) && str(item.get("id")) != null)
757               out.add(str(item.get("id")));
758         }
759         return out;
760      }
761
762      // ---- bridges (E-51, E-53, E-54) and R-11 (E-52) -----------------------------------------------------------
763
764      void checkBridges() {
765         var seen = new HashSet<String>();
766         for (var b : bridges) {
767            var id = str(b.get("id"));
768            var name = id == null ? "?" : id;
769            if (id == null || ! BRIDGE_ID.matcher(id).matches())
770               add("E-51", E51, name, "id must match " + BRIDGE_ID.pattern());
771            else if (! seen.add(id))
772               add("E-51", E51, name, "duplicate id");
773            var transport = str(b.get("transport"));
774            if (! "sse".equals(transport) && ! "websocket".equals(transport))
775               add("E-51", E51, name, "unknown transport '" + transport + "'");
776            var session = str(b.get("session"));
777            if (session == null || ! SAME_ORIGIN_PATH.matcher(session).matches())
778               add("E-51", E51, name, "session must be a same-origin path, got '" + session + "'");
779            var max = b.get("maxAttempts");
780            if (max != null && ! ((max instanceof Integer || max instanceof Long) && ((Number)max).longValue() >= 1))
781               add("E-51", E51, name, "maxAttempts must be an integer of at least 1");
782            var down = strings(b.get("downstream"));
783            var up = strings(b.get("upstream"));
784            if (! up.isEmpty() && ! "websocket".equals(transport))
785               add("E-51", E51, name, "upstream is only allowed on a websocket bridge");
786            for (var d : down)
787               checkDownstream(name, d);
788            for (var u : up)
789               checkUpstream(name, u);
790            for (var d : down)
791               if (up.contains(d))
792                  add("E-51", E51, name, "topic '" + d + "' is both downstream and upstream");
793         }
794      }
795
796      void checkDownstream(String bridge, String topic) {
797         if (! "job:*".equals(topic) && ! TOPIC.matcher(topic).matches() && ! CUSTOM_DECL.matcher(topic).matches()) {
798            add("E-51", E51, bridge, "invalid topic '" + topic + "'");
799            return;
800         }
801         var family = family(topic);
802         if (FRAMEWORK_FAMILIES.contains(family)) {
803            var key = key(topic);
804            if (family.equals("job"))
805               return;
806            if (family.equals("cmd") && key != null) {
807               if (! cards.containsKey(key))
808                  add("E-42", E42, topic, key);
809               return;
810            }
811            add("E-54", E54, bridge, topic);
812            return;
813         }
814         if (! declaredCustom(topic, Set.of("server"), false))
815            add("E-51", E51, bridge, "downstream '" + topic + "' is not declared in topics with publisher=server");
816      }
817
818      void checkUpstream(String bridge, String topic) {
819         if (! TOPIC.matcher(topic).matches() && ! CUSTOM_DECL.matcher(topic).matches()) {
820            add("E-51", E51, bridge, "invalid topic '" + topic + "'");
821            return;
822         }
823         if (FRAMEWORK_FAMILIES.contains(family(topic))) {
824            add("E-54", E54, bridge, topic);
825            return;
826         }
827         if (! declaredCustom(topic, Set.of("script", "ribbon"), true))
828            add("E-53", E53, bridge, topic);
829      }
830
831      /** A top-level topics entry with one of {@code publishers}, or (when {@code cardPublishes}) a card's publishes, matches. */
832      boolean declaredCustom(String topic, Set<String> publishers, boolean cardPublishes) {
833         for (var t : topics) {
834            var decl = str(t.get("topic"));
835            if (decl != null && str(t.get("publisher")) != null && publishers.contains(str(t.get("publisher"))) && topicMatches(decl, topic))
836               return true;
837         }
838         if (cardPublishes)
839            for (var c : cards.values())
840               for (var p : maps(c.get("publishes"))) {
841                  var decl = str(p.get("topic"));
842                  if (decl != null && topicMatches(decl, topic))
843                     return true;
844               }
845         return false;
846      }
847
848      /** E-58: a terminal card with {@code input: true} must carry the whole input wiring (publish, subscribe, server topic, bridge). */
849      @SuppressWarnings({
850         "java:S135" // Guard clauses keep the card filtering readable
851      })
852      void checkTerminalInput() {
853         for (var c : cards.values()) {
854            if (! "terminal".equals(str(c.get("type"))))
855               continue;
856            if (! (c.get("terminal") instanceof Map<?,?> term) || ! Boolean.TRUE.equals(term.get("input")))
857               continue;
858            var id = str(c.get("id"));
859            var publishes = false;
860            for (var p : maps(c.get("publishes")))
861               publishes |= "term.input".equals(str(p.get("topic")));
862            if (! publishes)
863               add("E-58", E58, id, "its publishes lacks 'term.input'");
864            var subscribes = false;
865            for (var s : maps(c.get("subscribes")))
866               subscribes |= "term.input-status".equals(str(s.get("topic"))) && "input-status".equals(str(s.get("as")));
867            if (! subscribes)
868               add("E-58", E58, id, "its subscribes lacks 'term.input-status' as 'input-status'");
869            var declared = false;
870            for (var t : topics)
871               declared |= "term.input-status".equals(str(t.get("topic"))) && "server".equals(str(t.get("publisher")));
872            if (! declared)
873               add("E-58", E58, id, "no topics entry declares 'term.input-status' with publisher=server");
874            var bridged = false;
875            for (var b : bridges)
876               bridged |= "websocket".equals(str(b.get("transport"))) && strings(b.get("upstream")).contains("term.input")
877                  && strings(b.get("downstream")).contains("term.input-status");
878            if (! bridged)
879               add("E-58", E58, id, "no websocket bridge carries 'term.input' upstream and 'term.input-status' downstream");
880         }
881      }
882
883      void checkServerTopics() {
884         for (var t : topics) {
885            var topic = str(t.get("topic"));
886            // A data: topic published by the server is already E-45 (it must be script-published).
887            if (topic == null || topic.startsWith("data:") || ! "server".equals(str(t.get("publisher"))))
888               continue;
889            var covered = false;
890            for (var b : bridges)
891               for (var d : strings(b.get("downstream")))
892                  covered |= topicMatches(d, topic);
893            if (! covered)
894               add("E-52", E52, topic);
895         }
896      }
897   }
898
899   // ---- JSON helpers -----------------------------------------------------------------------------------------------
900
901   static String family(String topic) {
902      var i = topic.indexOf(':');
903      return i < 0 ? topic : topic.substring(0, i);
904   }
905
906   static String key(String topic) {
907      var i = topic.indexOf(':');
908      return i < 0 ? null : topic.substring(i + 1);
909   }
910
911   static String str(Object o) {
912      return o instanceof String s ? s : null;
913   }
914
915   @SuppressWarnings({
916      "java:S1452" // wildcard return reflects heterogeneous element types
917   })
918   static List<Map<?,?>> maps(Object o) {
919      var out = new ArrayList<Map<?,?>>();
920      if (o instanceof List<?> l)
921         for (var e : l)
922            if (e instanceof Map<?,?> m)
923               out.add(m);
924      return out;
925   }
926
927   static List<String> strings(Object o) {
928      var out = new ArrayList<String>();
929      if (o instanceof List<?> l)
930         for (var e : l)
931            if (e instanceof String s)
932               out.add(s);
933      return out;
934   }
935
936   /** A datatables card's catalog (its {@code table}), or the card itself for every other shape. */
937   @SuppressWarnings({
938      "java:S1452" // wildcard return reflects heterogeneous element types
939   })
940   static Map<?,?> view(Map<?,?> card) {
941      return card.get("table") instanceof Map<?,?> t ? t : card;
942   }
943
944   @SuppressWarnings({
945      "java:S1452" // wildcard return reflects heterogeneous element types
946   })
947   static List<Map<?,?>> ribbon(Map<?,?> card) {
948      return maps(view(card).get("ribbon"));
949   }
950
951   @SuppressWarnings({
952      "unchecked" // Narrows a JSON-shaped Map<?,?> to Map<String,Object>; its keys are always strings.
953   })
954   static JsonMap json(Map<?,?> m) {
955      if (m instanceof JsonMap j)
956         return j;
957      var j = new JsonMap();
958      j.putAll((Map<String,Object>)m);
959      return j;
960   }
961}