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/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 +150 -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,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
|
-
|
|
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 (!
|
|
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 (!
|
|
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 =
|
|
184
|
+
const presents = list(fm["presents"]);
|
|
165
185
|
const seenFields = new Set();
|
|
166
186
|
for (const [index, element] of presents.entries()) {
|
|
167
|
-
if (!element
|
|
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 =
|
|
202
|
+
const controls = list(fm["controls"]);
|
|
183
203
|
const seenControls = new Set();
|
|
184
204
|
for (const [index, control] of controls.entries()) {
|
|
185
|
-
if (!control
|
|
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 (
|
|
240
|
-
if (!group
|
|
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 (
|
|
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
|
|
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 (
|
|
275
|
-
if (!removed
|
|
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
|
-
|
|
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"]
|
|
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
|
|
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
|
|
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(
|
|
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
|
|
493
|
-
if (
|
|
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
|
|
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
|
|
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 \`${
|
|
542
|
+
err("unknown_step_reference", `test \`${id}\` covers unknown step \`${ref}\``);
|
|
510
543
|
covered.add(ref);
|
|
511
544
|
}
|
|
512
|
-
if (!TEST_LEVELS.includes(test
|
|
513
|
-
err("invalid_enum_value", `test \`${
|
|
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
|
|
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
|
|
522
|
-
const kind = entry
|
|
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
|
|
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
|
|
534
|
-
for (const sid of
|
|
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
|
-
.
|
|
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
|
|
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
|
|
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
|
|
568
|
-
const code = step
|
|
569
|
-
if (
|
|
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
|
-
|
|
574
|
-
|
|
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
|
|
588
|
-
const code = step
|
|
589
|
-
|
|
590
|
-
|
|
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 \`${
|
|
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 \`${
|
|
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
|
|
606
|
-
const jobStates = fm["async_execution"]
|
|
607
|
-
|
|
608
|
-
|
|
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
|
-
|
|
628
|
-
|
|
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
|
|
632
|
-
const targets = Array.isArray(transition.to) ? transition.to :
|
|
633
|
-
for (const target of targets)
|
|
634
|
-
|
|
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
|
|
637
|
-
const error = step
|
|
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
|
|
648
|
-
|
|
649
|
-
|
|
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
|
|
664
|
-
if (!covers
|
|
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
|
|
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"
|
|
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
|
|
717
|
-
|
|
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
|
|
720
|
-
if (!path.includes(`{${
|
|
721
|
-
err("decorative_parameter_not_in_path", `interface \`${name}\`: \`${
|
|
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 &&
|
|
745
|
-
if (!
|
|
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
|
|
752
|
-
if (
|
|
753
|
-
|
|
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
|
|
760
|
-
warn("undescribed_counterpart", `\`serves_step\` points at undescribed \`${serves
|
|
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;
|