usedesign 1.4.2 → 1.4.4

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,10 @@ 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
+ if (!absent(fm["id"]) && text(fm["id"]) === undefined) {
150
+ err("malformed_form_id", "`id` is not text, so it is not <area>.<object>.<name>");
151
+ }
152
+ const formId = text(fm["id"]) ?? "";
133
153
  if (formId && !OPERATION_ID.test(formId)) {
134
154
  err("malformed_form_id", `\`${formId}\` is not <area>.<object>.<name>`);
135
155
  }
@@ -143,16 +163,16 @@ export function validateForm(fm, filename = "", knownForms = null, knownCards =
143
163
  // every entry names the data state it lives in.
144
164
  const stateMap = fm["states"];
145
165
  if (stateMap !== undefined) {
146
- if (!stateMap || typeof stateMap !== "object" || Array.isArray(stateMap)) {
166
+ if (!isMap(stateMap)) {
147
167
  err("malformed_states", "`states` must be a map of screen state → { data: <data state> }");
148
168
  }
149
169
  else {
150
170
  for (const [state, spec] of Object.entries(stateMap)) {
151
- if (!spec || typeof spec !== "object" || Array.isArray(spec)) {
171
+ if (!isMap(spec)) {
152
172
  err("missing_required_field", `states.${state}: \`data\` is absent`);
153
173
  continue;
154
174
  }
155
- if (!spec["data"])
175
+ if (!text(spec["data"]))
156
176
  err("missing_required_field", `states.${state}: \`data\` is absent`);
157
177
  for (const key of Object.keys(spec)) {
158
178
  if (!STATE_KEYS.has(key))
@@ -161,12 +181,12 @@ export function validateForm(fm, filename = "", knownForms = null, knownCards =
161
181
  }
162
182
  }
163
183
  }
164
- const presents = Array.isArray(fm["presents"]) ? fm["presents"] : [];
184
+ const presents = list(fm["presents"]);
165
185
  const seenFields = new Set();
166
186
  for (const [index, element] of presents.entries()) {
167
- if (!element || typeof element !== "object")
187
+ if (!isMap(element))
168
188
  continue;
169
- if (!element["shows"])
189
+ if (!text(element["shows"]))
170
190
  err("missing_required_field", `presents[${index}]: \`shows\` is absent`);
171
191
  for (const key of Object.keys(element)) {
172
192
  if (!ELEMENT_KEYS.has(key))
@@ -179,10 +199,10 @@ export function validateForm(fm, filename = "", knownForms = null, knownCards =
179
199
  seenFields.add(field);
180
200
  }
181
201
  }
182
- const controls = Array.isArray(fm["controls"]) ? fm["controls"] : [];
202
+ const controls = list(fm["controls"]);
183
203
  const seenControls = new Set();
184
204
  for (const [index, control] of controls.entries()) {
185
- if (!control || typeof control !== "object")
205
+ if (!isMap(control))
186
206
  continue;
187
207
  for (const key of Object.keys(control)) {
188
208
  if (!CONTROL_KEYS.has(key))
@@ -194,9 +214,9 @@ export function validateForm(fm, filename = "", knownForms = null, knownCards =
194
214
  err("duplicate_control", `\`${name}\` appears more than once in controls`);
195
215
  seenControls.add(name);
196
216
  }
197
- const opens = control["opens"];
217
+ const opens = text(control["opens"]);
198
218
  if (opens && knownForms !== null && !knownForms.has(opens)) {
199
- warn("undescribed_form", `control \`${name}\` opens \`${opens}\`, which no contract in this set describes`);
219
+ warn("undescribed_form", `control \`${name ?? ""}\` opens \`${opens}\`, which no contract in this set describes`);
200
220
  }
201
221
  // `calls` names the operation a control runs, or null for local behaviour — and since round 27
202
222
  // the chain it runs, in call order: a draft's «save as plan» renames it, then publishes it.
@@ -223,7 +243,7 @@ export function validateForm(fm, filename = "", knownForms = null, knownCards =
223
243
  for (const [step, id] of chain.entries()) {
224
244
  if (!knownCards.has(id)) {
225
245
  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`);
246
+ warn("form_calls_undescribed", `control \`${name ?? ""}\` calls \`${id}\`${where}, which no card in this set describes`);
227
247
  }
228
248
  }
229
249
  }
@@ -236,34 +256,36 @@ export function validateForm(fm, filename = "", knownForms = null, knownCards =
236
256
  const members = new Set([...seenFields, ...seenControls]);
237
257
  const seenGroups = new Set();
238
258
  const claimed = new Map();
239
- for (const [index, group] of (Array.isArray(fm["groups"]) ? fm["groups"] : []).entries()) {
240
- if (!group || typeof group !== "object")
259
+ for (const [index, group] of list(fm["groups"]).entries()) {
260
+ if (!isMap(group))
241
261
  continue;
242
262
  for (const required of ["group", "role", "contains"]) {
243
- if (!group[required])
263
+ if (absent(group[required]))
244
264
  err("missing_required_field", `groups[${index}]: \`${required}\` is absent`);
245
265
  }
246
266
  for (const key of Object.keys(group)) {
247
267
  if (!GROUP_KEYS.has(key))
248
268
  err("unknown_field", `groups[${index}]: \`${key}\` is not part of a group line`);
249
269
  }
250
- const name = group["group"];
270
+ const name = text(group["group"]);
251
271
  if (name) {
252
272
  if (seenGroups.has(name))
253
273
  err("duplicate_group", `\`${name}\` appears more than once in groups`);
254
274
  seenGroups.add(name);
255
275
  }
256
276
  const role = group["role"];
257
- if (role && !GROUP_ROLES.includes(role)) {
277
+ if (!absent(role) && !GROUP_ROLES.includes(role)) {
258
278
  err("invalid_enum_value", `groups[${index}]: role \`${role}\` is not one of ${GROUP_ROLES}`);
259
279
  }
260
- for (const member of Array.isArray(group["contains"]) ? group["contains"] : []) {
280
+ for (const member of list(group["contains"])) {
281
+ if (typeof member !== "string")
282
+ continue;
261
283
  if (!members.has(member)) {
262
- err("unknown_group_member", `group \`${name}\` contains \`${member}\`, which no element or control declares`);
284
+ err("unknown_group_member", `group \`${name ?? ""}\` contains \`${member}\`, which no element or control declares`);
263
285
  }
264
286
  const already = claimed.get(member);
265
287
  if (already && already !== name) {
266
- err("element_in_two_groups", `\`${member}\` sits in \`${already}\` and \`${name}\` — an element renders in one place`);
288
+ err("element_in_two_groups", `\`${member}\` sits in \`${already}\` and \`${name ?? ""}\` — an element renders in one place`);
267
289
  }
268
290
  if (name)
269
291
  claimed.set(member, name);
@@ -271,16 +293,16 @@ export function validateForm(fm, filename = "", knownForms = null, knownCards =
271
293
  }
272
294
  // One document both requiring and forbidding a control is not incompleteness — it is the
273
295
  // 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")
296
+ for (const [index, removed] of list(fm["removed"]).entries()) {
297
+ if (!isMap(removed))
276
298
  continue;
277
- if (!removed["control"])
299
+ if (!text(removed["control"]))
278
300
  err("missing_required_field", `removed[${index}]: \`control\` is absent`);
279
301
  for (const key of Object.keys(removed)) {
280
302
  if (!REMOVED_KEYS.has(key))
281
303
  err("unknown_field", `removed[${index}]: \`${key}\` is not part of a removed line`);
282
304
  }
283
- const name = removed["control"];
305
+ const name = text(removed["control"]);
284
306
  if (name && seenControls.has(name)) {
285
307
  err("removed_also_required", `\`${name}\` is listed in controls and in removed — the contract both requires and forbids it`);
286
308
  }
@@ -403,7 +425,13 @@ export function validate(fm, filename = "", knownIds = null) {
403
425
  if (!(field in fm))
404
426
  err("missing_required_field", `\`${field}\` is absent`);
405
427
  }
406
- const cardId = fm["id"] ?? "";
428
+ // An id written as something other than text is still a malformed id (1.4.4). Read in its type
429
+ // since 1.4.3, `id: 1` became absent to the rules and, without the schema layer, nothing said a
430
+ // word about it; up to 1.4.2 it was reported here.
431
+ if (!absent(fm["id"]) && text(fm["id"]) === undefined) {
432
+ err("malformed_operation_id", "`id` is not text, so it is not <area>.<object>.<action>");
433
+ }
434
+ const cardId = text(fm["id"]) ?? "";
407
435
  if (cardId && !OPERATION_ID.test(cardId)) {
408
436
  err("malformed_operation_id", `\`${cardId}\` is not <area>.<object>.<action>`);
409
437
  }
@@ -421,7 +449,7 @@ export function validate(fm, filename = "", knownIds = null) {
421
449
  // states — resume from finished | partial | abandoned. Measured on a live service, where the only
422
450
  // honest alternative was `from: any`, which switches the shown_when rule off. One state is written
423
451
  // 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;
452
+ const fromValue = map(fm["data_transition"]).from;
425
453
  if (Array.isArray(fromValue)) {
426
454
  if (fromValue.length < 2)
427
455
  err("malformed_transition", "`data_transition.from` as a set needs at least two states — one state is written as a string");
@@ -429,10 +457,10 @@ export function validate(fm, filename = "", knownIds = null) {
429
457
  err("malformed_transition", "`data_transition.from` lists the same state twice");
430
458
  }
431
459
  // ── steps ──────────────────────────────────────────────────────────────────────────────────
432
- const steps = fm["steps"] ?? [];
460
+ const steps = list(fm["steps"]);
433
461
  const stepIds = new Set();
434
462
  for (const step of steps) {
435
- const sid = step?.id ?? "";
463
+ const sid = text(map(step).id) ?? "";
436
464
  if (!STEP_ID.test(sid))
437
465
  err("malformed_step_id", `\`${sid}\` is not s<N>-<name>`);
438
466
  else if (stepIds.has(sid))
@@ -440,9 +468,9 @@ export function validate(fm, filename = "", knownIds = null) {
440
468
  stepIds.add(sid);
441
469
  }
442
470
  // ── maturity vs evidence ───────────────────────────────────────────────────────────────────
443
- const evidence = fm["maturity_evidence"] ?? {};
444
- const tests = fm["tests"] ?? [];
445
- if (maturity && maturity in EVIDENCE_FOR) {
471
+ const evidence = map(fm["maturity_evidence"]);
472
+ const tests = list(fm["tests"]);
473
+ if (typeof maturity === "string" && has(EVIDENCE_FOR, maturity)) {
446
474
  // Every level implies the ones below it.
447
475
  for (const level of ["implemented", "tested", "in_production"]) {
448
476
  if (MATURITY.indexOf(maturity) >= MATURITY.indexOf(level)) {
@@ -460,20 +488,20 @@ export function validate(fm, filename = "", knownIds = null) {
460
488
  // claim, and it rots by hand: cards keep "5 tests" after the sixth was cited, or after one was
461
489
  // 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
490
  const testedProse = evidence["tested"];
463
- const testedMatch = typeof testedProse === "number" ? [null, String(testedProse)] : /^\s*(\d+)/.exec(String(testedProse ?? ""));
491
+ const testedMatch = typeof testedProse === "number" ? [null, String(testedProse)] : typeof testedProse === "string" ? /^\s*(\d+)/.exec(testedProse) : null;
464
492
  if (testedMatch && Number(testedMatch[1]) !== tests.length) {
465
493
  warn("tested_count_mismatch", `maturity_evidence.tested says ${testedMatch[1]}, tests[] lists ${tests.length} — the count went stale`);
466
494
  }
467
495
  // ── concurrency ────────────────────────────────────────────────────────────────────────────
468
- const concurrency = fm["concurrency"] ?? {};
496
+ const concurrency = map(fm["concurrency"]);
469
497
  const mode = concurrency["mode"];
470
498
  if (mode !== undefined && !CONCURRENCY_MODES.includes(mode)) {
471
499
  err("invalid_enum_value", `concurrency.mode \`${mode}\` is not one of ${CONCURRENCY_MODES}`);
472
500
  }
473
- if (mode && mode !== "etag_required" && !concurrency["rationale"]) {
501
+ if (text(mode) && mode !== "etag_required" && !text(concurrency["rationale"])) {
474
502
  err("relaxation_without_rationale", `mode \`${mode}\` weakens protection without a rationale`);
475
503
  }
476
- if (mode === "idempotency_by_formula" && !concurrency["formula"]) {
504
+ if (mode === "idempotency_by_formula" && !text(concurrency["formula"])) {
477
505
  err("missing_required_field", "idempotency_by_formula without `formula`");
478
506
  }
479
507
  // Round 24, from the first card a pilot's backend role wrote unaided: a soft delete that reads
@@ -484,44 +512,50 @@ export function validate(fm, filename = "", knownIds = null) {
484
512
  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
513
  }
486
514
  // ── interfaces ─────────────────────────────────────────────────────────────────────────────
487
- const interfaces = fm["interfaces"] ?? {};
515
+ const interfaces = map(fm["interfaces"]);
488
516
  if (Object.keys(interfaces).length === 0) {
489
517
  err("missing_required_field", "at least one interface is required");
490
518
  }
491
519
  for (const [name, iface] of Object.entries(interfaces)) {
492
- const transport = iface?.transport;
493
- if (!transport)
520
+ const transport = map(iface).transport;
521
+ if (absent(transport))
494
522
  err("missing_transport", `interface \`${name}\` does not declare a transport`);
495
523
  else if (!TRANSPORTS.includes(transport)) {
496
524
  err("invalid_enum_value", `interface \`${name}\`: transport \`${transport}\` is unknown`);
497
525
  }
498
- for (const ref of iface?.covers_steps ?? []) {
526
+ for (const ref of list(map(iface).covers_steps)) {
527
+ if (typeof ref !== "string")
528
+ continue;
499
529
  if (!stepIds.has(ref))
500
530
  err("unknown_step_reference", `interface \`${name}\` covers unknown step \`${ref}\``);
501
531
  }
502
532
  }
503
533
  // ── tests ──────────────────────────────────────────────────────────────────────────────────
504
534
  const covered = new Set();
505
- for (const test of tests) {
506
- const refs = Array.isArray(test?.covers) ? test.covers : [test?.covers];
535
+ for (const test of tests.map(map)) {
536
+ const id = text(test.id) ?? "";
537
+ const refs = Array.isArray(test.covers) ? test.covers : [test.covers];
507
538
  for (const ref of refs) {
539
+ if (typeof ref !== "string")
540
+ continue;
508
541
  if (!stepIds.has(ref))
509
- err("unknown_step_reference", `test \`${test?.id}\` covers unknown step \`${ref}\``);
542
+ err("unknown_step_reference", `test \`${id}\` covers unknown step \`${ref}\``);
510
543
  covered.add(ref);
511
544
  }
512
- if (!TEST_LEVELS.includes(test?.level)) {
513
- err("invalid_enum_value", `test \`${test?.id}\`: level \`${test?.level}\` is unknown`);
545
+ if (test.level !== undefined && !TEST_LEVELS.includes(test.level)) {
546
+ err("invalid_enum_value", `test \`${id}\`: level \`${test.level}\` is unknown`);
514
547
  }
515
548
  }
516
- const gaps = new Set((fm["coverage_gaps"] ?? []).map((gap) => gap?.step));
549
+ const gapEntries = list(fm["coverage_gaps"]).filter(isMap);
550
+ const gaps = new Set(gapEntries.map((gap) => gap.step).filter((step) => typeof step === "string"));
517
551
  for (const gap of gaps) {
518
552
  if (!stepIds.has(gap))
519
553
  err("unknown_step_reference", `coverage_gaps names unknown step \`${gap}\``);
520
554
  }
521
- for (const entry of (fm["coverage_gaps"] ?? [])) {
522
- const kind = entry?.kind;
555
+ for (const entry of gapEntries) {
556
+ const kind = entry.kind;
523
557
  if (kind !== undefined && !GAP_KINDS.includes(kind)) {
524
- err("invalid_enum_value", `coverage_gaps \`${entry?.step}\`: kind \`${kind}\` is not one of ${GAP_KINDS}`);
558
+ err("invalid_enum_value", `coverage_gaps \`${text(entry.step) ?? ""}\`: kind \`${kind}\` is not one of ${GAP_KINDS}`);
525
559
  }
526
560
  }
527
561
  // ── a variant of another operation (§5.2e) ────────────────────────────────────────────────
@@ -530,8 +564,10 @@ export function validate(fm, filename = "", knownIds = null) {
530
564
  // shared code is its own evidence. Whether the original agrees is check 2's question.
531
565
  const inheritedSteps = new Set();
532
566
  const variant = fm["variant_of"];
533
- if (variant && typeof variant === "object") {
534
- for (const sid of Array.isArray(variant.inherits) ? variant.inherits : []) {
567
+ if (isMap(variant)) {
568
+ for (const sid of list(variant.inherits)) {
569
+ if (typeof sid !== "string")
570
+ continue;
535
571
  if (!stepIds.has(sid)) {
536
572
  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
573
  }
@@ -539,7 +575,8 @@ export function validate(fm, filename = "", knownIds = null) {
539
575
  }
540
576
  if (evidence["implemented"] !== undefined && typeof variant.shared === "string") {
541
577
  const own = (Array.isArray(evidence["implemented"]) ? evidence["implemented"] : [evidence["implemented"]])
542
- .flatMap((item) => String(item).split("+"))
578
+ .filter((item) => typeof item === "string")
579
+ .flatMap((item) => item.split("+"))
543
580
  .map((part) => part.trim().replace(/:\d+$/, ""));
544
581
  const shared = variant.shared.trim().replace(/:\d+$/, "");
545
582
  if (!own.includes(shared)) {
@@ -557,21 +594,24 @@ export function validate(fm, filename = "", knownIds = null) {
557
594
  // Three rules that need nothing but the card itself. Each one exists because the field it
558
595
  // guards is otherwise a claim nobody can be wrong about — and a claim nobody can be wrong
559
596
  // about is a claim nobody maintains.
560
- const outcomes = fm["outcomes"] ?? [];
561
- const outcomeIds = new Set(outcomes.map((o) => o?.id));
597
+ const outcomes = list(fm["outcomes"]).map(map);
598
+ const outcomeIds = new Set(outcomes.map((o) => o.id).filter((id) => typeof id === "string"));
599
+ const stepLines = steps.map(map);
600
+ const violation = (step) => map(step.on_violation);
562
601
  for (const [name, iface] of Object.entries(interfaces)) {
563
- const declared = iface?.responses ?? [];
602
+ const declared = list(map(iface).responses).filter((code) => status(code) !== undefined);
564
603
  if (declared.length === 0)
565
604
  continue; // the field is optional; absent is not a claim
566
605
  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}\``);
606
+ for (const step of stepLines) {
607
+ const code = status(violation(step).http);
608
+ if (code !== undefined)
609
+ returned.set(code, `step \`${text(step.id) ?? ""}\``);
571
610
  }
572
611
  for (const outcome of outcomes) {
573
- if (typeof outcome?.http === "number")
574
- returned.set(outcome.http, `outcome \`${outcome.id}\``);
612
+ const code = status(outcome.http);
613
+ if (code !== undefined)
614
+ returned.set(code, `outcome \`${text(outcome.id) ?? ""}\``);
575
615
  }
576
616
  for (const [code, who] of returned) {
577
617
  if (!declared.includes(code)) {
@@ -584,16 +624,17 @@ export function validate(fm, filename = "", knownIds = null) {
584
624
  // shape of `steps[]` — violated, therefore stopped, therefore an error code — does not fit it.
585
625
  // A warning, not an error, because the honest bulk card would otherwise be unwritable.
586
626
  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) {
627
+ for (const step of stepLines) {
628
+ const code = status(violation(step).http);
629
+ const sid = text(step.id) ?? "";
630
+ if (code !== undefined && code >= 200 && code < 300) {
631
+ if (isMap(perItem)) {
591
632
  // With `per_item` declared there is a true place to write this, so writing it falsely
592
633
  // 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`);
634
+ 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
635
  }
595
636
  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\``);
637
+ 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
638
  }
598
639
  }
599
640
  }
@@ -602,10 +643,11 @@ export function validate(fm, filename = "", knownIds = null) {
602
643
  // it. A request thread suspends on `done`, a *job state*, and waits there for a person. The
603
644
  // axis survived that; its check did not.
604
645
  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`);
646
+ if (isMap(continuation)) {
647
+ const jobStates = list(map(fm["async_execution"]).job_states);
648
+ const after = text(continuation.after) ?? "";
649
+ if (!outcomeIds.has(after) && !jobStates.includes(after)) {
650
+ err("continuation_without_outcome", `\`continuation.after\` names \`${after}\`, which is neither a declared outcome nor a job state`);
609
651
  }
610
652
  }
611
653
  // ── covers_outcomes ────────────────────────────────────────────────────────────────────────
@@ -624,19 +666,22 @@ export function validate(fm, filename = "", knownIds = null) {
624
666
  // which asks a genuinely different question. Six errors, all false, on the first honest card.
625
667
  const outcomeVocabulary = new Map();
626
668
  for (const outcome of outcomes) {
627
- if (outcome?.id)
628
- outcomeVocabulary.set(outcome.id, "outcomes");
669
+ const id = text(outcome.id);
670
+ if (id)
671
+ outcomeVocabulary.set(id, "outcomes");
629
672
  }
630
673
  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");
674
+ if (isMap(transition)) {
675
+ const targets = Array.isArray(transition.to) ? transition.to : [transition.to];
676
+ for (const target of targets) {
677
+ if (text(target))
678
+ outcomeVocabulary.set(target, "data_transition.to");
679
+ }
635
680
  }
636
- for (const step of steps) {
637
- const error = step?.on_violation?.error;
681
+ for (const step of stepLines) {
682
+ const error = text(violation(step).error);
638
683
  if (error)
639
- outcomeVocabulary.set(error, `step \`${step.id}\``);
684
+ outcomeVocabulary.set(error, `step \`${text(step.id) ?? ""}\``);
640
685
  }
641
686
  // Per-item failures belong here and job states do not, and the line between them is not taste.
642
687
  // A per-item failure arrives in THIS call's response — the user is looking at the screen when
@@ -644,9 +689,10 @@ export function validate(fm, filename = "", knownIds = null) {
644
689
  // measured a bulk screen that discards the response body entirely: ten items selected, three
645
690
  // rejected inside a 200, and the page says nothing at all. Without these in the vocabulary the
646
691
  // 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");
692
+ for (const failure of list(map(perItem).failures)) {
693
+ const code = text(map(failure).code);
694
+ if (code)
695
+ outcomeVocabulary.set(code, "per_item failure");
650
696
  }
651
697
  // Round 23 wrote two read cards whose screens could describe every refusal and not the one
652
698
  // ending the user came for. A read has no `data_transition.to`, and with a single ending it has
@@ -660,8 +706,8 @@ export function validate(fm, filename = "", knownIds = null) {
660
706
  .map(([id]) => id);
661
707
  const hasDefaultSuccess = namedSuccesses.length === 0;
662
708
  for (const [name, iface] of Object.entries(interfaces)) {
663
- const covers = iface?.covers_outcomes;
664
- if (!covers || typeof covers !== "object")
709
+ const covers = map(iface).covers_outcomes;
710
+ if (!isMap(covers))
665
711
  continue;
666
712
  for (const key of Object.keys(covers)) {
667
713
  if (key === "ok") {
@@ -675,7 +721,7 @@ export function validate(fm, filename = "", knownIds = null) {
675
721
  }
676
722
  }
677
723
  for (const [id, where] of outcomeVocabulary) {
678
- if (!(id in covers)) {
724
+ if (!has(covers, id)) {
679
725
  err("outcome_not_covered", `interface \`${name}\`: outcome \`${id}\` (${where}) is absent from covers_outcomes — write it, even as null`);
680
726
  }
681
727
  else if (covers[id] === null) {
@@ -685,7 +731,7 @@ export function validate(fm, filename = "", knownIds = null) {
685
731
  // A warning, not an error: cards written before round 23 had no way to name the success,
686
732
  // and a gate must not turn red on them for a word that did not exist when they were written.
687
733
  if (hasDefaultSuccess) {
688
- if (!("ok" in covers)) {
734
+ if (!has(covers, "ok")) {
689
735
  warn("default_success_uncovered", `interface \`${name}\`: the default success has no line in covers_outcomes — write \`ok:\`, even as null`);
690
736
  }
691
737
  else if (covers["ok"] === null) {
@@ -713,12 +759,13 @@ export function validate(fm, filename = "", knownIds = null) {
713
759
  }
714
760
  }
715
761
  for (const [name, iface] of Object.entries(interfaces)) {
716
- for (const parameter of iface?.parameters ?? []) {
717
- if (parameter?.handling !== "decorative")
762
+ for (const parameter of list(map(iface).parameters).map(map)) {
763
+ const pname = text(parameter.name);
764
+ if (parameter.handling !== "decorative" || pname === undefined)
718
765
  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)"}\``);
766
+ const path = text(map(iface).path) ?? "";
767
+ if (!path.includes(`{${pname}}`)) {
768
+ err("decorative_parameter_not_in_path", `interface \`${name}\`: \`${pname}\` is declared decorative but does not appear in \`${path || "(no path)"}\``);
722
769
  }
723
770
  }
724
771
  }
@@ -741,23 +788,20 @@ export function validate(fm, filename = "", knownIds = null) {
741
788
  // #1: a compliance read had to pick between two lies, and the smaller lie was still a lie.
742
789
  if ("data_transition" in fm && fm["data_transition"] === null) {
743
790
  const provenance = fm["provenance"];
744
- const recordsOnly = provenance && typeof provenance === "object" && provenance.records_only === true;
745
- if (!fm["mutates"] && provenance !== "none" && !recordsOnly) {
791
+ const recordsOnly = isMap(provenance) && provenance.records_only === true;
792
+ if (!writesWithoutTransition && provenance !== "none" && !recordsOnly) {
746
793
  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
794
  }
748
795
  }
749
796
  // ── cross-card references ──────────────────────────────────────────────────────────────────
750
797
  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
- }
798
+ const target = text(map(fm["reversibility"]).reversible_via);
799
+ if (target && !knownIds.has(target)) {
800
+ warn("undescribed_counterpart", `\`reversible_via\` points at undescribed \`${target}\``);
757
801
  }
758
- const serves = fm["serves_step"];
759
- if (serves && !knownIds.has(serves.operation)) {
760
- warn("undescribed_counterpart", `\`serves_step\` points at undescribed \`${serves.operation}\``);
802
+ const serves = text(map(fm["serves_step"]).operation);
803
+ if (serves !== undefined && !knownIds.has(serves)) {
804
+ warn("undescribed_counterpart", `\`serves_step\` points at undescribed \`${serves}\``);
761
805
  }
762
806
  }
763
807
  return out;