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}