usedesign 1.5.0 → 1.5.2
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +35 -26
- package/dist/checks.d.ts +4 -1
- package/dist/checks.js +189 -123
- package/dist/checks.js.map +1 -1
- package/dist/cli.js +4 -3
- package/dist/cli.js.map +1 -1
- package/dist/conformance.d.ts +2 -2
- package/dist/conformance.js +2 -2
- package/dist/conformance.js.map +1 -1
- package/dist/core.d.ts +24 -0
- package/dist/core.js +98 -4
- package/dist/core.js.map +1 -1
- package/dist/scaffold.js +4 -4
- package/dist/scaffold.js.map +1 -1
- package/dist/schema/config.schema.json +1 -1
- package/dist/validate.js +2 -5
- package/dist/validate.js.map +1 -1
- package/package.json +3 -3
package/dist/checks.js
CHANGED
|
@@ -1,16 +1,18 @@
|
|
|
1
1
|
/**
|
|
2
|
-
* The
|
|
2
|
+
* The five checks. Each one has the same shape: the repository states a fact in a format it
|
|
3
3
|
* already produces, and usedesign compares that statement with the cards. Nothing here parses a
|
|
4
4
|
* framework, reads source code, or guesses.
|
|
5
5
|
*
|
|
6
|
-
* 1. no wild endpoints
|
|
7
|
-
* 2. no unproven steps
|
|
8
|
-
* 3. no inflated maturity
|
|
6
|
+
* 1. no wild endpoints design/route-conformance.md
|
|
7
|
+
* 2. no unproven steps design/step-coverage.md
|
|
8
|
+
* 3. no inflated maturity design/maturity-evidence.md
|
|
9
|
+
* 4. no imagined storage SPEC §7.4
|
|
10
|
+
* 5. the form matches its contract SPEC §7.5
|
|
9
11
|
*/
|
|
10
12
|
import { existsSync, readFileSync } from "node:fs";
|
|
11
13
|
import { join } from "node:path";
|
|
12
|
-
import {
|
|
13
|
-
import { Finding, expandGlobs, loadCardFiles, readDocument } from "./core.js";
|
|
14
|
+
import { SaxesParser } from "saxes";
|
|
15
|
+
import { Finding, expandGlobs, globMatches, isMap, loadCardFiles, map, maps, readDocument, refuseTwins, text, texts, whyUnreadable, } from "./core.js";
|
|
14
16
|
const METHODS = ["GET", "POST", "PUT", "PATCH", "DELETE", "HEAD", "OPTIONS"];
|
|
15
17
|
/**
|
|
16
18
|
* Parameter syntaxes seen in the wild: {name} · :name · <name> · <converter:name>
|
|
@@ -42,11 +44,12 @@ function loadDeclared(config, base) {
|
|
|
42
44
|
const maturityById = new Map();
|
|
43
45
|
const { cards, findings } = loadCardFiles(config, base);
|
|
44
46
|
for (const [cardId, fm] of cards) {
|
|
45
|
-
maturityById.set(cardId,
|
|
46
|
-
for (const [name, iface] of Object.entries(fm["interfaces"]
|
|
47
|
-
if (!iface ||
|
|
47
|
+
maturityById.set(cardId, text(fm["maturity"]) ?? "");
|
|
48
|
+
for (const [name, iface] of Object.entries(map(fm["interfaces"]))) {
|
|
49
|
+
if (!isMap(iface) || iface["transport"] !== "http_rest")
|
|
48
50
|
continue;
|
|
49
|
-
const
|
|
51
|
+
const method = text(iface["method"]);
|
|
52
|
+
const path = text(iface["path"]);
|
|
50
53
|
if (!method || !path) {
|
|
51
54
|
findings.push(new Finding("incomplete_rest_interface", `${cardId}: interface \`${name}\` declares http_rest without method or path`));
|
|
52
55
|
continue;
|
|
@@ -54,12 +57,14 @@ function loadDeclared(config, base) {
|
|
|
54
57
|
// The key stays the route shape — that is what the inventory can be compared against.
|
|
55
58
|
// Dispatch tells cards apart from each other, not routes from each other.
|
|
56
59
|
const key = normalise(method, path);
|
|
57
|
-
const d = iface
|
|
60
|
+
const d = map(iface["dispatch"]);
|
|
61
|
+
const by = text(d["by"]);
|
|
62
|
+
const value = text(d["value"]);
|
|
58
63
|
declared.set(key, [...(declared.get(key) ?? []), cardId]);
|
|
59
|
-
dispatchOf.set(`${key}\u0000${cardId}`,
|
|
64
|
+
dispatchOf.set(`${key}\u0000${cardId}`, by && value ? `${by}=${value}` : "");
|
|
60
65
|
}
|
|
61
66
|
}
|
|
62
|
-
for (const [key, owners] of
|
|
67
|
+
for (const [key, owners] of byKey(declared)) {
|
|
63
68
|
if (owners.length < 2)
|
|
64
69
|
continue;
|
|
65
70
|
// Several operations may legitimately share a route, told apart by a request field — a
|
|
@@ -72,6 +77,14 @@ function loadDeclared(config, base) {
|
|
|
72
77
|
}
|
|
73
78
|
return { declared, maturityById, findings };
|
|
74
79
|
}
|
|
80
|
+
/**
|
|
81
|
+
* Declared routes in the order of their `METHOD shape` (1.5.1). Sorted as pairs, they were ordered
|
|
82
|
+
* by the text `GET /a,owner` — `GET /a+b` before `GET /a` — and the Python prototype by the pair
|
|
83
|
+
* itself, so two routes could print in a different order in each.
|
|
84
|
+
*/
|
|
85
|
+
function byKey(declared) {
|
|
86
|
+
return [...declared.entries()].sort((a, b) => (a[0] < b[0] ? -1 : a[0] > b[0] ? 1 : 0));
|
|
87
|
+
}
|
|
75
88
|
function loadInventory(config, base) {
|
|
76
89
|
const location = config.inventory;
|
|
77
90
|
if (!location) {
|
|
@@ -103,17 +116,13 @@ function loadInventory(config, base) {
|
|
|
103
116
|
}
|
|
104
117
|
return { routes, findings, producedBy: data.produced_by ?? "" };
|
|
105
118
|
}
|
|
106
|
-
/**
|
|
107
|
-
function matchesPattern(shape, pattern) {
|
|
108
|
-
const escaped = pattern.replace(/[.+^${}()|[\]\\]/g, "\\$&").replace(/\*/g, "[^/]*").replace(/\?/g, "[^/]");
|
|
109
|
-
return new RegExp(`^${escaped}$`).test(shape);
|
|
110
|
-
}
|
|
119
|
+
/** The rule that hides this route, by the glob of a `cards:` segment (1.5.1), or -1. */
|
|
111
120
|
function excludedBy(config, method, shape) {
|
|
112
121
|
const rules = config.exclude ?? [];
|
|
113
122
|
for (const [index, rule] of rules.entries()) {
|
|
114
123
|
if (rule.method && rule.method.toUpperCase() !== method)
|
|
115
124
|
continue;
|
|
116
|
-
if (
|
|
125
|
+
if (globMatches(shape, rule.path ?? ""))
|
|
117
126
|
return index;
|
|
118
127
|
}
|
|
119
128
|
return -1;
|
|
@@ -140,7 +149,7 @@ export function checkRoutes(config, base) {
|
|
|
140
149
|
findings.push(new Finding("wild_endpoint", `${key} is served but declared by no card${entry.source ? ` (source: ${entry.source})` : ""}`));
|
|
141
150
|
}
|
|
142
151
|
}
|
|
143
|
-
for (const [key, owners] of
|
|
152
|
+
for (const [key, owners] of byKey(declared)) {
|
|
144
153
|
const method = key.slice(0, key.indexOf(" "));
|
|
145
154
|
const shape = key.slice(key.indexOf(" ") + 1);
|
|
146
155
|
if (routes.length > 0 && !served.has(key) && excludedBy(config, method, shape) === -1) {
|
|
@@ -188,6 +197,55 @@ export function checkRoutes(config, base) {
|
|
|
188
197
|
// The repository states the facts, the checker compares — the same shape as check 1, except that
|
|
189
198
|
// here the format already exists (JUnit XML), so nothing new is invented.
|
|
190
199
|
const PARAMETRISED = /[[(].*$/; // checkout[blocked] · checkout(1) → checkout
|
|
200
|
+
/**
|
|
201
|
+
* The test cases of a report, read as XML 1.0 reads it: a `testcase` element in no namespace, at any
|
|
202
|
+
* depth, in document order; its `classname` and `name`; and its status from its own children —
|
|
203
|
+
* `failure` or `error` fails it, `skipped` skips it, in any letter case, and the last of them decides.
|
|
204
|
+
*
|
|
205
|
+
* 1.5.1 read the report with a lenient parser: a report cut off while it was written read as far as
|
|
206
|
+
* it went, so a test cut before its `<failure>` passed; past 1 000 escaped characters (`"`) the
|
|
207
|
+
* report was refused; `é` stayed six characters. The Python prototype read all three as XML
|
|
208
|
+
* reads them; both do now (1.5.2). A DOCTYPE is refused in both: JUnit reports carry none, and its
|
|
209
|
+
* entities are what the expansion attacks are made of.
|
|
210
|
+
*/
|
|
211
|
+
function readReport(text) {
|
|
212
|
+
class Doctype extends Error {
|
|
213
|
+
}
|
|
214
|
+
const parser = new SaxesParser({ xmlns: true });
|
|
215
|
+
const cases = [];
|
|
216
|
+
const open = [];
|
|
217
|
+
parser.on("doctype", () => {
|
|
218
|
+
throw new Doctype();
|
|
219
|
+
});
|
|
220
|
+
parser.on("opentag", (tag) => {
|
|
221
|
+
const parent = open[open.length - 1];
|
|
222
|
+
if (parent && tag.uri === "") {
|
|
223
|
+
const local = tag.local.toLowerCase();
|
|
224
|
+
if (local === "failure" || local === "error")
|
|
225
|
+
parent.status = "failed";
|
|
226
|
+
else if (local === "skipped")
|
|
227
|
+
parent.status = "skipped";
|
|
228
|
+
}
|
|
229
|
+
let own = null;
|
|
230
|
+
if (tag.uri === "" && tag.local === "testcase") {
|
|
231
|
+
const classname = tag.attributes["classname"]?.value ?? "";
|
|
232
|
+
const name = tag.attributes["name"]?.value ?? "";
|
|
233
|
+
own = { classname, name, status: "passed", full: classname ? `${classname}.${name}` : name };
|
|
234
|
+
cases.push(own);
|
|
235
|
+
}
|
|
236
|
+
open.push(own);
|
|
237
|
+
});
|
|
238
|
+
parser.on("closetag", () => {
|
|
239
|
+
open.pop();
|
|
240
|
+
});
|
|
241
|
+
try {
|
|
242
|
+
parser.write(text).close();
|
|
243
|
+
}
|
|
244
|
+
catch (exc) {
|
|
245
|
+
return exc instanceof Doctype ? "doctype" : "malformed";
|
|
246
|
+
}
|
|
247
|
+
return cases;
|
|
248
|
+
}
|
|
191
249
|
function loadReport(config, base) {
|
|
192
250
|
const location = config.test_report;
|
|
193
251
|
if (!location) {
|
|
@@ -199,40 +257,20 @@ function loadReport(config, base) {
|
|
|
199
257
|
const path = join(base, location);
|
|
200
258
|
if (!existsSync(path))
|
|
201
259
|
return { cases: [], findings: [new Finding("report_missing", `${location} does not exist`)] };
|
|
202
|
-
|
|
203
|
-
|
|
204
|
-
|
|
260
|
+
// 1.5.1 named a directory here as malformed XML, and the Python prototype stopped with a traceback.
|
|
261
|
+
const why = whyUnreadable(path);
|
|
262
|
+
if (why)
|
|
263
|
+
return { cases: [], findings: [new Finding("report_missing", `${location} cannot be read: ${why}`)] };
|
|
264
|
+
const cases = readReport(readFileSync(path, "utf8"));
|
|
265
|
+
if (cases === "doctype") {
|
|
266
|
+
return { cases: [], findings: [new Finding("report_malformed", `${location}: a DOCTYPE — a JUnit report has none, and check 2 reads none`)] };
|
|
205
267
|
}
|
|
206
|
-
|
|
207
|
-
return {
|
|
268
|
+
if (cases === "malformed") {
|
|
269
|
+
return {
|
|
270
|
+
cases: [],
|
|
271
|
+
findings: [new Finding("report_malformed", `${location}: not well-formed XML — a run that stopped while writing its report leaves such a file`)],
|
|
272
|
+
};
|
|
208
273
|
}
|
|
209
|
-
const cases = [];
|
|
210
|
-
const walk = (node) => {
|
|
211
|
-
if (!node || typeof node !== "object")
|
|
212
|
-
return;
|
|
213
|
-
for (const [key, value] of Object.entries(node)) {
|
|
214
|
-
if (key === "testcase") {
|
|
215
|
-
for (const item of Array.isArray(value) ? value : [value]) {
|
|
216
|
-
let status = "passed";
|
|
217
|
-
for (const child of Object.keys(item ?? {})) {
|
|
218
|
-
const tag = child.toLowerCase();
|
|
219
|
-
if (tag === "failure" || tag === "error")
|
|
220
|
-
status = "failed";
|
|
221
|
-
else if (tag === "skipped")
|
|
222
|
-
status = "skipped";
|
|
223
|
-
}
|
|
224
|
-
const classname = String(item?.["@_classname"] ?? "");
|
|
225
|
-
const name = String(item?.["@_name"] ?? "");
|
|
226
|
-
cases.push({ classname, name, status, full: classname ? `${classname}.${name}` : name });
|
|
227
|
-
}
|
|
228
|
-
}
|
|
229
|
-
else if (typeof value === "object") {
|
|
230
|
-
for (const item of Array.isArray(value) ? value : [value])
|
|
231
|
-
walk(item);
|
|
232
|
-
}
|
|
233
|
-
}
|
|
234
|
-
};
|
|
235
|
-
walk(tree);
|
|
236
274
|
if (cases.length === 0) {
|
|
237
275
|
return { cases, findings: [new Finding("report_empty", `${location}: no test cases — a failed run, not a suite without tests`)] };
|
|
238
276
|
}
|
|
@@ -301,9 +339,9 @@ export function checkCoverage(config, base) {
|
|
|
301
339
|
for (const [cardId, fm] of cards) {
|
|
302
340
|
const byStep = new Map();
|
|
303
341
|
ownProof.set(cardId, byStep);
|
|
304
|
-
for (const test of (fm["tests"]
|
|
305
|
-
const refs =
|
|
306
|
-
const testId = test
|
|
342
|
+
for (const test of maps(fm["tests"])) {
|
|
343
|
+
const refs = coversOf(test);
|
|
344
|
+
const testId = text(test["id"]) ?? "";
|
|
307
345
|
const matched = haveReport ? findCases(testId, byFull, byName) : [];
|
|
308
346
|
if (haveReport) {
|
|
309
347
|
if (matched.length === 0) {
|
|
@@ -333,14 +371,14 @@ export function checkCoverage(config, base) {
|
|
|
333
371
|
// Pass 2 — verdicts, with a variant's inherited steps credited from the original.
|
|
334
372
|
const byId = new Map(cards);
|
|
335
373
|
for (const [cardId, fm] of cards) {
|
|
336
|
-
const steps = (fm
|
|
337
|
-
const gaps =
|
|
374
|
+
const steps = stepIdsOf(fm);
|
|
375
|
+
const gaps = gapStepsOf(fm);
|
|
338
376
|
const byStep = ownProof.get(cardId);
|
|
339
377
|
const { credited, explained } = inheritance(cardId, fm, steps, byStep, byId, ownProof, haveReport, findings);
|
|
340
378
|
// Round 26 ⑬: a gap says why the proof is missing — counted apart, so "not written yet" is
|
|
341
379
|
// never read as "cannot be written".
|
|
342
|
-
for (const entry of (fm["coverage_gaps"]
|
|
343
|
-
const kind =
|
|
380
|
+
for (const entry of maps(fm["coverage_gaps"])) {
|
|
381
|
+
const kind = text(entry["kind"]) || "unwritten";
|
|
344
382
|
if (kind === "unwritten" || kind === "harness" || kind === "unreachable")
|
|
345
383
|
gapKinds[kind] += 1;
|
|
346
384
|
}
|
|
@@ -373,6 +411,19 @@ export function checkCoverage(config, base) {
|
|
|
373
411
|
},
|
|
374
412
|
};
|
|
375
413
|
}
|
|
414
|
+
/** The steps a test covers: one id or a list of them (1.5.1: an id of another type is absent). */
|
|
415
|
+
function coversOf(test) {
|
|
416
|
+
const covers = test["covers"];
|
|
417
|
+
return typeof covers === "string" ? [covers] : texts(covers);
|
|
418
|
+
}
|
|
419
|
+
/** A card's step ids, in order; a step that is not a map, or an id that is not a string, is absent. */
|
|
420
|
+
function stepIdsOf(fm) {
|
|
421
|
+
return texts(maps(fm["steps"]).map((step) => step["id"]));
|
|
422
|
+
}
|
|
423
|
+
/** The steps a card declares a gap for. */
|
|
424
|
+
function gapStepsOf(fm) {
|
|
425
|
+
return new Set(texts(maps(fm["coverage_gaps"]).map((gap) => gap["step"])));
|
|
426
|
+
}
|
|
376
427
|
/**
|
|
377
428
|
* Which inherited steps of a variant (§5.2e) the original's own tests prove. `explained` holds the
|
|
378
429
|
* steps whose missing credit a variant finding has already reported, so check 2 does not repeat it
|
|
@@ -382,18 +433,18 @@ function inheritance(cardId, fm, steps, byStep, byId, ownProof, haveReport, find
|
|
|
382
433
|
const credited = new Set();
|
|
383
434
|
const explained = new Set();
|
|
384
435
|
const variant = fm["variant_of"];
|
|
385
|
-
if (!variant
|
|
436
|
+
if (!isMap(variant))
|
|
386
437
|
return { credited, explained };
|
|
387
|
-
const inherits = (
|
|
388
|
-
const originalId =
|
|
438
|
+
const inherits = texts(variant["inherits"]);
|
|
439
|
+
const originalId = text(variant["card"]) ?? "";
|
|
389
440
|
const original = byId.get(originalId);
|
|
390
441
|
if (!original) {
|
|
391
442
|
findings.push(new Finding("variant_of_unknown", `${cardId}: \`variant_of\` names \`${originalId}\`, which no card in the set declares`));
|
|
392
443
|
inherits.forEach((s) => explained.add(s));
|
|
393
444
|
return { credited, explained };
|
|
394
445
|
}
|
|
395
|
-
const shared =
|
|
396
|
-
const codeShared = evidencePaths(original["maturity_evidence"]
|
|
446
|
+
const shared = (text(variant["shared"]) ?? "").trim().replace(LINE_SUFFIX, "");
|
|
447
|
+
const codeShared = evidencePaths(map(original["maturity_evidence"])["implemented"]).includes(shared);
|
|
397
448
|
if (!codeShared) {
|
|
398
449
|
findings.push(new Finding("variant_code_not_shared", `${cardId}: \`${shared}\` is not in the \`implemented\` evidence of \`${originalId}\` — nothing shows the two run the same code`));
|
|
399
450
|
}
|
|
@@ -403,8 +454,8 @@ function inheritance(cardId, fm, steps, byStep, byId, ownProof, haveReport, find
|
|
|
403
454
|
if (!wired) {
|
|
404
455
|
findings.push(new Finding("variant_unwired", `${cardId}: no passing test of its own covers its last step \`${last ?? "(none)"}\` — ${inherits.length} inherited step(s) get no credit`, haveReport ? "error" : "warning"));
|
|
405
456
|
}
|
|
406
|
-
const originalSteps = new Set((original
|
|
407
|
-
const originalGaps =
|
|
457
|
+
const originalSteps = new Set(stepIdsOf(original));
|
|
458
|
+
const originalGaps = gapStepsOf(original);
|
|
408
459
|
const originalProof = ownProof.get(originalId) ?? new Map();
|
|
409
460
|
for (const step of inherits) {
|
|
410
461
|
if (!originalSteps.has(step)) {
|
|
@@ -429,12 +480,15 @@ function inheritance(cardId, fm, steps, byStep, byId, ownProof, haveReport, find
|
|
|
429
480
|
const LEVELS = ["conceived", "designed", "implemented", "tested", "in_production", "deprecated"];
|
|
430
481
|
const LINE_SUFFIX = /:\d+$/;
|
|
431
482
|
const DEFAULT_HORIZON_DAYS = 180;
|
|
432
|
-
/**
|
|
483
|
+
/**
|
|
484
|
+
* `implemented` is one path or several — one example joins two with ' + '. A value that is not a
|
|
485
|
+
* string is absent (1.5.1): `true` was the path `true` here and `True` in the Python prototype.
|
|
486
|
+
*/
|
|
433
487
|
export function evidencePaths(value) {
|
|
434
|
-
const values =
|
|
488
|
+
const values = typeof value === "string" ? [value] : texts(value);
|
|
435
489
|
const paths = [];
|
|
436
490
|
for (const item of values) {
|
|
437
|
-
for (const part of
|
|
491
|
+
for (const part of item.split("+")) {
|
|
438
492
|
const trimmed = part.trim().replace(LINE_SUFFIX, "");
|
|
439
493
|
if (trimmed)
|
|
440
494
|
paths.push(trimmed);
|
|
@@ -466,11 +520,11 @@ export function checkMaturity(config, base, today = new Date()) {
|
|
|
466
520
|
findings.push(...cardFindings);
|
|
467
521
|
let checkedPaths = 0;
|
|
468
522
|
for (const [cardId, fm] of cards) {
|
|
469
|
-
const maturity = fm["maturity"];
|
|
523
|
+
const maturity = text(fm["maturity"]) ?? "";
|
|
470
524
|
if (!LEVELS.includes(maturity))
|
|
471
525
|
continue;
|
|
472
526
|
const level = LEVELS.indexOf(maturity);
|
|
473
|
-
const evidence = fm["maturity_evidence"]
|
|
527
|
+
const evidence = map(fm["maturity_evidence"]);
|
|
474
528
|
// Tier 1 — the code the card points at exists.
|
|
475
529
|
if (level >= LEVELS.indexOf("implemented") && evidence["implemented"] && root) {
|
|
476
530
|
for (const path of evidencePaths(evidence["implemented"])) {
|
|
@@ -483,14 +537,13 @@ export function checkMaturity(config, base, today = new Date()) {
|
|
|
483
537
|
// Tier 2 — the claim is derived from the report, not from prose.
|
|
484
538
|
const passing = [];
|
|
485
539
|
const covered = new Set();
|
|
486
|
-
for (const test of (fm["tests"]
|
|
487
|
-
const
|
|
540
|
+
for (const test of maps(fm["tests"])) {
|
|
541
|
+
const testId = text(test["id"]) ?? "";
|
|
542
|
+
const matched = haveReport ? findCases(testId, byFull, byName) : [];
|
|
488
543
|
if (matched.length > 0 && matched.every((c) => c.status === "passed")) {
|
|
489
|
-
passing.push(
|
|
490
|
-
for (const ref of
|
|
491
|
-
|
|
492
|
-
covered.add(ref);
|
|
493
|
-
}
|
|
544
|
+
passing.push(testId);
|
|
545
|
+
for (const ref of coversOf(test))
|
|
546
|
+
covered.add(ref);
|
|
494
547
|
}
|
|
495
548
|
}
|
|
496
549
|
if (level >= LEVELS.indexOf("tested") && passing.length === 0) {
|
|
@@ -502,16 +555,16 @@ export function checkMaturity(config, base, today = new Date()) {
|
|
|
502
555
|
// And only when a passing test covers the LAST step — the proof §5.2e rule 4 accepts that an
|
|
503
556
|
// operation goes all the way through. Tests of the door alone (sign-in, permission) pass in
|
|
504
557
|
// front of an operation that answers 501; that card is honestly `designed`.
|
|
505
|
-
const
|
|
506
|
-
const stepIds = steps.filter((s) => s && typeof s === "object" && !Array.isArray(s)).map((s) => s.id);
|
|
558
|
+
const stepIds = stepIdsOf(fm);
|
|
507
559
|
const last = stepIds.length > 0 ? stepIds[stepIds.length - 1] : undefined;
|
|
508
|
-
if (haveReport && level < LEVELS.indexOf("implemented") &&
|
|
560
|
+
if (haveReport && level < LEVELS.indexOf("implemented") && last !== undefined && covered.has(last)) {
|
|
509
561
|
findings.push(new Finding("maturity_understated", `${cardId}: claims \`${maturity}\` while a passing test of its own covers its last step \`${last}\` (${passing.length} of its tests pass)`, "warning"));
|
|
510
562
|
}
|
|
511
563
|
// Tier 3 — what cannot be verified must expire.
|
|
512
564
|
if (level >= LEVELS.indexOf("in_production")) {
|
|
565
|
+
// A dated map or a bare string (the schema's two forms); any other value is absent (1.5.1).
|
|
513
566
|
const deployed = evidence["deployed"];
|
|
514
|
-
if (
|
|
567
|
+
if (isMap(deployed)) {
|
|
515
568
|
// A day the calendar has (1.4.3): `new Date` carried 2026-02-30 over to 2 March and called it
|
|
516
569
|
// stale, where the Python prototype said it is not a date. Year 0 is none either.
|
|
517
570
|
const raw = typeof deployed.since === "string" ? deployed.since : "";
|
|
@@ -525,7 +578,7 @@ export function checkMaturity(config, base, today = new Date()) {
|
|
|
525
578
|
findings.push(new Finding("evidence_stale", `${cardId}: \`deployed\` was last affirmed ${age} days ago (horizon ${horizon}) — nobody has looked since`, "warning"));
|
|
526
579
|
}
|
|
527
580
|
}
|
|
528
|
-
else if (deployed) {
|
|
581
|
+
else if (typeof deployed === "string" && deployed) {
|
|
529
582
|
findings.push(new Finding("evidence_undated", `${cardId}: \`deployed: ${deployed}\` carries no date, so it can never go stale`, "warning"));
|
|
530
583
|
}
|
|
531
584
|
}
|
|
@@ -580,10 +633,10 @@ export function checkStorage(config, base) {
|
|
|
580
633
|
const touched = new Set();
|
|
581
634
|
let claims = 0;
|
|
582
635
|
for (const [cardId, fm] of cards) {
|
|
583
|
-
for (const claim of fm["data"]
|
|
636
|
+
for (const claim of maps(map(fm["data"])["storage"])) {
|
|
584
637
|
claims += 1;
|
|
585
|
-
const pattern = claim
|
|
586
|
-
const matched = stores.filter((store) =>
|
|
638
|
+
const pattern = text(claim["store"]) ?? "";
|
|
639
|
+
const matched = stores.filter((store) => globMatches(String(store.name), pattern));
|
|
587
640
|
if (matched.length === 0) {
|
|
588
641
|
findings.push(new Finding("unknown_store", `${cardId}: claims store \`${pattern}\`, which the storage does not have`));
|
|
589
642
|
continue;
|
|
@@ -591,7 +644,7 @@ export function checkStorage(config, base) {
|
|
|
591
644
|
for (const store of matched)
|
|
592
645
|
touched.add(String(store.name));
|
|
593
646
|
// Every environment must agree, or the card is true in one place and false in another.
|
|
594
|
-
const claimedKeys = claim
|
|
647
|
+
const claimedKeys = texts(claim["keyed_by"]);
|
|
595
648
|
if (claimedKeys.length > 0) {
|
|
596
649
|
for (const store of matched) {
|
|
597
650
|
const actual = store.keyed_by ?? [];
|
|
@@ -600,7 +653,7 @@ export function checkStorage(config, base) {
|
|
|
600
653
|
}
|
|
601
654
|
}
|
|
602
655
|
}
|
|
603
|
-
const index = claim
|
|
656
|
+
const index = text(claim["via_index"]);
|
|
604
657
|
if (index) {
|
|
605
658
|
for (const store of matched) {
|
|
606
659
|
const names = (store.indexes ?? []).map((i) => String(i.name));
|
|
@@ -706,9 +759,8 @@ function judgeCalls(contractId, name, control, shownWhen, dataOf, cardById, find
|
|
|
706
759
|
// `from` is one state or a SET of states (round 18): the control must be shown only in
|
|
707
760
|
// states that belong to the set, and in at least one of them. `none` / `any` switch the
|
|
708
761
|
// rule off — `any` is the honest word for an operation that does not check its origin.
|
|
709
|
-
const
|
|
710
|
-
const
|
|
711
|
-
const fromSet = Array.isArray(rawFrom) ? rawFrom.map(String) : rawFrom ? [String(rawFrom)] : [];
|
|
762
|
+
const rawFrom = map(card["data_transition"])["from"];
|
|
763
|
+
const fromSet = typeof rawFrom === "string" && rawFrom ? [rawFrom] : texts(rawFrom);
|
|
712
764
|
if (fromSet.length > 0 && !fromSet.includes("none") && !fromSet.includes("any")) {
|
|
713
765
|
const mismatch = shownWhen.filter((state) => !fromSet.includes(dataOf(state)));
|
|
714
766
|
if (mismatch.length > 0 || !shownWhen.some((state) => fromSet.includes(dataOf(state)))) {
|
|
@@ -717,6 +769,14 @@ function judgeCalls(contractId, name, control, shownWhen, dataOf, cardById, find
|
|
|
717
769
|
}
|
|
718
770
|
}
|
|
719
771
|
}
|
|
772
|
+
/** A list of state names, or undefined when the field is not a list (1.5.1: a name that is not a string is absent). */
|
|
773
|
+
function statesOf(value) {
|
|
774
|
+
return Array.isArray(value) ? texts(value) : undefined;
|
|
775
|
+
}
|
|
776
|
+
/** `at_least`, an integer, or 1. */
|
|
777
|
+
function floorOf(line) {
|
|
778
|
+
return Number.isInteger(line["at_least"]) ? Number(line["at_least"]) : 1;
|
|
779
|
+
}
|
|
720
780
|
export function checkForm(config, base) {
|
|
721
781
|
const findings = [];
|
|
722
782
|
const patterns = config.forms ?? [];
|
|
@@ -767,30 +827,35 @@ export function checkForm(config, base) {
|
|
|
767
827
|
}
|
|
768
828
|
const cardById = new Map(cards);
|
|
769
829
|
const contracts = [];
|
|
830
|
+
const read = [];
|
|
770
831
|
for (const path of expandGlobs(base, patterns)) {
|
|
771
832
|
const fm = readDocument(path);
|
|
772
|
-
if (fm && fm["usedesign_form"] === 1)
|
|
773
|
-
contracts.push([
|
|
833
|
+
if (isMap(fm) && fm["usedesign_form"] === 1) {
|
|
834
|
+
contracts.push([text(fm["id"]) ?? path, fm]);
|
|
835
|
+
read.push([path, fm]);
|
|
836
|
+
}
|
|
774
837
|
}
|
|
838
|
+
refuseTwins(read);
|
|
775
839
|
if (contracts.length === 0) {
|
|
776
840
|
findings.push(new Finding("form_contracts_missing", "the `forms` patterns matched no contract"));
|
|
777
841
|
}
|
|
778
842
|
const claimed = new Map();
|
|
779
843
|
let designedAhead = 0;
|
|
780
844
|
for (const [contractId, fm] of contracts) {
|
|
781
|
-
const screen =
|
|
845
|
+
const screen = text(fm["screen"]) ?? "";
|
|
782
846
|
const states = byScreen.get(screen);
|
|
783
847
|
// A contract may be written before its screen — the format's own recommended order of
|
|
784
848
|
// work — and say so with `maturity: designed` (issue #8, the forms twin of issue #3). Then
|
|
785
849
|
// an absent screen is a warning, not the same error as a screen that vanished. The flag
|
|
786
850
|
// cannot go stale silently: the day the screen renders, the contract still saying
|
|
787
851
|
// `designed` is reported too — the gate itself tells the owner to flip it.
|
|
788
|
-
const maturity =
|
|
852
|
+
const maturity = text(fm["maturity"]) || "implemented";
|
|
789
853
|
// Screen state → data state, through the contract's `states:` map; identity when unmapped.
|
|
790
|
-
|
|
854
|
+
// Looked up among the map's own keys: `toString` is not a state.
|
|
855
|
+
const stateMap = map(fm["states"]);
|
|
791
856
|
const dataOf = (state) => {
|
|
792
|
-
const spec = stateMap[state];
|
|
793
|
-
return spec &&
|
|
857
|
+
const spec = Object.prototype.hasOwnProperty.call(stateMap, state) ? stateMap[state] : undefined;
|
|
858
|
+
return (isMap(spec) && text(spec["data"])) || state;
|
|
794
859
|
};
|
|
795
860
|
if (!states) {
|
|
796
861
|
if (maturity === "designed") {
|
|
@@ -800,9 +865,9 @@ export function checkForm(config, base) {
|
|
|
800
865
|
// and comparing it with the cards needs no inventory (issue #13). Only where the config
|
|
801
866
|
// declares `cards:`: a frontend-only repository has none, and stays as quiet as before.
|
|
802
867
|
if (config.cards) {
|
|
803
|
-
for (const control of fm["controls"]
|
|
804
|
-
const name =
|
|
805
|
-
judgeCalls(contractId, name, control, control
|
|
868
|
+
for (const control of maps(fm["controls"])) {
|
|
869
|
+
const name = text(control["control_pattern"]) || text(control["control"]) || "";
|
|
870
|
+
judgeCalls(contractId, name, control, statesOf(control["shown_when"]), dataOf, cardById, findings, "warning");
|
|
806
871
|
}
|
|
807
872
|
}
|
|
808
873
|
}
|
|
@@ -818,17 +883,18 @@ export function checkForm(config, base) {
|
|
|
818
883
|
const mine = claimed.get(screen) ?? { fields: new Set(), controls: new Set(), fieldFamilies: [], controlFamilies: [] };
|
|
819
884
|
claimed.set(screen, mine);
|
|
820
885
|
const countOf = (regex, set) => [...set].filter((name) => regex.test(name)).length;
|
|
821
|
-
for (const entry of fm["presents"]
|
|
822
|
-
const pattern =
|
|
886
|
+
for (const entry of maps(fm["presents"])) {
|
|
887
|
+
const pattern = text(entry["field_pattern"]) ?? "";
|
|
888
|
+
const when = statesOf(entry["when"]);
|
|
823
889
|
if (pattern) {
|
|
824
890
|
// A family: one element per item of a list that arrives at runtime. Every rendered
|
|
825
891
|
// anchor matching the pattern is accounted for; `at_least` keeps the family from
|
|
826
892
|
// silently becoming empty — a screen rendering NO tabs because the backend returned
|
|
827
893
|
// nothing is a real failure, and without the floor nothing would notice.
|
|
828
894
|
const regex = familyRegex(pattern);
|
|
829
|
-
const atLeast =
|
|
895
|
+
const atLeast = floorOf(entry);
|
|
830
896
|
mine.fieldFamilies.push(regex);
|
|
831
|
-
for (const state of
|
|
897
|
+
for (const state of when ?? everyState) {
|
|
832
898
|
const rendered = states.get(state);
|
|
833
899
|
if (!rendered) {
|
|
834
900
|
findings.push(new Finding("form_state_missing", `${contractId}: \`${pattern}\` is required in state \`${state}\`, which the inventory never rendered`));
|
|
@@ -840,12 +906,12 @@ export function checkForm(config, base) {
|
|
|
840
906
|
}
|
|
841
907
|
}
|
|
842
908
|
}
|
|
843
|
-
fieldOutOfState(contractId, pattern,
|
|
909
|
+
fieldOutOfState(contractId, pattern, when, states, (rendered) => countOf(regex, rendered.fields) > 0, findings);
|
|
844
910
|
continue;
|
|
845
911
|
}
|
|
846
|
-
const field =
|
|
912
|
+
const field = text(entry["field"]) ?? "";
|
|
847
913
|
mine.fields.add(field);
|
|
848
|
-
for (const state of
|
|
914
|
+
for (const state of when ?? everyState) {
|
|
849
915
|
const rendered = states.get(state);
|
|
850
916
|
if (!rendered) {
|
|
851
917
|
findings.push(new Finding("form_state_missing", `${contractId}: \`${field}\` is required in state \`${state}\`, which the inventory never rendered`));
|
|
@@ -854,18 +920,18 @@ export function checkForm(config, base) {
|
|
|
854
920
|
findings.push(new Finding("element_missing", `${contractId}: \`${field}\` must be shown in state \`${state}\` and is not`));
|
|
855
921
|
}
|
|
856
922
|
}
|
|
857
|
-
fieldOutOfState(contractId, field,
|
|
923
|
+
fieldOutOfState(contractId, field, when, states, (rendered) => rendered.fields.has(field), findings);
|
|
858
924
|
}
|
|
859
|
-
for (const control of fm["controls"]
|
|
860
|
-
const pattern =
|
|
925
|
+
for (const control of maps(fm["controls"])) {
|
|
926
|
+
const pattern = text(control["control_pattern"]) ?? "";
|
|
861
927
|
const regex = pattern ? familyRegex(pattern) : null;
|
|
862
|
-
const atLeast =
|
|
863
|
-
const name = pattern ||
|
|
928
|
+
const atLeast = floorOf(control);
|
|
929
|
+
const name = pattern || (text(control["control"]) ?? "");
|
|
864
930
|
if (regex)
|
|
865
931
|
mine.controlFamilies.push(regex);
|
|
866
932
|
else
|
|
867
933
|
mine.controls.add(name);
|
|
868
|
-
const shownWhen = control
|
|
934
|
+
const shownWhen = statesOf(control["shown_when"]);
|
|
869
935
|
// For a literal both questions are "is it there"; for a family they differ: enough
|
|
870
936
|
// members for the floor, versus any member at all (which is what leaks out of state).
|
|
871
937
|
const enough = (rendered) => (regex ? countOf(regex, rendered.controls) >= atLeast : rendered.controls.has(name));
|
|
@@ -894,8 +960,8 @@ export function checkForm(config, base) {
|
|
|
894
960
|
}
|
|
895
961
|
judgeCalls(contractId, name, control, shownWhen, dataOf, cardById, findings, "error");
|
|
896
962
|
}
|
|
897
|
-
for (const entry of fm["removed"]
|
|
898
|
-
const name =
|
|
963
|
+
for (const entry of maps(fm["removed"])) {
|
|
964
|
+
const name = text(entry["control"]) ?? "";
|
|
899
965
|
mine.controls.add(name);
|
|
900
966
|
for (const [state, rendered] of states) {
|
|
901
967
|
if (rendered.controls.has(name)) {
|
|
@@ -906,9 +972,10 @@ export function checkForm(config, base) {
|
|
|
906
972
|
// A group's anchor is accounted for BY the group line: the owner named the container when
|
|
907
973
|
// grouping by it. Warning about it as undescribed would ask the owner to decide what they
|
|
908
974
|
// already decided — measured 19.08: three of fourteen warnings were exactly this noise.
|
|
909
|
-
for (const group of fm["groups"]
|
|
910
|
-
|
|
911
|
-
|
|
975
|
+
for (const group of maps(fm["groups"])) {
|
|
976
|
+
const gname = text(group["group"]);
|
|
977
|
+
if (gname)
|
|
978
|
+
mine.fields.add(gname);
|
|
912
979
|
}
|
|
913
980
|
// ── group membership ───────────────────────────────────────────────────────────────────
|
|
914
981
|
// The contract seats elements in groups; the inventory's `within` records which containers
|
|
@@ -916,16 +983,15 @@ export function checkForm(config, base) {
|
|
|
916
983
|
// where the member renders and the inventory carries the measurement. The first inventory
|
|
917
984
|
// with containers refuted its own contract: `add-set` was contracted into the footer and
|
|
918
985
|
// measured living only in the set table — the checker's first catch was its author.
|
|
919
|
-
for (const group of fm["groups"]
|
|
920
|
-
const gname =
|
|
986
|
+
for (const group of maps(fm["groups"])) {
|
|
987
|
+
const gname = text(group["group"]) ?? "";
|
|
921
988
|
if (!gname)
|
|
922
989
|
continue;
|
|
923
990
|
const anchorSeen = [...states.values()].some((r) => r.fields.has(gname) || (r.within !== null && [...r.within.values()].some((chain) => chain.has(gname))));
|
|
924
991
|
if (!anchorSeen) {
|
|
925
992
|
findings.push(new Finding("group_missing", `${contractId}: group \`${gname}\` is contracted but its anchor never renders`));
|
|
926
993
|
}
|
|
927
|
-
for (const
|
|
928
|
-
const member = String(memberRaw);
|
|
994
|
+
for (const member of texts(group["contains"])) {
|
|
929
995
|
// A family seated in a group: every rendered member of the family must sit there.
|
|
930
996
|
const regex = member.includes("*") ? familyRegex(member) : null;
|
|
931
997
|
for (const [state, rendered] of states) {
|