usedesign 1.5.0 → 1.5.1

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/README.md CHANGED
@@ -1,25 +1,25 @@
1
1
  # usedesign — TypeScript
2
2
 
3
- The reference implementation: validates Operation Cards and runs the five checks against a
4
- repository.
3
+ The reference implementation: validates Operation Cards and form contracts, and runs the five
4
+ checks against a repository.
5
5
 
6
6
  ```bash
7
7
  npx usedesign validate examples/library/
8
8
  npx usedesign check usedesign.config.yaml
9
9
  ```
10
10
 
11
- Exit status is `1` when anything reported an error, `0` otherwise, `2` on misuse. That is the
12
- whole CI contract.
11
+ Exit status is `1` when anything reported an error, `0` otherwise, `2` on misuse or on input that
12
+ cannot be read. That is the whole CI contract.
13
13
 
14
14
  ## Status
15
15
 
16
- **Complete against the corpus, published to npm as `usedesign`.** Everything below runs from a
16
+ **Complete against both corpora, published to npm as `usedesign`.** Everything below runs from a
17
17
  clone as well as through `npx usedesign`.
18
18
 
19
19
  | | |
20
20
  |---|---|
21
- | Cards corpus | **84 / 84** |
22
- | Repository corpus | **55 / 55** |
21
+ | Cards corpus | every case passes (`tests/conformance/cards/`) |
22
+ | Repository corpus | every case passes (`tests/conformance/checks/`) |
23
23
  | Runtime | Node ≥ 20, no native modules |
24
24
  | Dependencies | `ajv`, `ajv-formats`, `yaml`, `fast-xml-parser` |
25
25
 
@@ -29,30 +29,31 @@ npm test # both corpora
29
29
  node dist/cli.js check ../../tests/fixtures/library-service/usedesign.config.yaml
