usedesign 1.5.0 → 1.5.2

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/dist/checks.js CHANGED
@@ -1,16 +1,18 @@
1
1
  /**
2
- * The three checks. Each one has the same shape: the repository states a fact in a format it
2
+ * The five checks. Each one has the same shape: the repository states a fact in a format it
3
3
  * already produces, and usedesign compares that statement with the cards. Nothing here parses a
4
4
  * framework, reads source code, or guesses.
5
5
  *
6
- * 1. no wild endpoints design/route-conformance.md
7
- * 2. no unproven steps design/step-coverage.md
8
- * 3. no inflated maturity design/maturity-evidence.md
6
+ * 1. no wild endpoints design/route-conformance.md
7
+ * 2. no unproven steps design/step-coverage.md
8
+ * 3. no inflated maturity design/maturity-evidence.md
9
+ * 4. no imagined storage SPEC §7.4
10
+ * 5. the form matches its contract SPEC §7.5
9
11
  */
10
12
  import { existsSync, readFileSync } from "node:fs";
11
13
  import { join } from "node:path";
12
- import { XMLParser } from "fast-xml-parser";
13
- import { Finding, expandGlobs, loadCardFiles, readDocument } from "./core.js";
14
+ import { SaxesParser } from "saxes";
15
+ import { Finding, expandGlobs, globMatches, isMap, loadCardFiles, map, maps, readDocument, refuseTwins, text, texts, whyUnreadable, } from "./core.js";
14
16
  const METHODS = ["GET", "POST", "PUT", "PATCH", "DELETE", "HEAD", "OPTIONS"];
