usedesign 1.4.2 → 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/checks.js +7 -8
- package/dist/checks.js.map +1 -1
- package/dist/cli.js +47 -14
- package/dist/cli.js.map +1 -1
- package/dist/core.d.ts +6 -0
- package/dist/core.js +126 -36
- package/dist/core.js.map +1 -1
- package/dist/preview.js +2 -5
- package/dist/preview.js.map +1 -1
- package/dist/validate.js +141 -106
- package/dist/validate.js.map +1 -1
- package/package.json +1 -1
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 && !
|
|
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
|
|
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 (!
|
|
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 (!
|
|
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 =
|
|
181
|
+
const presents = list(fm["presents"]);
|
|
165
182
|
const seenFields = new Set();
|
|
166
183
|
for (const [index, element] of presents.entries()) {
|
|
167
|
-
if (!element
|
|
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 =
|
|
199
|
+
const controls = list(fm["controls"]);
|
|
183
200
|
const seenControls = new Set();
|
|
184
201
|
for (const [index, control] of controls.entries()) {
|
|
185
|
-
if (!control
|
|
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 (
|
|
240
|
-
if (!group
|
|
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 (
|
|
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
|
|
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 (
|
|
275
|
-
if (!removed
|
|
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"]
|
|
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
|
|
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
|
|
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(
|
|
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
|
|
493
|
-
if (
|
|
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
|
|
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
|
|
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 \`${
|
|
533
|
+
err("unknown_step_reference", `test \`${id}\` covers unknown step \`${ref}\``);
|
|
510
534
|
covered.add(ref);
|
|
511
535
|
}
|
|
512
|
-
if (!TEST_LEVELS.includes(test
|
|
513
|
-
err("invalid_enum_value", `test \`${
|
|
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
|
|
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
|
|
522
|
-
const kind = entry
|
|
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
|
|
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
|
|
534
|
-
for (const sid of
|
|
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
|
-
.
|
|
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
|
|
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
|
|
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
|
|
568
|
-
const code = step
|
|
569
|
-
if (
|
|
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
|
-
|
|
574
|
-
|
|
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
|
|
588
|
-
const code = step
|
|
589
|
-
|
|
590
|
-
|
|
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 \`${
|
|
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 \`${
|
|
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
|
|
606
|
-
const jobStates = fm["async_execution"]
|
|
607
|
-
|
|
608
|
-
|
|
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
|
-
|
|
628
|
-
|
|
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
|
|
632
|
-
const targets = Array.isArray(transition.to) ? transition.to :
|
|
633
|
-
for (const target of targets)
|
|
634
|
-
|
|
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
|
|
637
|
-
const error = step
|
|
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
|
|
648
|
-
|
|
649
|
-
|
|
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
|
|
664
|
-
if (!covers
|
|
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
|
|
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"
|
|
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
|
|
717
|
-
|
|
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
|
|
720
|
-
if (!path.includes(`{${
|
|
721
|
-
err("decorative_parameter_not_in_path", `interface \`${name}\`: \`${
|
|
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 &&
|
|
745
|
-
if (!
|
|
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
|
|
752
|
-
if (
|
|
753
|
-
|
|
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
|
|
760
|
-
warn("undescribed_counterpart", `\`serves_step\` points at undescribed \`${serves
|
|
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;
|