usedesign 1.4.1 → 1.4.3

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/validate.js CHANGED
@@ -86,20 +86,37 @@ const CONTROL_KEYS = new Set(["control", "control_pattern", "at_least", "calls",
86
86
  const GROUP_KEYS = new Set(["group", "role", "contains", "note"]);
87
87
  const GROUP_ROLES = ["header", "footer", "section", "table", "list", "toolbar", "menu"];
88
88
  const REMOVED_KEYS = new Set(["control", "was", "verdict"]);
89
+ // ── reading a field in its type (1.4.3) ─────────────────────────────────────────────────────
90
+ //
91
+ // A rule reads a field only in the type the format gives it: a list as a list, a map as a map, an
92
+ // id, a reference or a name as a string, a status as an integer. Any other value is absent to the
93
+ // rules, and the schema layer names its type. Before, a number where a list belongs threw here
94
+ // (`responses: 412`), a string was walked letter by letter as if it were steps, `[]` counted as a
95
+ // value here and as nothing in the Python prototype, which stopped with a traceback on most of
96
+ // these. An enumeration keeps its own rule: a value outside the list, of whatever type, is reported
97
+ // as written. A key is looked up among the map's own keys — `toString` is not an outcome.
98
+ const isMap = (value) => value !== null && typeof value === "object" && !Array.isArray(value);
99
+ const list = (value) => (Array.isArray(value) ? value : []);
100
+ const map = (value) => (isMap(value) ? value : {});
101
+ const text = (value) => (typeof value === "string" ? value : undefined);
102
+ const status = (value) => (Number.isInteger(value) ? value : undefined);
103
+ /** Absent for a required key: not written, null, or an empty string. */
104
+ const absent = (value) => value === undefined || value === null || value === "";
105
+ const has = (object, key) => Object.prototype.hasOwnProperty.call(object, key);
89
106
  /**
90
107
  * One line names ONE anchor or ONE family, never both and never neither. A family (issue #10)
91
108
  * is a pattern with `*`; a "pattern" without a wildcard is a literal wearing the wrong key,
92
109
  * and would silently match nothing the literal key would have matched.
93
110
  */
94
111
  function anchorOrFamily(line, literalKey, patternKey, where, err) {
95
- const literal = line[literalKey];
96
- const pattern = line[patternKey];
112
+ const literal = text(line[literalKey]);
113
+ const pattern = text(line[patternKey]);
97
114
  if (!literal && !pattern)
98
115
  err("missing_required_field", `${where}: \`${literalKey}\` (or \`${patternKey}\`) is absent`);
99
116
  if (literal && pattern) {
100
117
  err("literal_and_pattern", `${where}: both \`${literalKey}\` and \`${patternKey}\` — one line names one anchor or one family, never both`);
101
118
  }
102
- if (pattern && !String(pattern).includes("*")) {
119
+ if (pattern && !pattern.includes("*")) {
103
120
  err("pattern_without_wildcard", `${where}: \`${pattern}\` has no \`*\` — a family without a wildcard is a literal; write \`${literalKey}:\``);
104
121
  }
105
122
  const atLeast = line["at_least"];
@@ -109,7 +126,7 @@ function anchorOrFamily(line, literalKey, patternKey, where, err) {
109
126
  if (atLeast !== undefined && !pattern) {
110
127
  err("malformed_at_least", `${where}: \`at_least\` belongs to a family line (\`${patternKey}\`) — a single anchor is present or it is not`);
111
128
  }
112
- return literal ? String(literal) : pattern ? String(pattern) : undefined;
129
+ return literal || pattern || undefined;
113
130
  }
114
131
  /**
115
132
  * Validate one form contract's *meaning*. `knownForms` enables the cross-contract link warning
@@ -129,7 +146,7 @@ export function validateForm(fm, filename = "", knownForms = null, knownCards =
129
146
  if (!FORM_KEYS.has(key))
130
147
  err("unknown_field", `\`${key}\` is not part of the form contract format`);
131
148
  }
132
- const formId = fm["id"] ?? "";
149
+ const formId = text(fm["id"]) ?? "";
133
150
  if (formId && !OPERATION_ID.test(formId)) {
134
151
  err("malformed_form_id", `\`${formId}\` is not <area>.<object>.<name>`);
135
152
  }
@@ -143,16 +160,16 @@ export function validateForm(fm, filename = "", knownForms = null, knownCards =
143
160
  // every entry names the data state it lives in.
144
161
  const stateMap = fm["states"];
145
162
  if (stateMap !== undefined) {
146
- if (!stateMap || typeof stateMap !== "object" || Array.isArray(stateMap)) {
163
+ if (!isMap(stateMap)) {
147
164
  err("malformed_states", "`states` must be a map of screen state → { data: <data state> }");
148
165
  }
149
166
  else {
150
167
  for (const [state, spec] of Object.entries(stateMap)) {
151
- if (!spec || typeof spec !== "object" || Array.isArray(spec)) {
168
+ if (!isMap(spec)) {
152
169
  err("missing_required_field", `states.${state}: \`data\` is absent`);
153
170
  continue;
154
171
  }
155
- if (!spec["data"])
172
+ if (!text(spec["data"]))
156
173
  err("missing_required_field", `states.${state}: \`data\` is absent`);
157
174
  for (const key of Object.keys(spec)) {
158
175
  if (!STATE_KEYS.has(key))
@@ -161,12 +178,12 @@ export function validateForm(fm, filename = "", knownForms = null, knownCards =
161
178
  }
162
179
  }
163
180
  }
164
- const presents = Array.isArray(fm["presents"]) ? fm["presents"] : [];
181
+ const presents = list(fm["presents"]);
165
182
  const seenFields = new Set();
166
183
  for (const [index, element] of presents.entries()) {
167
- if (!element || typeof element !== "object")
184
+ if (!isMap(element))
168
185
  continue;
169
- if (!element["shows"])
186
+ if (!text(element["shows"]))
170
187
  err("missing_required_field", `presents[${index}]: \`shows\` is absent`);
171
188
  for (const key of Object.keys(element)) {
172
189
  if (!ELEMENT_KEYS.has(key))
@@ -179,10 +196,10 @@ export function validateForm(fm, filename = "", knownForms = null, knownCards =
179
196
  seenFields.add(field);
180
197
  }
181
198
  }
182
- const controls = Array.isArray(fm["controls"]) ? fm["controls"] : [];
199
+ const controls = list(fm["controls"]);
183
200
  const seenControls = new Set();
184
201
  for (const [index, control] of controls.entries()) {
185
- if (!control || typeof control !== "object")
202
+ if (!isMap(control))
186
203
  continue;
187
204
  for (const key of Object.keys(control)) {
188
205
  if (!CONTROL_KEYS.has(key))
@@ -194,9 +211,9 @@ export function validateForm(fm, filename = "", knownForms = null, knownCards =
194
211
  err("duplicate_control", `\`${name}\` appears more than once in controls`);
195
212
  seenControls.add(name);
196
213
  }
197
- const opens = control["opens"];
214
+ const opens = text(control["opens"]);
198
215
  if (opens && knownForms !== null && !knownForms.has(opens)) {
199
- warn("undescribed_form", `control \`${name}\` opens \`${opens}\`, which no contract in this set describes`);
216
+ warn("undescribed_form", `control \`${name ?? ""}\` opens \`${opens}\`, which no contract in this set describes`);
200
217
  }
201
218
  // `calls` names the operation a control runs, or null for local behaviour — and since round 27
202
219
  // the chain it runs, in call order: a draft's «save as plan» renames it, then publishes it.
@@ -223,7 +240,7 @@ export function validateForm(fm, filename = "", knownForms = null, knownCards =
223
240
  for (const [step, id] of chain.entries()) {
224
241
  if (!knownCards.has(id)) {
225
242
  const where = chain.length > 1 ? ` (step ${step + 1} of ${chain.length})` : "";
226
- warn("form_calls_undescribed", `control \`${name}\` calls \`${id}\`${where}, which no card in this set describes`);
243
+ warn("form_calls_undescribed", `control \`${name ?? ""}\` calls \`${id}\`${where}, which no card in this set describes`);
227
244
  }
228
245
  }
229
246
  }
@@ -236,34 +253,36 @@ export function validateForm(fm, filename = "", knownForms = null, knownCards =
236
253
  const members = new Set([...seenFields, ...seenControls]);
237
254
  const seenGroups = new Set();
238
255
  const claimed = new Map();
239
- for (const [index, group] of (Array.isArray(fm["groups"]) ? fm["groups"] : []).entries()) {
240
- if (!group || typeof group !== "object")
256
+ for (const [index, group] of list(fm["groups"]).entries()) {
257
+ if (!isMap(group))
241
258
  continue;
242
259
  for (const required of ["group", "role", "contains"]) {
243
- if (!group[required])
260
+ if (absent(group[required]))
244
261
  err("missing_required_field", `groups[${index}]: \`${required}\` is absent`);
245
262
  }
246
263
  for (const key of Object.keys(group)) {
247
264
  if (!GROUP_KEYS.has(key))
248
265
  err("unknown_field", `groups[${index}]: \`${key}\` is not part of a group line`);
249
266
  }
250
- const name = group["group"];
267
+ const name = text(group["group"]);
251
268
  if (name) {
252
269
  if (seenGroups.has(name))
253
270
  err("duplicate_group", `\`${name}\` appears more than once in groups`);
254
271
  seenGroups.add(name);
255
272
  }
256
273
  const role = group["role"];
257
- if (role && !GROUP_ROLES.includes(role)) {
274
+ if (!absent(role) && !GROUP_ROLES.includes(role)) {
258
275
  err("invalid_enum_value", `groups[${index}]: role \`${role}\` is not one of ${GROUP_ROLES}`);
259
276
  }
260
- for (const member of Array.isArray(group["contains"]) ? group["contains"] : []) {
277
+ for (const member of list(group["contains"])) {
278
+ if (typeof member !== "string")
279
+ continue;
261
280
  if (!members.has(member)) {
262
- err("unknown_group_member", `group \`${name}\` contains \`${member}\`, which no element or control declares`);
281
+ err("unknown_group_member", `group \`${name ?? ""}\` contains \`${member}\`, which no element or control declares`);
263
282
  }
264
283
  const already = claimed.get(member);
265
284
  if (already && already !== name) {
266
- err("element_in_two_groups", `\`${member}\` sits in \`${already}\` and \`${name}\` — an element renders in one place`);
285
+ err("element_in_two_groups", `\`${member}\` sits in \`${already}\` and \`${name ?? ""}\` — an element renders in one place`);
267
286
  }
268
287
  if (name)
269
288
  claimed.set(member, name);
@@ -271,16 +290,16 @@ export function validateForm(fm, filename = "", knownForms = null, knownCards =
271
290
  }
272
291
  // One document both requiring and forbidding a control is not incompleteness — it is the
273
292
  // contract disagreeing with itself, and no amount of code can satisfy it.
274
- for (const [index, removed] of (Array.isArray(fm["removed"]) ? fm["removed"] : []).entries()) {
275
- if (!removed || typeof removed !== "object")
293
+ for (const [index, removed] of list(fm["removed"]).entries()) {
294
+ if (!isMap(removed))
276
295
  continue;
277
- if (!removed["control"])
296
+ if (!text(removed["control"]))
278
297
  err("missing_required_field", `removed[${index}]: \`control\` is absent`);
279
298
  for (const key of Object.keys(removed)) {
280
299
  if (!REMOVED_KEYS.has(key))
281
300
  err("unknown_field", `removed[${index}]: \`${key}\` is not part of a removed line`);
282
301
  }
283
- const name = removed["control"];
302
+ const name = text(removed["control"]);
284
303
  if (name && seenControls.has(name)) {
285
304
  err("removed_also_required", `\`${name}\` is listed in controls and in removed — the contract both requires and forbids it`);
286
305
  }
@@ -403,7 +422,7 @@ export function validate(fm, filename = "", knownIds = null) {
403
422
  if (!(field in fm))
404
423
  err("missing_required_field", `\`${field}\` is absent`);
405
424
  }
406
- const cardId = fm["id"] ?? "";
425
+ const cardId = text(fm["id"]) ?? "";
407
426
  if (cardId && !OPERATION_ID.test(cardId)) {
408
427
  err("malformed_operation_id", `\`${cardId}\` is not <area>.<object>.<action>`);
409
428
  }
@@ -421,7 +440,7 @@ export function validate(fm, filename = "", knownIds = null) {
421
440
  // states — resume from finished | partial | abandoned. Measured on a live service, where the only
422
441
  // honest alternative was `from: any`, which switches the shown_when rule off. One state is written
423
442
  // as a string; a one-element set is that string wearing brackets.
424
- const fromValue = fm["data_transition"] && typeof fm["data_transition"] === "object" ? fm["data_transition"].from : undefined;
443
+ const fromValue = map(fm["data_transition"]).from;
425
444
  if (Array.isArray(fromValue)) {
426
445
  if (fromValue.length < 2)
427
446
  err("malformed_transition", "`data_transition.from` as a set needs at least two states — one state is written as a string");
@@ -429,10 +448,10 @@ export function validate(fm, filename = "", knownIds = null) {
429
448
  err("malformed_transition", "`data_transition.from` lists the same state twice");
430
449
  }
431
450
  // ── steps ──────────────────────────────────────────────────────────────────────────────────
432
- const steps = fm["steps"] ?? [];
451
+ const steps = list(fm["steps"]);
433
452
  const stepIds = new Set();
434
453
  for (const step of steps) {
435
- const sid = step?.id ?? "";
454
+ const sid = text(map(step).id) ?? "";
436
455
  if (!STEP_ID.test(sid))
437
456
  err("malformed_step_id", `\`${sid}\` is not s<N>-<name>`);
438
457
  else if (stepIds.has(sid))
@@ -440,9 +459,9 @@ export function validate(fm, filename = "", knownIds = null) {
440
459
  stepIds.add(sid);
441
460
  }
442
461
  // ── maturity vs evidence ───────────────────────────────────────────────────────────────────
443
- const evidence = fm["maturity_evidence"] ?? {};
444
- const tests = fm["tests"] ?? [];
445
- if (maturity && maturity in EVIDENCE_FOR) {
462
+ const evidence = map(fm["maturity_evidence"]);
463
+ const tests = list(fm["tests"]);
464
+ if (typeof maturity === "string" && has(EVIDENCE_FOR, maturity)) {
446
465
  // Every level implies the ones below it.
447
466
  for (const level of ["implemented", "tested", "in_production"]) {
448
467
  if (MATURITY.indexOf(maturity) >= MATURITY.indexOf(level)) {
@@ -460,20 +479,20 @@ export function validate(fm, filename = "", knownIds = null) {
460
479
  // claim, and it rots by hand: cards keep "5 tests" after the sixth was cited, or after one was
461
480
  // dropped. The format's own example library was clean when this arrived; the product's cards had been kept in step by hand three times in one day.
462
481
  const testedProse = evidence["tested"];
463
- const testedMatch = typeof testedProse === "number" ? [null, String(testedProse)] : /^\s*(\d+)/.exec(String(testedProse ?? ""));
482
+ const testedMatch = typeof testedProse === "number" ? [null, String(testedProse)] : typeof testedProse === "string" ? /^\s*(\d+)/.exec(testedProse) : null;
464
483
  if (testedMatch && Number(testedMatch[1]) !== tests.length) {
465
484
  warn("tested_count_mismatch", `maturity_evidence.tested says ${testedMatch[1]}, tests[] lists ${tests.length} — the count went stale`);
466
485
  }
467
486
  // ── concurrency ────────────────────────────────────────────────────────────────────────────
468
- const concurrency = fm["concurrency"] ?? {};
487
+ const concurrency = map(fm["concurrency"]);
469
488
  const mode = concurrency["mode"];
470
489
  if (mode !== undefined && !CONCURRENCY_MODES.includes(mode)) {
471
490
  err("invalid_enum_value", `concurrency.mode \`${mode}\` is not one of ${CONCURRENCY_MODES}`);
472
491
  }
473
- if (mode && mode !== "etag_required" && !concurrency["rationale"]) {
492
+ if (text(mode) && mode !== "etag_required" && !text(concurrency["rationale"])) {
474
493
  err("relaxation_without_rationale", `mode \`${mode}\` weakens protection without a rationale`);
475
494
  }
476
- if (mode === "idempotency_by_formula" && !concurrency["formula"]) {
495
+ if (mode === "idempotency_by_formula" && !text(concurrency["formula"])) {
477
496
  err("missing_required_field", "idempotency_by_formula without `formula`");
478
497
  }
479
498
  // Round 24, from the first card a pilot's backend role wrote unaided: a soft delete that reads
@@ -484,44 +503,50 @@ export function validate(fm, filename = "", knownIds = null) {
484
503
  warn("concurrency_unexplained", "concurrency.mode is `none_unexplained` — no precondition is read and the code gives no reason; a measured absence, kept visible until it is explained (`none_by_design`) or closed (an `etag_*` mode)");
485
504
  }
486
505
  // ── interfaces ─────────────────────────────────────────────────────────────────────────────
487
- const interfaces = fm["interfaces"] ?? {};
506
+ const interfaces = map(fm["interfaces"]);
488
507
  if (Object.keys(interfaces).length === 0) {
489
508
  err("missing_required_field", "at least one interface is required");
490
509
  }
491
510
  for (const [name, iface] of Object.entries(interfaces)) {
492
- const transport = iface?.transport;
493
- if (!transport)
511
+ const transport = map(iface).transport;
512
+ if (absent(transport))
494
513
  err("missing_transport", `interface \`${name}\` does not declare a transport`);
495
514
  else if (!TRANSPORTS.includes(transport)) {
496
515
  err("invalid_enum_value", `interface \`${name}\`: transport \`${transport}\` is unknown`);
497
516
  }
498
- for (const ref of iface?.covers_steps ?? []) {
517
+ for (const ref of list(map(iface).covers_steps)) {
518
+ if (typeof ref !== "string")
519
+ continue;
499
520
  if (!stepIds.has(ref))
500
521
  err("unknown_step_reference", `interface \`${name}\` covers unknown step \`${ref}\``);
501
522
  }
502
523
  }
503
524
  // ── tests ──────────────────────────────────────────────────────────────────────────────────
504
525
  const covered = new Set();
505
- for (const test of tests) {
506
- const refs = Array.isArray(test?.covers) ? test.covers : [test?.covers];
526
+ for (const test of tests.map(map)) {
527
+ const id = text(test.id) ?? "";
528
+ const refs = Array.isArray(test.covers) ? test.covers : [test.covers];
507
529
  for (const ref of refs) {
530
+ if (typeof ref !== "string")
531
+ continue;
508
532
  if (!stepIds.has(ref))
509
- err("unknown_step_reference", `test \`${test?.id}\` covers unknown step \`${ref}\``);
533
+ err("unknown_step_reference", `test \`${id}\` covers unknown step \`${ref}\``);
510
534
  covered.add(ref);
511
535
  }
512
- if (!TEST_LEVELS.includes(test?.level)) {
513
- err("invalid_enum_value", `test \`${test?.id}\`: level \`${test?.level}\` is unknown`);
536
+ if (test.level !== undefined && !TEST_LEVELS.includes(test.level)) {
537
+ err("invalid_enum_value", `test \`${id}\`: level \`${test.level}\` is unknown`);
514
538
  }
515
539
  }
516
- const gaps = new Set((fm["coverage_gaps"] ?? []).map((gap) => gap?.step));
540
+ const gapEntries = list(fm["coverage_gaps"]).filter(isMap);
541
+ const gaps = new Set(gapEntries.map((gap) => gap.step).filter((step) => typeof step === "string"));
517
542
  for (const gap of gaps) {
518
543
  if (!stepIds.has(gap))
519
544
  err("unknown_step_reference", `coverage_gaps names unknown step \`${gap}\``);
520
545
  }
521
- for (const entry of (fm["coverage_gaps"] ?? [])) {
522
- const kind = entry?.kind;
546
+ for (const entry of gapEntries) {
547
+ const kind = entry.kind;
523
548
  if (kind !== undefined && !GAP_KINDS.includes(kind)) {
524
- err("invalid_enum_value", `coverage_gaps \`${entry?.step}\`: kind \`${kind}\` is not one of ${GAP_KINDS}`);
549
+ err("invalid_enum_value", `coverage_gaps \`${text(entry.step) ?? ""}\`: kind \`${kind}\` is not one of ${GAP_KINDS}`);
525
550
  }
526
551
  }
527
552
  // ── a variant of another operation (§5.2e) ────────────────────────────────────────────────
@@ -530,8 +555,10 @@ export function validate(fm, filename = "", knownIds = null) {
530
555
  // shared code is its own evidence. Whether the original agrees is check 2's question.
531
556
  const inheritedSteps = new Set();
532
557
  const variant = fm["variant_of"];
533
- if (variant && typeof variant === "object") {
534
- for (const sid of Array.isArray(variant.inherits) ? variant.inherits : []) {
558
+ if (isMap(variant)) {
559
+ for (const sid of list(variant.inherits)) {
560
+ if (typeof sid !== "string")
561
+ continue;
535
562
  if (!stepIds.has(sid)) {
536
563
  err("variant_inherits_unlisted_step", `\`variant_of.inherits\` names \`${sid}\`, which is not one of this card's steps — a variant lists every step it runs`);
537
564
  }
@@ -539,7 +566,8 @@ export function validate(fm, filename = "", knownIds = null) {
539
566
  }
540
567
  if (evidence["implemented"] !== undefined && typeof variant.shared === "string") {
541
568
  const own = (Array.isArray(evidence["implemented"]) ? evidence["implemented"] : [evidence["implemented"]])
542
- .flatMap((item) => String(item).split("+"))
569
+ .filter((item) => typeof item === "string")
570
+ .flatMap((item) => item.split("+"))
543
571
  .map((part) => part.trim().replace(/:\d+$/, ""));
544
572
  const shared = variant.shared.trim().replace(/:\d+$/, "");
545
573
  if (!own.includes(shared)) {
@@ -557,21 +585,24 @@ export function validate(fm, filename = "", knownIds = null) {
557
585
  // Three rules that need nothing but the card itself. Each one exists because the field it
558
586
  // guards is otherwise a claim nobody can be wrong about — and a claim nobody can be wrong
559
587
  // about is a claim nobody maintains.
560
- const outcomes = fm["outcomes"] ?? [];
561
- const outcomeIds = new Set(outcomes.map((o) => o?.id));
588
+ const outcomes = list(fm["outcomes"]).map(map);
589
+ const outcomeIds = new Set(outcomes.map((o) => o.id).filter((id) => typeof id === "string"));
590
+ const stepLines = steps.map(map);
591
+ const violation = (step) => map(step.on_violation);
562
592
  for (const [name, iface] of Object.entries(interfaces)) {
563
- const declared = iface?.responses ?? [];
593
+ const declared = list(map(iface).responses).filter((code) => status(code) !== undefined);
564
594
  if (declared.length === 0)
565
595
  continue; // the field is optional; absent is not a claim
566
596
  const returned = new Map();
567
- for (const step of steps) {
568
- const code = step?.on_violation?.http;
569
- if (typeof code === "number")
570
- returned.set(code, `step \`${step.id}\``);
597
+ for (const step of stepLines) {
598
+ const code = status(violation(step).http);
599
+ if (code !== undefined)
600
+ returned.set(code, `step \`${text(step.id) ?? ""}\``);
571
601
  }
572
602
  for (const outcome of outcomes) {
573
- if (typeof outcome?.http === "number")
574
- returned.set(outcome.http, `outcome \`${outcome.id}\``);
603
+ const code = status(outcome.http);
604
+ if (code !== undefined)
605
+ returned.set(code, `outcome \`${text(outcome.id) ?? ""}\``);
575
606
  }
576
607
  for (const [code, who] of returned) {
577
608
  if (!declared.includes(code)) {
@@ -584,16 +615,17 @@ export function validate(fm, filename = "", knownIds = null) {
584
615
  // shape of `steps[]` — violated, therefore stopped, therefore an error code — does not fit it.
585
616
  // A warning, not an error, because the honest bulk card would otherwise be unwritable.
586
617
  const perItem = fm["per_item"];
587
- for (const step of steps) {
588
- const code = step?.on_violation?.http;
589
- if (typeof code === "number" && code >= 200 && code < 300) {
590
- if (perItem) {
618
+ for (const step of stepLines) {
619
+ const code = status(violation(step).http);
620
+ const sid = text(step.id) ?? "";
621
+ if (code !== undefined && code >= 200 && code < 300) {
622
+ if (isMap(perItem)) {
591
623
  // With `per_item` declared there is a true place to write this, so writing it falsely
592
624
  // is a mistake rather than a shortage of vocabulary.
593
- err("per_item_failure_as_violation", `step \`${step.id}\` answers ${code}; this card declares \`per_item\`, so a per-item failure belongs there and carries no status`);
625
+ err("per_item_failure_as_violation", `step \`${sid}\` answers ${code}; this card declares \`per_item\`, so a per-item failure belongs there and carries no status`);
594
626
  }
595
627
  else {
596
- warn("violation_with_success_status", `step \`${step.id}\` is violated yet answers ${code} — either a typo, or this is a per-item failure and belongs in \`per_item\``);
628
+ warn("violation_with_success_status", `step \`${sid}\` is violated yet answers ${code} — either a typo, or this is a per-item failure and belongs in \`per_item\``);
597
629
  }
598
630
  }
599
631
  }
@@ -602,10 +634,11 @@ export function validate(fm, filename = "", knownIds = null) {
602
634
  // it. A request thread suspends on `done`, a *job state*, and waits there for a person. The
603
635
  // axis survived that; its check did not.
604
636
  const continuation = fm["continuation"];
605
- if (continuation && typeof continuation === "object") {
606
- const jobStates = fm["async_execution"]?.job_states ?? [];
607
- if (!outcomeIds.has(continuation.after) && !jobStates.includes(continuation.after)) {
608
- err("continuation_without_outcome", `\`continuation.after\` names \`${continuation.after}\`, which is neither a declared outcome nor a job state`);
637
+ if (isMap(continuation)) {
638
+ const jobStates = list(map(fm["async_execution"]).job_states);
639
+ const after = text(continuation.after) ?? "";
640
+ if (!outcomeIds.has(after) && !jobStates.includes(after)) {
641
+ err("continuation_without_outcome", `\`continuation.after\` names \`${after}\`, which is neither a declared outcome nor a job state`);
609
642
  }
610
643
  }
611
644
  // ── covers_outcomes ────────────────────────────────────────────────────────────────────────
@@ -624,19 +657,22 @@ export function validate(fm, filename = "", knownIds = null) {
624
657
  // which asks a genuinely different question. Six errors, all false, on the first honest card.
625
658
  const outcomeVocabulary = new Map();
626
659
  for (const outcome of outcomes) {
627
- if (outcome?.id)
628
- outcomeVocabulary.set(outcome.id, "outcomes");
660
+ const id = text(outcome.id);
661
+ if (id)
662
+ outcomeVocabulary.set(id, "outcomes");
629
663
  }
630
664
  const transition = fm["data_transition"];
631
- if (transition && typeof transition === "object") {
632
- const targets = Array.isArray(transition.to) ? transition.to : transition.to ? [transition.to] : [];
633
- for (const target of targets)
634
- outcomeVocabulary.set(target, "data_transition.to");
665
+ if (isMap(transition)) {
666
+ const targets = Array.isArray(transition.to) ? transition.to : [transition.to];
667
+ for (const target of targets) {
668
+ if (text(target))
669
+ outcomeVocabulary.set(target, "data_transition.to");
670
+ }
635
671
  }
636
- for (const step of steps) {
637
- const error = step?.on_violation?.error;
672
+ for (const step of stepLines) {
673
+ const error = text(violation(step).error);
638
674
  if (error)
639
- outcomeVocabulary.set(error, `step \`${step.id}\``);
675
+ outcomeVocabulary.set(error, `step \`${text(step.id) ?? ""}\``);
640
676
  }
641
677
  // Per-item failures belong here and job states do not, and the line between them is not taste.
642
678
  // A per-item failure arrives in THIS call's response — the user is looking at the screen when
@@ -644,9 +680,10 @@ export function validate(fm, filename = "", knownIds = null) {
644
680
  // measured a bulk screen that discards the response body entirely: ten items selected, three
645
681
  // rejected inside a 200, and the page says nothing at all. Without these in the vocabulary the
646
682
  // card had no way to admit it, which is the one thing this field exists to prevent.
647
- for (const failure of fm["per_item"]?.failures ?? []) {
648
- if (failure?.code)
649
- outcomeVocabulary.set(failure.code, "per_item failure");
683
+ for (const failure of list(map(perItem).failures)) {
684
+ const code = text(map(failure).code);
685
+ if (code)
686
+ outcomeVocabulary.set(code, "per_item failure");
650
687
  }
651
688
  // Round 23 wrote two read cards whose screens could describe every refusal and not the one
652
689
  // ending the user came for. A read has no `data_transition.to`, and with a single ending it has
@@ -660,8 +697,8 @@ export function validate(fm, filename = "", knownIds = null) {
660
697
  .map(([id]) => id);
661
698
  const hasDefaultSuccess = namedSuccesses.length === 0;
662
699
  for (const [name, iface] of Object.entries(interfaces)) {
663
- const covers = iface?.covers_outcomes;
664
- if (!covers || typeof covers !== "object")
700
+ const covers = map(iface).covers_outcomes;
701
+ if (!isMap(covers))
665
702
  continue;
666
703
  for (const key of Object.keys(covers)) {
667
704
  if (key === "ok") {
@@ -675,7 +712,7 @@ export function validate(fm, filename = "", knownIds = null) {
675
712
  }
676
713
  }
677
714
  for (const [id, where] of outcomeVocabulary) {
678
- if (!(id in covers)) {
715
+ if (!has(covers, id)) {
679
716
  err("outcome_not_covered", `interface \`${name}\`: outcome \`${id}\` (${where}) is absent from covers_outcomes — write it, even as null`);
680
717
  }
681
718
  else if (covers[id] === null) {
@@ -685,7 +722,7 @@ export function validate(fm, filename = "", knownIds = null) {
685
722
  // A warning, not an error: cards written before round 23 had no way to name the success,
686
723
  // and a gate must not turn red on them for a word that did not exist when they were written.
687
724
  if (hasDefaultSuccess) {
688
- if (!("ok" in covers)) {
725
+ if (!has(covers, "ok")) {
689
726
  warn("default_success_uncovered", `interface \`${name}\`: the default success has no line in covers_outcomes — write \`ok:\`, even as null`);
690
727
  }
691
728
  else if (covers["ok"] === null) {
@@ -713,12 +750,13 @@ export function validate(fm, filename = "", knownIds = null) {
713
750
  }
714
751
  }
715
752
  for (const [name, iface] of Object.entries(interfaces)) {
716
- for (const parameter of iface?.parameters ?? []) {
717
- if (parameter?.handling !== "decorative")
753
+ for (const parameter of list(map(iface).parameters).map(map)) {
754
+ const pname = text(parameter.name);
755
+ if (parameter.handling !== "decorative" || pname === undefined)
718
756
  continue;
719
- const path = iface?.path ?? "";
720
- if (!path.includes(`{${parameter.name}}`)) {
721
- err("decorative_parameter_not_in_path", `interface \`${name}\`: \`${parameter.name}\` is declared decorative but does not appear in \`${path || "(no path)"}\``);
757
+ const path = text(map(iface).path) ?? "";
758
+ if (!path.includes(`{${pname}}`)) {
759
+ err("decorative_parameter_not_in_path", `interface \`${name}\`: \`${pname}\` is declared decorative but does not appear in \`${path || "(no path)"}\``);
722
760
  }
723
761
  }
724
762
  }
@@ -741,23 +779,20 @@ export function validate(fm, filename = "", knownIds = null) {
741
779
  // #1: a compliance read had to pick between two lies, and the smaller lie was still a lie.
742
780
  if ("data_transition" in fm && fm["data_transition"] === null) {
743
781
  const provenance = fm["provenance"];
744
- const recordsOnly = provenance && typeof provenance === "object" && provenance.records_only === true;
745
- if (!fm["mutates"] && provenance !== "none" && !recordsOnly) {
782
+ const recordsOnly = isMap(provenance) && provenance.records_only === true;
783
+ if (!writesWithoutTransition && provenance !== "none" && !recordsOnly) {
746
784
  err("write_without_effect", "`data_transition: null` with neither `mutates`, `provenance: none`, nor `records_only: true` — the write does not say what it changes");
747
785
  }
748
786
  }
749
787
  // ── cross-card references ──────────────────────────────────────────────────────────────────
750
788
  if (knownIds !== null) {
751
- const reversibility = fm["reversibility"];
752
- if (reversibility && typeof reversibility === "object") {
753
- const target = reversibility.reversible_via;
754
- if (target && !knownIds.has(target)) {
755
- warn("undescribed_counterpart", `\`reversible_via\` points at undescribed \`${target}\``);
756
- }
789
+ const target = text(map(fm["reversibility"]).reversible_via);
790
+ if (target && !knownIds.has(target)) {
791
+ warn("undescribed_counterpart", `\`reversible_via\` points at undescribed \`${target}\``);
757
792
  }
758
- const serves = fm["serves_step"];
759
- if (serves && !knownIds.has(serves.operation)) {
760
- warn("undescribed_counterpart", `\`serves_step\` points at undescribed \`${serves.operation}\``);
793
+ const serves = text(map(fm["serves_step"]).operation);
794
+ if (serves !== undefined && !knownIds.has(serves)) {
795
+ warn("undescribed_counterpart", `\`serves_step\` points at undescribed \`${serves}\``);
761
796
  }
762
797
  }
763
798
  return out;