30
30
  ```
31
31
 
32
- The last command is expected to report **six errors** — the fixture is deliberately rotten, and
33
- a clean run there would mean the checks had stopped working.
32
+ The last command is expected to exit `1`: the fixture is deliberately rotten — two wild
33
+ endpoints, three unproven steps, one missing evidence path — and it has no storage inventory,
34
+ which check 4 reports as not run. A clean run there would mean the checks had stopped working.
34
35
 
35
36
  ## Commands
36
37
 
37
38
  | Command | What it does |
38
39
  |---|---|
39
- | `validate <path…>` | Cards against the JSON Schema **and** the cross-card rules. `--no-schema` keeps only the latter |
40
- | `check <config>` | The three invariants: no wild endpoints, no unproven steps, no maturity beyond its evidence |
40
+ | `validate <path…>` | Cards and form contracts against the JSON Schema **and** the cross-card rules. `--no-schema` keeps only the latter |
41
+ | `check <config>` | Checks 1–5: no wild endpoints, no unproven steps, no maturity beyond its evidence, no imagined storage, the form matches its contract. A config's `checks:` names the ones that apply to the repository |
42
+ | `scaffold <openapi.json> --out <dir>` | A draft card per route the cards do not declare yet, read from the application's own OpenAPI. Every draft fails validation on purpose |
43
+ | `preview <config> --out <file.html>` | Form contracts as a three-rail wireframe page |
44
+ | `gen openapi <config> --out <file.json>` | An OpenAPI contract derived from the cards — the opposite direction to `scaffold` |
41
45
  | `conformance` | Both corpora. `--cards` / `--checks` to run one |
42
46
 
43
47
  ## What validation is, in two halves
44
48
 
45
49
  `validate` runs the published schema and the cross-reference rules, and the split is not
46
- cosmetic. Measured on the 10 invalid corpus cases:
50
+ cosmetic. The schema catches what a field looks like — a missing required field, an enum out of
51
+ range, a malformed step id, an interface with no transport. It cannot see a test covering a step
52
+ that does not exist, a screen covering a step that does not exist, or one step id used twice.
47
53
 
48
- - **7** are caught by the schema — a missing required field, an enum out of range, a malformed
49
- step id, an interface with no transport;
50
- - **3** are invisible to it — a test covering a step that does not exist, a UI screen covering a
51
- step that does not exist, and the same step id used twice.
52
-
53
- All three of the invisible ones are the same shape: *a name that points at nothing*. A schema can
54
- say what a field looks like; only a checker holding the whole card can say whether the thing it
55
- names is there.
54
+ Those invisible ones are the same shape: *a name that points at nothing*. A schema can say what a
55
+ field looks like; only a checker holding the whole card — and every other card of the run — can
56
+ say whether the thing it names is there.
56
57
 
57
58
  That second group is the reason this tool exists rather than a bare `ajv` invocation. A schema
58
59
  validates *form*; the rules validate *meaning*, and meaning is where cards rot.
@@ -66,19 +67,27 @@ The schema itself is never vendored: the compiled package carries a copy of the
66
67
  return after the closing fence; scalars came back with `\r` attached, ids stopped matching
67
68
  their pattern, and one card would not parse at all. Every symptom looked like a format problem
68
69
  and none of them was. `frontMatter` normalises before parsing.
69
- - **The three checks are ported line for line from the Python prototype**, including the noise
70
+ - **A field is read only in its type.** A list is read as a list, a map as a map, a name as text;
71
+ a value of another type is absent to the rules and to the checks, and the schema names it. A
72
+ rule that trusts the shape of its input stops with a stack trace on the first card that lies.
73
+ - **The Python implementation prints what this one prints, line for line**, including the noise
70
74
  suppressions — a dead exclusion is not reported when there are no routes, and a step whose only
71
75
  test failed is reported once, not twice. Two implementations that disagree about what to stay
72
- quiet about are two dialects.
76
+ quiet about are two dialects; CI compares them on the corpora and on the fixtures.
73
77
 
74
78
  ## Layout
75
79
 
76
80
  | File | What is in it |
77
81
  |---|---|
78
- | `src/core.ts` | Findings, front matter, config, globbing — no rules |
79
- | `src/validate.ts` | The schema and the cross-card rules |
80
- | `src/checks.ts` | The three checks |
82
+ | `src/core.ts` | Findings, front matter, config, file collection, typed reads — no rules |
83
+ | `src/validate.ts` | The schema and the cross-card rules for cards and form contracts |
84
+ | `src/checks.ts` | The five checks |
81
85
  | `src/conformance.ts` | The corpus runners |
86
+ | `src/scaffold.ts` | Draft cards from an OpenAPI document |
87
+ | `src/gen.ts` | `gen openapi`: an OpenAPI document from the cards |
88
+ | `src/preview.ts` | The wireframe page of form contracts |
89
+ | `src/formInventory.ts` | Helper for producing the form inventory check 5 reads |
90
+ | `src/index.ts` | Library entry point: findings, front matter, config, card collection, card validation, checks 1–3 and the `CHECKS` map of all five, the corpus runners, the form-inventory helpers |
82
91
  | `src/cli.ts` | Argument handling and output |
83
92
 
84
93
  Rules never live in a helper: a rule that hides inside a utility is a rule nobody reviews.
package/dist/checks.d.ts CHANGED
@@ -22,7 +22,10 @@ type Index = Map<string, TestCase[]>;
22
22
  /** Resolve a card's test id to report entries. Full-id matching only — never a substring. */
23
23
  export declare function findCases(testId: string, byFull: Index, byName: Index): TestCase[];
24
24
  export declare function checkCoverage(config: Config, base: string): CheckResult;
25
- /** `implemented` is one path or several — one example joins two with ' + '. */
25
+ /**
26
+ * `implemented` is one path or several — one example joins two with ' + '. A value that is not a
27
+ * string is absent (1.5.1): `true` was the path `true` here and `True` in the Python prototype.
28
+ */
26
29
  export declare function evidencePaths(value: unknown): string[];
27
30
  export declare function checkMaturity(config: Config, base: string, today?: Date): CheckResult;
28
31
  export declare function checkStorage(config: Config, base: string): CheckResult;
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
14
  import { XMLParser } from "fast-xml-parser";
13
- import { Finding, expandGlobs, loadCardFiles, readDocument } from "./core.js";
15
+ import { Finding, expandGlobs, globMatches, isMap, loadCardFiles, map, maps, readDocument, refuseTwins, text, texts, } 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) {
@@ -301,9 +310,9 @@ export function checkCoverage(config, base) {
301
310
  for (const [cardId, fm] of cards) {
302
311
  const byStep = new Map();
303
312
  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 ?? "";
313
+ for (const test of maps(fm["tests"])) {
314
+ const refs = coversOf(test);
315
+ const testId = text(test["id"]) ?? "";
307
316
  const matched = haveReport ? findCases(testId, byFull, byName) : [];
308
317
  if (haveReport) {
309
318
  if (matched.length === 0) {
@@ -333,14 +342,14 @@ export function checkCoverage(config, base) {
333
342
  // Pass 2 — verdicts, with a variant's inherited steps credited from the original.
334
343
  const byId = new Map(cards);
335
344
  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));
345
+ const steps = stepIdsOf(fm);
346
+ const gaps = gapStepsOf(fm);
338
347
  const byStep = ownProof.get(cardId);
339
348
  const { credited, explained } = inheritance(cardId, fm, steps, byStep, byId, ownProof, haveReport, findings);
340
349
  // Round 26 ⑬: a gap says why the proof is missing — counted apart, so "not written yet" is
341
350
  // never read as "cannot be written".
342
- for (const entry of (fm["coverage_gaps"] ?? [])) {
343
- const kind = String(entry?.kind ?? "unwritten");
351
+ for (const entry of maps(fm["coverage_gaps"])) {
352
+ const kind = text(entry["kind"]) || "unwritten";
344
353
  if (kind === "unwritten" || kind === "harness" || kind === "unreachable")
345
354
  gapKinds[kind] += 1;
346
355
  }
@@ -373,6 +382,19 @@ export function checkCoverage(config, base) {
373
382
  },
374
383
  };
375
384
  }
385
+ /** The steps a test covers: one id or a list of them (1.5.1: an id of another type is absent). */
386
+ function coversOf(test) {
387
+ const covers = test["covers"];
388
+ return typeof covers === "string" ? [covers] : texts(covers);
389
+ }
390
+ /** A card's step ids, in order; a step that is not a map, or an id that is not a string, is absent. */
391
+ function stepIdsOf(fm) {
392
+ return texts(maps(fm["steps"]).map((step) => step["id"]));
393
+ }
394
+ /** The steps a card declares a gap for. */
395
+ function gapStepsOf(fm) {
396
+ return new Set(texts(maps(fm["coverage_gaps"]).map((gap) => gap["step"])));
397
+ }
376
398
  /**
377
399
  * Which inherited steps of a variant (§5.2e) the original's own tests prove. `explained` holds the
378
400
  * steps whose missing credit a variant finding has already reported, so check 2 does not repeat it
@@ -382,18 +404,18 @@ function inheritance(cardId, fm, steps, byStep, byId, ownProof, haveReport, find
382
404
  const credited = new Set();
383
405
  const explained = new Set();
384
406
  const variant = fm["variant_of"];
385
- if (!variant || typeof variant !== "object")
407
+ if (!isMap(variant))
386
408
  return { credited, explained };
387
- const inherits = (Array.isArray(variant.inherits) ? variant.inherits : []).filter((s) => typeof s === "string");
388
- const originalId = String(variant.card ?? "");
409
+ const inherits = texts(variant["inherits"]);
410
+ const originalId = text(variant["card"]) ?? "";
389
411
  const original = byId.get(originalId);
390
412
  if (!original) {
391
413
  findings.push(new Finding("variant_of_unknown", `${cardId}: \`variant_of\` names \`${originalId}\`, which no card in the set declares`));