15
17
  /**
16
18
  * Parameter syntaxes seen in the wild: {name} · :name · <name> · <converter:name>
@@ -42,11 +44,12 @@ function loadDeclared(config, base) {
42
44
  const maturityById = new Map();
43
45
  const { cards, findings } = loadCardFiles(config, base);
44
46
  for (const [cardId, fm] of cards) {
45
- maturityById.set(cardId, String(fm["maturity"] ?? ""));
46
- for (const [name, iface] of Object.entries(fm["interfaces"] ?? {})) {
47
- if (!iface || typeof iface !== "object" || iface.transport !== "http_rest")
47
+ maturityById.set(cardId, text(fm["maturity"]) ?? "");
48
+ for (const [name, iface] of Object.entries(map(fm["interfaces"]))) {
49
+ if (!isMap(iface) || iface["transport"] !== "http_rest")
48
50
  continue;
49
- const { method, path } = iface;
51
+ const method = text(iface["method"]);
52
+ const path = text(iface["path"]);
50
53
  if (!method || !path) {
51
54
  findings.push(new Finding("incomplete_rest_interface", `${cardId}: interface \`${name}\` declares http_rest without method or path`));
52
55
  continue;
@@ -54,12 +57,14 @@ function loadDeclared(config, base) {
54
57
  // The key stays the route shape — that is what the inventory can be compared against.
55
58
  // Dispatch tells cards apart from each other, not routes from each other.
56
59
  const key = normalise(method, path);
57
- const d = iface.dispatch;
60
+ const d = map(iface["dispatch"]);
61
+ const by = text(d["by"]);
62
+ const value = text(d["value"]);
58
63
  declared.set(key, [...(declared.get(key) ?? []), cardId]);
59
- dispatchOf.set(`${key}\u0000${cardId}`, d?.by && d?.value ? `${d.by}=${d.value}` : "");
64
+ dispatchOf.set(`${key}\u0000${cardId}`, by && value ? `${by}=${value}` : "");
60
65
  }
61
66
  }
62
- for (const [key, owners] of [...declared.entries()].sort()) {
67
+ for (const [key, owners] of byKey(declared)) {
63
68
  if (owners.length < 2)
64
69
  continue;
65
70
  // Several operations may legitimately share a route, told apart by a request field — a
@@ -72,6 +77,14 @@ function loadDeclared(config, base) {
72
77
  }
73
78
  return { declared, maturityById, findings };
74
79
  }
80
+ /**
81
+ * Declared routes in the order of their `METHOD shape` (1.5.1). Sorted as pairs, they were ordered
82
+ * by the text `GET /a,owner` — `GET /a+b` before `GET /a` — and the Python prototype by the pair
83
+ * itself, so two routes could print in a different order in each.
84
+ */
85
+ function byKey(declared) {
86
+ return [...declared.entries()].sort((a, b) => (a[0] < b[0] ? -1 : a[0] > b[0] ? 1 : 0));
87
+ }
75
88
  function loadInventory(config, base) {
76
89
  const location = config.inventory;
77
90
  if (!location) {
@@ -103,17 +116,13 @@ function loadInventory(config, base) {
103
116
  }
104
117
  return { routes, findings, producedBy: data.produced_by ?? "" };
105
118
  }
106
- /** Shell-style match for exclusion patterns: `*` and `?` do not cross a `/`. */
107
- function matchesPattern(shape, pattern) {
108
- const escaped = pattern.replace(/[.+^${}()|[\]\\]/g, "\\$&").replace(/\*/g, "[^/]*").replace(/\?/g, "[^/]");
109
- return new RegExp(`^${escaped}$`).test(shape);
110
- }
119
+ /** The rule that hides this route, by the glob of a `cards:` segment (1.5.1), or -1. */
111
120
  function excludedBy(config, method, shape) {
112
121
  const rules = config.exclude ?? [];
113
122
  for (const [index, rule] of rules.entries()) {
114
123
  if (rule.method && rule.method.toUpperCase() !== method)
115
124
  continue;
116
- if (matchesPattern(shape, rule.path ?? ""))
125
+ if (globMatches(shape, rule.path ?? ""))
117
126
  return index;
118
127
  }
119
128
  return -1;
@@ -140,7 +149,7 @@ export function checkRoutes(config, base) {
140
149
  findings.push(new Finding("wild_endpoint", `${key} is served but declared by no card${entry.source ? ` (source: ${entry.source})` : ""}`));
141
150
  }
142
151
  }
143
- for (const [key, owners] of [...declared.entries()].sort()) {
152
+ for (const [key, owners] of byKey(declared)) {
144
153
  const method = key.slice(0, key.indexOf(" "));
145
154
  const shape = key.slice(key.indexOf(" ") + 1);
146
155
  if (routes.length > 0 && !served.has(key) && excludedBy(config, method, shape) === -1) {
@@ -188,6 +197,55 @@ export function checkRoutes(config, base) {
188
197
  // The repository states the facts, the checker compares — the same shape as check 1, except that
189
198
  // here the format already exists (JUnit XML), so nothing new is invented.
190
199
  const PARAMETRISED = /[[(].*$/; // checkout[blocked] · checkout(1) → checkout
200
+ /**
201
+ * The test cases of a report, read as XML 1.0 reads it: a `testcase` element in no namespace, at any
202
+ * depth, in document order; its `classname` and `name`; and its status from its own children —
203
+ * `failure` or `error` fails it, `skipped` skips it, in any letter case, and the last of them decides.
204
+ *
205
+ * 1.5.1 read the report with a lenient parser: a report cut off while it was written read as far as
206
+ * it went, so a test cut before its `<failure>` passed; past 1 000 escaped characters (`&quot;`) the
207
+ * report was refused; `&#233;` stayed six characters. The Python prototype read all three as XML
208
+ * reads them; both do now (1.5.2). A DOCTYPE is refused in both: JUnit reports carry none, and its
209
+ * entities are what the expansion attacks are made of.
210
+ */
211
+ function readReport(text) {
212
+ class Doctype extends Error {
213
+ }
214
+ const parser = new SaxesParser({ xmlns: true });
215
+ const cases = [];
216
+ const open = [];
217
+ parser.on("doctype", () => {
218
+ throw new Doctype();
219
+ });
220
+ parser.on("opentag", (tag) => {
221
+ const parent = open[open.length - 1];
222
+ if (parent && tag.uri === "") {
223
+ const local = tag.local.toLowerCase();
224
+ if (local === "failure" || local === "error")
225
+ parent.status = "failed";
226
+ else if (local === "skipped")
227
+ parent.status = "skipped";
228
+ }
229
+ let own = null;
230
+ if (tag.uri === "" && tag.local === "testcase") {
231
+ const classname = tag.attributes["classname"]?.value ?? "";
232
+ const name = tag.attributes["name"]?.value ?? "";
233
+ own = { classname, name, status: "passed", full: classname ? `${classname}.${name}` : name };
234
+ cases.push(own);
235
+ }
236
+ open.push(own);
237
+ });
238
+ parser.on("closetag", () => {
239
+ open.pop();
240
+ });
241
+ try {
242
+ parser.write(text).close();
243
+ }
244
+ catch (exc) {
245
+ return exc instanceof Doctype ? "doctype" : "malformed";
246
+ }
247
+ return cases;
248
+ }
191
249
  function loadReport(config, base) {
192
250
  const location = config.test_report;
193
251
  if (!location) {
@@ -199,40 +257,20 @@ function loadReport(config, base) {
199
257
  const path = join(base, location);
200
258
  if (!existsSync(path))
201
259
  return { cases: [], findings: [new Finding("report_missing", `${location} does not exist`)] };
202
- let tree;
203
- try {
204
- tree = new XMLParser({ ignoreAttributes: false, attributeNamePrefix: "@_", isArray: (name) => name === "testcase" }).parse(readFileSync(path, "utf8"));
260
+ // 1.5.1 named a directory here as malformed XML, and the Python prototype stopped with a traceback.
261
+ const why = whyUnreadable(path);
262
+ if (why)
263
+ return { cases: [], findings: [new Finding("report_missing", `${location} cannot be read: ${why}`)] };
264
+ const cases = readReport(readFileSync(path, "utf8"));
265
+ if (cases === "doctype") {
266
+ return { cases: [], findings: [new Finding("report_malformed", `${location}: a DOCTYPE — a JUnit report has none, and check 2 reads none`)] };
205
267
  }
206
- catch (exc) {
207
- return { cases: [], findings: [new Finding("report_malformed", `${location}: ${exc.message}`)] };
268
+ if (cases === "malformed") {
269
+ return {
270
+ cases: [],
271
+ findings: [new Finding("report_malformed", `${location}: not well-formed XML — a run that stopped while writing its report leaves such a file`)],
272
+ };
208
273
  }
209
- const cases = [];
210
- const walk = (node) => {
211
- if (!node || typeof node !== "object")
212
- return;
213
- for (const [key, value] of Object.entries(node)) {
214
- if (key === "testcase") {
215
- for (const item of Array.isArray(value) ? value : [value]) {
216
- let status = "passed";
217
- for (const child of Object.keys(item ?? {})) {
218
- const tag = child.toLowerCase();
219
- if (tag === "failure" || tag === "error")
220
- status = "failed";
221
- else if (tag === "skipped")
222
- status = "skipped";
223
- }
224
- const classname = String(item?.["@_classname"] ?? "");
225
- const name = String(item?.["@_name"] ?? "");
226
- cases.push({ classname, name, status, full: classname ? `${classname}.${name}` : name });
227
- }
228
- }
229
- else if (typeof value === "object") {
230
- for (const item of Array.isArray(value) ? value : [value])
231
- walk(item);
232
- }
233
- }
234
- };
235
- walk(tree);
236
274
  if (cases.length === 0) {
237
275
  return { cases, findings: [new Finding("report_empty", `${location}: no test cases — a failed run, not a suite without tests`)] };
238
276
  }
@@ -301,9 +339,9 @@ export function checkCoverage(config, base) {
301
339
  for (const [cardId, fm] of cards) {
302
340
  const byStep = new Map();
303
341
  ownProof.set(cardId, byStep);
304
- for (const test of (fm["tests"] ?? [])) {
305
- const refs = Array.isArray(test?.covers) ? test.covers : [test?.covers];
306
- const testId = test?.id ?? "";
342
+ for (const test of maps(fm["tests"])) {
343
+ const refs = coversOf(test);
344
+ const testId = text(test["id"]) ?? "";
307
345
  const matched = haveReport ? findCases(testId, byFull, byName) : [];
308
346
  if (haveReport) {
309
347
  if (matched.length === 0) {
@@ -333,14 +371,14 @@ export function checkCoverage(config, base) {
333
371
  // Pass 2 — verdicts, with a variant's inherited steps credited from the original.
334
372
  const byId = new Map(cards);
335
373
  for (const [cardId, fm] of cards) {
336
- const steps = (fm["steps"] ?? []).map((s) => s?.id);
337
- const gaps = new Set((fm["coverage_gaps"] ?? []).map((g) => g?.step));
374
+ const steps = stepIdsOf(fm);
375
+ const gaps = gapStepsOf(fm);
338
376
  const byStep = ownProof.get(cardId);
339
377
  const { credited, explained } = inheritance(cardId, fm, steps, byStep, byId, ownProof, haveReport, findings);
340
378
  // Round 26 ⑬: a gap says why the proof is missing — counted apart, so "not written yet" is
341
379
  // never read as "cannot be written".
342
- for (const entry of (fm["coverage_gaps"] ?? [])) {
343
- const kind = String(entry?.kind ?? "unwritten");
380
+ for (const entry of maps(fm["coverage_gaps"])) {
381
+ const kind = text(entry["kind"]) || "unwritten";
344
382
  if (kind === "unwritten" || kind === "harness" || kind === "unreachable")
345
383
  gapKinds[kind] += 1;
346
384
  }
@@ -373,6 +411,19 @@ export function checkCoverage(config, base) {
373
411
  },
374
412
  };
375
413
  }
414
+ /** The steps a test covers: one id or a list of them (1.5.1: an id of another type is absent). */
415
+ function coversOf(test) {
416
+ const covers = test["covers"];
417
+ return typeof covers === "string" ? [covers] : texts(covers);
418
+ }
419
+ /** A card's step ids, in order; a step that is not a map, or an id that is not a string, is absent. */
420
+ function stepIdsOf(fm) {
421
+ return texts(maps(fm["steps"]).map((step) => step["id"]));
422
+ }
423
+ /** The steps a card declares a gap for. */
424
+ function gapStepsOf(fm) {
425
+ return new Set(texts(maps(fm["coverage_gaps"]).map((gap) => gap["step"])));
426
+ }
376
427
  /**
377
428
  * Which inherited steps of a variant (§5.2e) the original's own tests prove. `explained` holds the
378
429
  * steps whose missing credit a variant finding has already reported, so check 2 does not repeat it
@@ -382,18 +433,18 @@ function inheritance(cardId, fm, steps, byStep, byId, ownProof, haveReport, find
382
433
  const credited = new Set();
383
434
  const explained = new Set();
384
435
  const variant = fm["variant_of"];
385
- if (!variant || typeof variant !== "object")
436
+ if (!isMap(variant))
386
437
  return { credited, explained };
387
- const inherits = (Array.isArray(variant.inherits) ? variant.inherits : []).filter((s) => typeof s === "string");
388
- const originalId = String(variant.card ?? "");
438
+ const inherits = texts(variant["inherits"]);
439
+ const originalId = text(variant["card"]) ?? "";
389
440
  const original = byId.get(originalId);
390
441
  if (!original) {
391
442
  findings.push(new Finding("variant_of_unknown", `${cardId}: \`variant_of\` names \`${originalId}\`, which no card in the set declares`));
392
443
  inherits.forEach((s) => explained.add(s));
393
444
  return { credited, explained };
394
445
  }
395
- const shared = String(variant.shared ?? "").trim().replace(LINE_SUFFIX, "");
396
- const codeShared = evidencePaths(original["maturity_evidence"]?.["implemented"]).includes(shared);
446
+ const shared = (text(variant["shared"]) ?? "").trim().replace(LINE_SUFFIX, "");
447
+ const codeShared = evidencePaths(map(original["maturity_evidence"])["implemented"]).includes(shared);
397
448
  if (!codeShared) {
398
449
  findings.push(new Finding("variant_code_not_shared", `${cardId}: \`${shared}\` is not in the \`implemented\` evidence of \`${originalId}\` — nothing shows the two run the same code`));
399
450
  }
@@ -403,8 +454,8 @@ function inheritance(cardId, fm, steps, byStep, byId, ownProof, haveReport, find
403
454
  if (!wired) {
404
455
  findings.push(new Finding("variant_unwired", `${cardId}: no passing test of its own covers its last step \`${last ?? "(none)"}\` — ${inherits.length} inherited step(s) get no credit`, haveReport ? "error" : "warning"));
405
456
  }
406
- const originalSteps = new Set((original["steps"] ?? []).map((s) => s?.id));
407
- const originalGaps = new Set((original["coverage_gaps"] ?? []).map((g) => g?.step));
457
+ const originalSteps = new Set(stepIdsOf(original));
458
+ const originalGaps = gapStepsOf(original);
408
459
  const originalProof = ownProof.get(originalId) ?? new Map();
409
460
  for (const step of inherits) {
410
461
  if (!originalSteps.has(step)) {
@@ -429,12 +480,15 @@ function inheritance(cardId, fm, steps, byStep, byId, ownProof, haveReport, find
429
480
  const LEVELS = ["conceived", "designed", "implemented", "tested", "in_production", "deprecated"];
430
481
  const LINE_SUFFIX = /:\d+$/;
431
482
  const DEFAULT_HORIZON_DAYS = 180;
432
- /** `implemented` is one path or several — one example joins two with ' + '. */
483
+ /**
484
+ * `implemented` is one path or several — one example joins two with ' + '. A value that is not a
485
+ * string is absent (1.5.1): `true` was the path `true` here and `True` in the Python prototype.
486
+ */
433
487
  export function evidencePaths(value) {
434
- const values = Array.isArray(value) ? value : [value];
488
+ const values = typeof value === "string" ? [value] : texts(value);
435
489
  const paths = [];
436
490
  for (const item of values) {
437
- for (const part of String(item).split("+")) {
491
+ for (const part of item.split("+")) {
438
492
  const trimmed = part.trim().replace(LINE_SUFFIX, "");
439
493
  if (trimmed)
440
494
  paths.push(trimmed);
@@ -466,11 +520,11 @@ export function checkMaturity(config, base, today = new Date()) {
466
520
  findings.push(...cardFindings);
467
521
  let checkedPaths = 0;
468
522
  for (const [cardId, fm] of cards) {
469
- const maturity = fm["maturity"];
523
+ const maturity = text(fm["maturity"]) ?? "";
470
524
  if (!LEVELS.includes(maturity))
471
525
  continue;
472
526
  const level = LEVELS.indexOf(maturity);
473
- const evidence = fm["maturity_evidence"] ?? {};
527
+ const evidence = map(fm["maturity_evidence"]);
474
528
  // Tier 1 — the code the card points at exists.
475
529
  if (level >= LEVELS.indexOf("implemented") && evidence["implemented"] && root) {
476
530
  for (const path of evidencePaths(evidence["implemented"])) {
@@ -483,14 +537,13 @@ export function checkMaturity(config, base, today = new Date()) {
483
537
  // Tier 2 — the claim is derived from the report, not from prose.
484
538
  const passing = [];
485
539
  const covered = new Set();
486
- for (const test of (fm["tests"] ?? [])) {
487
- const matched = haveReport ? findCases(test?.id ?? "", byFull, byName) : [];
540
+ for (const test of maps(fm["tests"])) {
541
+ const testId = text(test["id"]) ?? "";
542
+ const matched = haveReport ? findCases(testId, byFull, byName) : [];
488
543
  if (matched.length > 0 && matched.every((c) => c.status === "passed")) {
489
- passing.push(test?.id);
490
- for (const ref of Array.isArray(test?.covers) ? test.covers : [test?.covers]) {
491
- if (typeof ref === "string")
492
- covered.add(ref);
493
- }
544
+ passing.push(testId);
545
+ for (const ref of coversOf(test))
546
+ covered.add(ref);
494
547
  }
495
548
  }
496
549
  if (level >= LEVELS.indexOf("tested") && passing.length === 0) {
@@ -502,16 +555,16 @@ export function checkMaturity(config, base, today = new Date()) {
502
555
  // And only when a passing test covers the LAST step — the proof §5.2e rule 4 accepts that an
503
556
  // operation goes all the way through. Tests of the door alone (sign-in, permission) pass in
504
557
  // front of an operation that answers 501; that card is honestly `designed`.
505
- const steps = Array.isArray(fm["steps"]) ? fm["steps"] : [];
506
- const stepIds = steps.filter((s) => s && typeof s === "object" && !Array.isArray(s)).map((s) => s.id);
558
+ const stepIds = stepIdsOf(fm);
507
559
  const last = stepIds.length > 0 ? stepIds[stepIds.length - 1] : undefined;
508
- if (haveReport && level < LEVELS.indexOf("implemented") && typeof last === "string" && covered.has(last)) {
560
+ if (haveReport && level < LEVELS.indexOf("implemented") && last !== undefined && covered.has(last)) {
509
561
  findings.push(new Finding("maturity_understated", `${cardId}: claims \`${maturity}\` while a passing test of its own covers its last step \`${last}\` (${passing.length} of its tests pass)`, "warning"));
510
562
  }
511
563
  // Tier 3 — what cannot be verified must expire.
512
564
  if (level >= LEVELS.indexOf("in_production")) {
565
+ // A dated map or a bare string (the schema's two forms); any other value is absent (1.5.1).
513
566
  const deployed = evidence["deployed"];
514
- if (deployed && typeof deployed === "object" && !Array.isArray(deployed)) {
567
+ if (isMap(deployed)) {
515
568
  // A day the calendar has (1.4.3): `new Date` carried 2026-02-30 over to 2 March and called it
516
569
  // stale, where the Python prototype said it is not a date. Year 0 is none either.
517
570
  const raw = typeof deployed.since === "string" ? deployed.since : "";
@@ -525,7 +578,7 @@ export function checkMaturity(config, base, today = new Date()) {
525
578
  findings.push(new Finding("evidence_stale", `${cardId}: \`deployed\` was last affirmed ${age} days ago (horizon ${horizon}) — nobody has looked since`, "warning"));
526
579
  }
527
580
  }
528
- else if (deployed) {
581
+ else if (typeof deployed === "string" && deployed) {
529
582
  findings.push(new Finding("evidence_undated", `${cardId}: \`deployed: ${deployed}\` carries no date, so it can never go stale`, "warning"));
530
583
  }
531
584
  }
@@ -580,10 +633,10 @@ export function checkStorage(config, base) {
580
633
  const touched = new Set();
581
634
  let claims = 0;
582
635
  for (const [cardId, fm] of cards) {
583
- for (const claim of fm["data"]?.storage ?? []) {
636
+ for (const claim of maps(map(fm["data"])["storage"])) {
584
637
  claims += 1;
585
- const pattern = claim?.store ?? "";
586
- const matched = stores.filter((store) => matchesPattern(String(store.name), pattern));
638
+ const pattern = text(claim["store"]) ?? "";
639
+ const matched = stores.filter((store) => globMatches(String(store.name), pattern));
587
640
  if (matched.length === 0) {
588
641
  findings.push(new Finding("unknown_store", `${cardId}: claims store \`${pattern}\`, which the storage does not have`));
589
642
  continue;
@@ -591,7 +644,7 @@ export function checkStorage(config, base) {
591
644
  for (const store of matched)
592
645
  touched.add(String(store.name));
593
646
  // Every environment must agree, or the card is true in one place and false in another.
594
- const claimedKeys = claim?.keyed_by ?? [];
647
+ const claimedKeys = texts(claim["keyed_by"]);
595
648
  if (claimedKeys.length > 0) {
596
649
  for (const store of matched) {
597
650
  const actual = store.keyed_by ?? [];
@@ -600,7 +653,7 @@ export function checkStorage(config, base) {
600
653
  }
601
654
  }
602
655
  }
603
- const index = claim?.via_index;
656
+ const index = text(claim["via_index"]);
604
657
  if (index) {
605
658
  for (const store of matched) {
606
659
  const names = (store.indexes ?? []).map((i) => String(i.name));
@@ -706,9 +759,8 @@ function judgeCalls(contractId, name, control, shownWhen, dataOf, cardById, find
706
759
  // `from` is one state or a SET of states (round 18): the control must be shown only in
707
760
  // states that belong to the set, and in at least one of them. `none` / `any` switch the
708
761
  // rule off — `any` is the honest word for an operation that does not check its origin.
709
- const transition = card["data_transition"];
710
- const rawFrom = transition && typeof transition === "object" ? transition.from : undefined;
711
- const fromSet = Array.isArray(rawFrom) ? rawFrom.map(String) : rawFrom ? [String(rawFrom)] : [];
762
+ const rawFrom = map(card["data_transition"])["from"];
763
+ const fromSet = typeof rawFrom === "string" && rawFrom ? [rawFrom] : texts(rawFrom);
712
764
  if (fromSet.length > 0 && !fromSet.includes("none") && !fromSet.includes("any")) {
713
765
  const mismatch = shownWhen.filter((state) => !fromSet.includes(dataOf(state)));
714
766
  if (mismatch.length > 0 || !shownWhen.some((state) => fromSet.includes(dataOf(state)))) {
@@ -717,6 +769,14 @@ function judgeCalls(contractId, name, control, shownWhen, dataOf, cardById, find
717
769
  }
718
770
  }
719
771
  }
772
+ /** A list of state names, or undefined when the field is not a list (1.5.1: a name that is not a string is absent). */
773
+ function statesOf(value) {
774
+ return Array.isArray(value) ? texts(value) : undefined;
775
+ }
776
+ /** `at_least`, an integer, or 1. */
777
+ function floorOf(line) {
778
+ return Number.isInteger(line["at_least"]) ? Number(line["at_least"]) : 1;
779
+ }
720
780
  export function checkForm(config, base) {
721
781
  const findings = [];
722
782
  const patterns = config.forms ?? [];
@@ -767,30 +827,35 @@ export function checkForm(config, base) {
767
827
  }
768
828
  const cardById = new Map(cards);
769
829
  const contracts = [];
830
+ const read = [];
770
831
  for (const path of expandGlobs(base, patterns)) {
771
832
  const fm = readDocument(path);
772
- if (fm && fm["usedesign_form"] === 1)
773
- contracts.push([String(fm["id"] ?? path), fm]);
833
+ if (isMap(fm) && fm["usedesign_form"] === 1) {
834
+ contracts.push([text(fm["id"]) ?? path, fm]);
835
+ read.push([path, fm]);
836
+ }
774
837
  }
838
+ refuseTwins(read);
775
839
  if (contracts.length === 0) {
776
840
  findings.push(new Finding("form_contracts_missing", "the `forms` patterns matched no contract"));
777
841
  }
778
842
  const claimed = new Map();
779
843
  let designedAhead = 0;
780
844
  for (const [contractId, fm] of contracts) {
781
- const screen = String(fm["screen"] ?? "");
845
+ const screen = text(fm["screen"]) ?? "";
782
846
  const states = byScreen.get(screen);
783
847
  // A contract may be written before its screen — the format's own recommended order of
784
848
  // work — and say so with `maturity: designed` (issue #8, the forms twin of issue #3). Then
785
849
  // an absent screen is a warning, not the same error as a screen that vanished. The flag
786
850
  // cannot go stale silently: the day the screen renders, the contract still saying
787
851
  // `designed` is reported too — the gate itself tells the owner to flip it.
788
- const maturity = String(fm["maturity"] ?? "implemented");
852
+ const maturity = text(fm["maturity"]) || "implemented";
789
853
  // Screen state → data state, through the contract's `states:` map; identity when unmapped.
790
- const stateMap = fm["states"] && typeof fm["states"] === "object" ? fm["states"] : {};
854
+ // Looked up among the map's own keys: `toString` is not a state.
855
+ const stateMap = map(fm["states"]);
791
856
  const dataOf = (state) => {
792
- const spec = stateMap[state];
793
- return spec && typeof spec === "object" && spec.data ? String(spec.data) : state;
857
+ const spec = Object.prototype.hasOwnProperty.call(stateMap, state) ? stateMap[state] : undefined;
858
+ return (isMap(spec) && text(spec["data"])) || state;
794
859
  };
795
860
  if (!states) {
796
861
  if (maturity === "designed") {
@@ -800,9 +865,9 @@ export function checkForm(config, base) {
800
865
  // and comparing it with the cards needs no inventory (issue #13). Only where the config
801
866
  // declares `cards:`: a frontend-only repository has none, and stays as quiet as before.
802
867
  if (config.cards) {
803
- for (const control of fm["controls"] ?? []) {
804
- const name = control?.control_pattern ? String(control.control_pattern) : String(control?.control ?? "");
805
- judgeCalls(contractId, name, control, control?.shown_when, dataOf, cardById, findings, "warning");
868
+ for (const control of maps(fm["controls"])) {
869
+ const name = text(control["control_pattern"]) || text(control["control"]) || "";
870
+ judgeCalls(contractId, name, control, statesOf(control["shown_when"]), dataOf, cardById, findings, "warning");
806
871
  }
807
872
  }
808
873
  }
@@ -818,17 +883,18 @@ export function checkForm(config, base) {
818
883
  const mine = claimed.get(screen) ?? { fields: new Set(), controls: new Set(), fieldFamilies: [], controlFamilies: [] };
819
884
  claimed.set(screen, mine);
820
885
  const countOf = (regex, set) => [...set].filter((name) => regex.test(name)).length;
821
- for (const entry of fm["presents"] ?? []) {
822
- const pattern = entry?.field_pattern ? String(entry.field_pattern) : "";
886
+ for (const entry of maps(fm["presents"])) {
887
+ const pattern = text(entry["field_pattern"]) ?? "";
888
+ const when = statesOf(entry["when"]);
823
889
  if (pattern) {
824
890
  // A family: one element per item of a list that arrives at runtime. Every rendered
825
891
  // anchor matching the pattern is accounted for; `at_least` keeps the family from
826
892
  // silently becoming empty — a screen rendering NO tabs because the backend returned
827
893
  // nothing is a real failure, and without the floor nothing would notice.
828
894
  const regex = familyRegex(pattern);
829
- const atLeast = Number.isInteger(entry?.at_least) ? Number(entry.at_least) : 1;
895
+ const atLeast = floorOf(entry);
830
896
  mine.fieldFamilies.push(regex);
831
- for (const state of entry?.when ?? everyState) {
897
+ for (const state of when ?? everyState) {
832
898
  const rendered = states.get(state);
833
899
  if (!rendered) {
834
900
  findings.push(new Finding("form_state_missing", `${contractId}: \`${pattern}\` is required in state \`${state}\`, which the inventory never rendered`));
@@ -840,12 +906,12 @@ export function checkForm(config, base) {
840
906
  }
841
907
  }
842
908
  }
843
- fieldOutOfState(contractId, pattern, entry?.when, states, (rendered) => countOf(regex, rendered.fields) > 0, findings);
909
+ fieldOutOfState(contractId, pattern, when, states, (rendered) => countOf(regex, rendered.fields) > 0, findings);
844
910
  continue;
845
911
  }
846
- const field = String(entry?.field ?? "");
912
+ const field = text(entry["field"]) ?? "";
847
913
  mine.fields.add(field);
848
- for (const state of entry?.when ?? everyState) {
914
+ for (const state of when ?? everyState) {
849
915
  const rendered = states.get(state);
850
916
  if (!rendered) {
851
917
  findings.push(new Finding("form_state_missing", `${contractId}: \`${field}\` is required in state \`${state}\`, which the inventory never rendered`));
@@ -854,18 +920,18 @@ export function checkForm(config, base) {
854
920
  findings.push(new Finding("element_missing", `${contractId}: \`${field}\` must be shown in state \`${state}\` and is not`));
855
921
  }
856
922
  }
857
- fieldOutOfState(contractId, field, entry?.when, states, (rendered) => rendered.fields.has(field), findings);
923
+ fieldOutOfState(contractId, field, when, states, (rendered) => rendered.fields.has(field), findings);
858
924
  }
859
- for (const control of fm["controls"] ?? []) {
860
- const pattern = control?.control_pattern ? String(control.control_pattern) : "";
925
+ for (const control of maps(fm["controls"])) {
926
+ const pattern = text(control["control_pattern"]) ?? "";
861
927
  const regex = pattern ? familyRegex(pattern) : null;
862
- const atLeast = Number.isInteger(control?.at_least) ? Number(control.at_least) : 1;
863
- const name = pattern || String(control?.control ?? "");
928
+ const atLeast = floorOf(control);
929
+ const name = pattern || (text(control["control"]) ?? "");
864
930
  if (regex)
865
931
  mine.controlFamilies.push(regex);
866
932
  else
867
933
  mine.controls.add(name);
868
- const shownWhen = control?.shown_when;
934
+ const shownWhen = statesOf(control["shown_when"]);
869
935
  // For a literal both questions are "is it there"; for a family they differ: enough
870
936
  // members for the floor, versus any member at all (which is what leaks out of state).
871
937
  const enough = (rendered) => (regex ? countOf(regex, rendered.controls) >= atLeast : rendered.controls.has(name));
@@ -894,8 +960,8 @@ export function checkForm(config, base) {
894
960
  }
895
961
  judgeCalls(contractId, name, control, shownWhen, dataOf, cardById, findings, "error");
896
962
  }
897
- for (const entry of fm["removed"] ?? []) {
898
- const name = String(entry?.control ?? "");
963
+ for (const entry of maps(fm["removed"])) {
964
+ const name = text(entry["control"]) ?? "";
899
965
  mine.controls.add(name);
900
966
  for (const [state, rendered] of states) {
901
967
  if (rendered.controls.has(name)) {
@@ -906,9 +972,10 @@ export function checkForm(config, base) {
906
972
  // A group's anchor is accounted for BY the group line: the owner named the container when
907
973
  // grouping by it. Warning about it as undescribed would ask the owner to decide what they
908
974
  // already decided — measured 19.08: three of fourteen warnings were exactly this noise.
909
- for (const group of fm["groups"] ?? []) {
910
- if (group?.group)
911
- mine.fields.add(String(group.group));
975
+ for (const group of maps(fm["groups"])) {
976
+ const gname = text(group["group"]);
977
+ if (gname)
978
+ mine.fields.add(gname);
912
979
  }
913
980
  // ── group membership ───────────────────────────────────────────────────────────────────
914
981
  // The contract seats elements in groups; the inventory's `within` records which containers
@@ -916,16 +983,15 @@ export function checkForm(config, base) {
916
983
  // where the member renders and the inventory carries the measurement. The first inventory
917
984
  // with containers refuted its own contract: `add-set` was contracted into the footer and
918
985
  // measured living only in the set table — the checker's first catch was its author.
919
- for (const group of fm["groups"] ?? []) {
920
- const gname = String(group?.group ?? "");
986
+ for (const group of maps(fm["groups"])) {
987
+ const gname = text(group["group"]) ?? "";
921
988
  if (!gname)
922
989
  continue;
923
990
  const anchorSeen = [...states.values()].some((r) => r.fields.has(gname) || (r.within !== null && [...r.within.values()].some((chain) => chain.has(gname))));
924
991
  if (!anchorSeen) {
925
992
  findings.push(new Finding("group_missing", `${contractId}: group \`${gname}\` is contracted but its anchor never renders`));
926
993
  }
927
- for (const memberRaw of group?.contains ?? []) {
928
- const member = String(memberRaw);
994
+ for (const member of texts(group["contains"])) {
929
995
  // A family seated in a group: every rendered member of the family must sit there.
930
996
  const regex = member.includes("*") ? familyRegex(member) : null;
931
997
  for (const [state, rendered] of states) {