392
414
  inherits.forEach((s) => explained.add(s));
393
415
  return { credited, explained };
394
416
  }
395
- const shared = String(variant.shared ?? "").trim().replace(LINE_SUFFIX, "");
396
- const codeShared = evidencePaths(original["maturity_evidence"]?.["implemented"]).includes(shared);
417
+ const shared = (text(variant["shared"]) ?? "").trim().replace(LINE_SUFFIX, "");
418
+ const codeShared = evidencePaths(map(original["maturity_evidence"])["implemented"]).includes(shared);
397
419
  if (!codeShared) {
398
420
  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
421
  }
@@ -403,8 +425,8 @@ function inheritance(cardId, fm, steps, byStep, byId, ownProof, haveReport, find
403
425
  if (!wired) {
404
426
  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
427
  }
406
- const originalSteps = new Set((original["steps"] ?? []).map((s) => s?.id));
407
- const originalGaps = new Set((original["coverage_gaps"] ?? []).map((g) => g?.step));
428
+ const originalSteps = new Set(stepIdsOf(original));
429
+ const originalGaps = gapStepsOf(original);
408
430
  const originalProof = ownProof.get(originalId) ?? new Map();
409
431
  for (const step of inherits) {
410
432
  if (!originalSteps.has(step)) {
@@ -429,12 +451,15 @@ function inheritance(cardId, fm, steps, byStep, byId, ownProof, haveReport, find
429
451
  const LEVELS = ["conceived", "designed", "implemented", "tested", "in_production", "deprecated"];
430
452
  const LINE_SUFFIX = /:\d+$/;
431
453
  const DEFAULT_HORIZON_DAYS = 180;
432
- /** `implemented` is one path or several — one example joins two with ' + '. */
454
+ /**
455
+ * `implemented` is one path or several — one example joins two with ' + '. A value that is not a
456
+ * string is absent (1.5.1): `true` was the path `true` here and `True` in the Python prototype.
457
+ */
433
458
  export function evidencePaths(value) {
434
- const values = Array.isArray(value) ? value : [value];
459
+ const values = typeof value === "string" ? [value] : texts(value);
435
460
  const paths = [];
436
461
  for (const item of values) {
437
- for (const part of String(item).split("+")) {
462
+ for (const part of item.split("+")) {
438
463
  const trimmed = part.trim().replace(LINE_SUFFIX, "");
439
464
  if (trimmed)
440
465
  paths.push(trimmed);
@@ -466,11 +491,11 @@ export function checkMaturity(config, base, today = new Date()) {
466
491
  findings.push(...cardFindings);
467
492
  let checkedPaths = 0;
468
493
  for (const [cardId, fm] of cards) {
469
- const maturity = fm["maturity"];
494
+ const maturity = text(fm["maturity"]) ?? "";
470
495
  if (!LEVELS.includes(maturity))
471
496
  continue;
472
497
  const level = LEVELS.indexOf(maturity);
473
- const evidence = fm["maturity_evidence"] ?? {};
498
+ const evidence = map(fm["maturity_evidence"]);
474
499
  // Tier 1 — the code the card points at exists.
475
500
  if (level >= LEVELS.indexOf("implemented") && evidence["implemented"] && root) {
476
501
  for (const path of evidencePaths(evidence["implemented"])) {
@@ -483,14 +508,13 @@ export function checkMaturity(config, base, today = new Date()) {
483
508
  // Tier 2 — the claim is derived from the report, not from prose.
484
509
  const passing = [];
485
510
  const covered = new Set();
486
- for (const test of (fm["tests"] ?? [])) {
487
- const matched = haveReport ? findCases(test?.id ?? "", byFull, byName) : [];
511
+ for (const test of maps(fm["tests"])) {
512
+ const testId = text(test["id"]) ?? "";
513
+ const matched = haveReport ? findCases(testId, byFull, byName) : [];
488
514
  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
- }
515
+ passing.push(testId);
516
+ for (const ref of coversOf(test))
517
+ covered.add(ref);
494
518
  }
495
519
  }
496
520
  if (level >= LEVELS.indexOf("tested") && passing.length === 0) {
@@ -502,16 +526,16 @@ export function checkMaturity(config, base, today = new Date()) {
502
526
  // And only when a passing test covers the LAST step — the proof §5.2e rule 4 accepts that an
503
527
  // operation goes all the way through. Tests of the door alone (sign-in, permission) pass in
504
528
  // 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);
529
+ const stepIds = stepIdsOf(fm);
507
530
  const last = stepIds.length > 0 ? stepIds[stepIds.length - 1] : undefined;
508
- if (haveReport && level < LEVELS.indexOf("implemented") && typeof last === "string" && covered.has(last)) {
531
+ if (haveReport && level < LEVELS.indexOf("implemented") && last !== undefined && covered.has(last)) {
509
532
  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
533
  }
511
534
  // Tier 3 — what cannot be verified must expire.
512
535
  if (level >= LEVELS.indexOf("in_production")) {
536
+ // A dated map or a bare string (the schema's two forms); any other value is absent (1.5.1).
513
537
  const deployed = evidence["deployed"];
514
- if (deployed && typeof deployed === "object" && !Array.isArray(deployed)) {
538
+ if (isMap(deployed)) {
515
539
  // A day the calendar has (1.4.3): `new Date` carried 2026-02-30 over to 2 March and called it
516
540
  // stale, where the Python prototype said it is not a date. Year 0 is none either.
517
541
  const raw = typeof deployed.since === "string" ? deployed.since : "";
@@ -525,7 +549,7 @@ export function checkMaturity(config, base, today = new Date()) {
525
549
  findings.push(new Finding("evidence_stale", `${cardId}: \`deployed\` was last affirmed ${age} days ago (horizon ${horizon}) — nobody has looked since`, "warning"));
526
550
  }
527
551
  }
528
- else if (deployed) {
552
+ else if (typeof deployed === "string" && deployed) {
529
553
  findings.push(new Finding("evidence_undated", `${cardId}: \`deployed: ${deployed}\` carries no date, so it can never go stale`, "warning"));
530
554
  }
531
555
  }
@@ -580,10 +604,10 @@ export function checkStorage(config, base) {
580
604
  const touched = new Set();
581
605
  let claims = 0;
582
606
  for (const [cardId, fm] of cards) {
583
- for (const claim of fm["data"]?.storage ?? []) {
607
+ for (const claim of maps(map(fm["data"])["storage"])) {
584
608
  claims += 1;
585
- const pattern = claim?.store ?? "";
586
- const matched = stores.filter((store) => matchesPattern(String(store.name), pattern));
609
+ const pattern = text(claim["store"]) ?? "";
610
+ const matched = stores.filter((store) => globMatches(String(store.name), pattern));
587
611
  if (matched.length === 0) {
588
612
  findings.push(new Finding("unknown_store", `${cardId}: claims store \`${pattern}\`, which the storage does not have`));
589
613
  continue;
@@ -591,7 +615,7 @@ export function checkStorage(config, base) {
591
615
  for (const store of matched)
592
616
  touched.add(String(store.name));
593
617
  // Every environment must agree, or the card is true in one place and false in another.
594
- const claimedKeys = claim?.keyed_by ?? [];
618
+ const claimedKeys = texts(claim["keyed_by"]);
595
619
  if (claimedKeys.length > 0) {
596
620
  for (const store of matched) {
597
621
  const actual = store.keyed_by ?? [];
@@ -600,7 +624,7 @@ export function checkStorage(config, base) {
600
624
  }
601
625
  }
602
626
  }
603
- const index = claim?.via_index;
627
+ const index = text(claim["via_index"]);
604
628
  if (index) {
605
629
  for (const store of matched) {
606
630
  const names = (store.indexes ?? []).map((i) => String(i.name));
@@ -706,9 +730,8 @@ function judgeCalls(contractId, name, control, shownWhen, dataOf, cardById, find
706
730
  // `from` is one state or a SET of states (round 18): the control must be shown only in
707
731
  // states that belong to the set, and in at least one of them. `none` / `any` switch the
708
732
  // 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)] : [];
733
+ const rawFrom = map(card["data_transition"])["from"];
734
+ const fromSet = typeof rawFrom === "string" && rawFrom ? [rawFrom] : texts(rawFrom);
712
735
  if (fromSet.length > 0 && !fromSet.includes("none") && !fromSet.includes("any")) {
713
736
  const mismatch = shownWhen.filter((state) => !fromSet.includes(dataOf(state)));
714
737
  if (mismatch.length > 0 || !shownWhen.some((state) => fromSet.includes(dataOf(state)))) {
@@ -717,6 +740,14 @@ function judgeCalls(contractId, name, control, shownWhen, dataOf, cardById, find
717
740
  }
718
741
  }
719
742
  }
743
+ /** 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). */
744
+ function statesOf(value) {
745
+ return Array.isArray(value) ? texts(value) : undefined;
746
+ }
747
+ /** `at_least`, an integer, or 1. */
748
+ function floorOf(line) {
749
+ return Number.isInteger(line["at_least"]) ? Number(line["at_least"]) : 1;
750
+ }
720
751
  export function checkForm(config, base) {
721
752
  const findings = [];
722
753
  const patterns = config.forms ?? [];
@@ -767,30 +798,35 @@ export function checkForm(config, base) {
767
798
  }
768
799
  const cardById = new Map(cards);
769
800
  const contracts = [];
801
+ const read = [];
770
802
  for (const path of expandGlobs(base, patterns)) {
771
803
  const fm = readDocument(path);
772
- if (fm && fm["usedesign_form"] === 1)
773
- contracts.push([String(fm["id"] ?? path), fm]);
804
+ if (isMap(fm) && fm["usedesign_form"] === 1) {
805
+ contracts.push([text(fm["id"]) ?? path, fm]);
806
+ read.push([path, fm]);
807
+ }
774
808
  }
809
+ refuseTwins(read);
775
810
  if (contracts.length === 0) {
776
811
  findings.push(new Finding("form_contracts_missing", "the `forms` patterns matched no contract"));
777
812
  }
778
813
  const claimed = new Map();
779
814
  let designedAhead = 0;
780
815
  for (const [contractId, fm] of contracts) {
781
- const screen = String(fm["screen"] ?? "");
816
+ const screen = text(fm["screen"]) ?? "";
782
817
  const states = byScreen.get(screen);
783
818
  // A contract may be written before its screen — the format's own recommended order of
784
819
  // work — and say so with `maturity: designed` (issue #8, the forms twin of issue #3). Then
785
820
  // an absent screen is a warning, not the same error as a screen that vanished. The flag
786
821
  // cannot go stale silently: the day the screen renders, the contract still saying
787
822
  // `designed` is reported too — the gate itself tells the owner to flip it.
788
- const maturity = String(fm["maturity"] ?? "implemented");
823
+ const maturity = text(fm["maturity"]) || "implemented";
789
824
  // Screen state → data state, through the contract's `states:` map; identity when unmapped.
790
- const stateMap = fm["states"] && typeof fm["states"] === "object" ? fm["states"] : {};
825
+ // Looked up among the map's own keys: `toString` is not a state.
826
+ const stateMap = map(fm["states"]);
791
827
  const dataOf = (state) => {
792
- const spec = stateMap[state];
793
- return spec && typeof spec === "object" && spec.data ? String(spec.data) : state;
828
+ const spec = Object.prototype.hasOwnProperty.call(stateMap, state) ? stateMap[state] : undefined;
829
+ return (isMap(spec) && text(spec["data"])) || state;
794
830
  };
795
831
  if (!states) {
796
832
  if (maturity === "designed") {
@@ -800,9 +836,9 @@ export function checkForm(config, base) {
800
836
  // and comparing it with the cards needs no inventory (issue #13). Only where the config
801
837
  // declares `cards:`: a frontend-only repository has none, and stays as quiet as before.
802
838
  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");
839
+ for (const control of maps(fm["controls"])) {
840
+ const name = text(control["control_pattern"]) || text(control["control"]) || "";
841
+ judgeCalls(contractId, name, control, statesOf(control["shown_when"]), dataOf, cardById, findings, "warning");
806
842
  }
807
843
  }
808
844
  }
@@ -818,17 +854,18 @@ export function checkForm(config, base) {
818
854
  const mine = claimed.get(screen) ?? { fields: new Set(), controls: new Set(), fieldFamilies: [], controlFamilies: [] };
819
855
  claimed.set(screen, mine);
820
856
  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) : "";
857
+ for (const entry of maps(fm["presents"])) {
858
+ const pattern = text(entry["field_pattern"]) ?? "";
859
+ const when = statesOf(entry["when"]);
823
860
  if (pattern) {
824
861
  // A family: one element per item of a list that arrives at runtime. Every rendered
825
862
  // anchor matching the pattern is accounted for; `at_least` keeps the family from
826
863
  // silently becoming empty — a screen rendering NO tabs because the backend returned
827
864
  // nothing is a real failure, and without the floor nothing would notice.
828
865
  const regex = familyRegex(pattern);
829
- const atLeast = Number.isInteger(entry?.at_least) ? Number(entry.at_least) : 1;
866
+ const atLeast = floorOf(entry);
830
867
  mine.fieldFamilies.push(regex);
831
- for (const state of entry?.when ?? everyState) {
868
+ for (const state of when ?? everyState) {
832
869
  const rendered = states.get(state);
833
870
  if (!rendered) {
834
871
  findings.push(new Finding("form_state_missing", `${contractId}: \`${pattern}\` is required in state \`${state}\`, which the inventory never rendered`));
@@ -840,12 +877,12 @@ export function checkForm(config, base) {
840
877
  }
841
878
  }
842
879
  }
843
- fieldOutOfState(contractId, pattern, entry?.when, states, (rendered) => countOf(regex, rendered.fields) > 0, findings);
880
+ fieldOutOfState(contractId, pattern, when, states, (rendered) => countOf(regex, rendered.fields) > 0, findings);
844
881
  continue;
845
882
  }
846
- const field = String(entry?.field ?? "");
883
+ const field = text(entry["field"]) ?? "";
847
884
  mine.fields.add(field);
848
- for (const state of entry?.when ?? everyState) {
885
+ for (const state of when ?? everyState) {
849
886
  const rendered = states.get(state);
850
887
  if (!rendered) {
851
888
  findings.push(new Finding("form_state_missing", `${contractId}: \`${field}\` is required in state \`${state}\`, which the inventory never rendered`));
@@ -854,18 +891,18 @@ export function checkForm(config, base) {
854
891
  findings.push(new Finding("element_missing", `${contractId}: \`${field}\` must be shown in state \`${state}\` and is not`));
855
892
  }
856
893
  }
857
- fieldOutOfState(contractId, field, entry?.when, states, (rendered) => rendered.fields.has(field), findings);
894
+ fieldOutOfState(contractId, field, when, states, (rendered) => rendered.fields.has(field), findings);
858
895
  }
859
- for (const control of fm["controls"] ?? []) {
860
- const pattern = control?.control_pattern ? String(control.control_pattern) : "";
896
+ for (const control of maps(fm["controls"])) {
897
+ const pattern = text(control["control_pattern"]) ?? "";
861
898
  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 ?? "");
899
+ const atLeast = floorOf(control);
900
+ const name = pattern || (text(control["control"]) ?? "");
864
901
  if (regex)
865
902
  mine.controlFamilies.push(regex);
866
903
  else
867
904
  mine.controls.add(name);
868
- const shownWhen = control?.shown_when;
905
+ const shownWhen = statesOf(control["shown_when"]);
869
906
  // For a literal both questions are "is it there"; for a family they differ: enough
870
907
  // members for the floor, versus any member at all (which is what leaks out of state).
871
908
  const enough = (rendered) => (regex ? countOf(regex, rendered.controls) >= atLeast : rendered.controls.has(name));
@@ -894,8 +931,8 @@ export function checkForm(config, base) {
894
931
  }
895
932
  judgeCalls(contractId, name, control, shownWhen, dataOf, cardById, findings, "error");
896
933
  }
897
- for (const entry of fm["removed"] ?? []) {
898
- const name = String(entry?.control ?? "");
934
+ for (const entry of maps(fm["removed"])) {
935
+ const name = text(entry["control"]) ?? "";
899
936
  mine.controls.add(name);
900
937
  for (const [state, rendered] of states) {
901
938
  if (rendered.controls.has(name)) {
@@ -906,9 +943,10 @@ export function checkForm(config, base) {
906
943
  // A group's anchor is accounted for BY the group line: the owner named the container when
907
944
  // grouping by it. Warning about it as undescribed would ask the owner to decide what they
908
945
  // 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));
946
+ for (const group of maps(fm["groups"])) {
947
+ const gname = text(group["group"]);
948
+ if (gname)
949
+ mine.fields.add(gname);
912
950
  }
913
951
  // ── group membership ───────────────────────────────────────────────────────────────────
914
952
  // The contract seats elements in groups; the inventory's `within` records which containers
@@ -916,16 +954,15 @@ export function checkForm(config, base) {
916
954
  // where the member renders and the inventory carries the measurement. The first inventory
917
955
  // with containers refuted its own contract: `add-set` was contracted into the footer and
918
956
  // 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 ?? "");
957
+ for (const group of maps(fm["groups"])) {
958
+ const gname = text(group["group"]) ?? "";
921
959
  if (!gname)
922
960
  continue;
923
961
  const anchorSeen = [...states.values()].some((r) => r.fields.has(gname) || (r.within !== null && [...r.within.values()].some((chain) => chain.has(gname))));
924
962
  if (!anchorSeen) {
925
963
  findings.push(new Finding("group_missing", `${contractId}: group \`${gname}\` is contracted but its anchor never renders`));
926
964
  }
927
- for (const memberRaw of group?.contains ?? []) {
928
- const member = String(memberRaw);
965
+ for (const member of texts(group["contains"])) {
929
966
  // A family seated in a group: every rendered member of the family must sit there.
930
967
  const regex = member.includes("*") ? familyRegex(member) : null;
931
968
  for (const [state, rendered] of states) {