usedesign 1.5.0 → 1.5.1
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 +34 -25
- package/dist/checks.d.ts +4 -1
- package/dist/checks.js +127 -90
- 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 +2 -2
package/README.md
CHANGED
|
@@ -1,25 +1,25 @@
|
|
|
1
1
|
# usedesign — TypeScript
|
|
2
2
|
|
|
3
|
-
The reference implementation: validates Operation Cards and runs the five
|
|
4
|
-
repository.
|
|
3
|
+
The reference implementation: validates Operation Cards and form contracts, and runs the five
|
|
4
|
+
checks against a repository.
|
|
5
5
|
|
|
6
6
|
```bash
|
|
7
7
|
npx usedesign validate examples/library/
|
|
8
8
|
npx usedesign check usedesign.config.yaml
|
|
9
9
|
```
|
|
10
10
|
|
|
11
|
-
Exit status is `1` when anything reported an error, `0` otherwise, `2` on misuse
|
|
12
|
-
whole CI contract.
|
|
11
|
+
Exit status is `1` when anything reported an error, `0` otherwise, `2` on misuse or on input that
|
|
12
|
+
cannot be read. That is the whole CI contract.
|
|
13
13
|
|
|
14
14
|
## Status
|
|
15
15
|
|
|
16
|
-
**Complete against
|
|
16
|
+
**Complete against both corpora, published to npm as `usedesign`.** Everything below runs from a
|
|
17
17
|
clone as well as through `npx usedesign`.
|
|
18
18
|
|
|
19
19
|
| | |
|
|
20
20
|
|---|---|
|
|
21
|
-
| Cards corpus |
|
|
22
|
-
| Repository corpus |
|
|
21
|
+
| Cards corpus | every case passes (`tests/conformance/cards/`) |
|
|
22
|
+
| Repository corpus | every case passes (`tests/conformance/checks/`) |
|
|
23
23
|
| Runtime | Node ≥ 20, no native modules |
|
|
24
24
|
| Dependencies | `ajv`, `ajv-formats`, `yaml`, `fast-xml-parser` |
|
|
25
25
|
|
|
@@ -29,30 +29,31 @@ npm test # both corpora
|
|
|
29
29
|
node dist/cli.js check ../../tests/fixtures/library-service/usedesign.config.yaml
|
|
30
30
|
```
|
|
31
31
|
|
|
32
|
-
The last command is expected to
|
|
33
|
-
|
|
32
|
+
The last command is expected to exit `1`: the fixture is deliberately rotten — two wild
|
|
33
|
+
endpoints, three unproven steps, one missing evidence path — and it has no storage inventory,
|
|
34
|
+
which check 4 reports as not run. A clean run there would mean the checks had stopped working.
|
|
34
35
|
|
|
35
36
|
## Commands
|
|
36
37
|
|
|
37
38
|
| Command | What it does |
|
|
38
39
|
|---|---|
|
|
39
|
-
| `validate <path…>` | Cards against the JSON Schema **and** the cross-card rules. `--no-schema` keeps only the latter |
|
|
40
|
-
| `check <config>` |
|
|
40
|
+
| `validate <path…>` | Cards and form contracts against the JSON Schema **and** the cross-card rules. `--no-schema` keeps only the latter |
|
|
41
|
+
| `check <config>` | Checks 1–5: no wild endpoints, no unproven steps, no maturity beyond its evidence, no imagined storage, the form matches its contract. A config's `checks:` names the ones that apply to the repository |
|
|
42
|
+
| `scaffold <openapi.json> --out <dir>` | A draft card per route the cards do not declare yet, read from the application's own OpenAPI. Every draft fails validation on purpose |
|
|
43
|
+
| `preview <config> --out <file.html>` | Form contracts as a three-rail wireframe page |
|
|
44
|
+
| `gen openapi <config> --out <file.json>` | An OpenAPI contract derived from the cards — the opposite direction to `scaffold` |
|
|
41
45
|
| `conformance` | Both corpora. `--cards` / `--checks` to run one |
|
|
42
46
|
|
|
43
47
|
## What validation is, in two halves
|
|
44
48
|
|
|
45
49
|
`validate` runs the published schema and the cross-reference rules, and the split is not
|
|
46
|
-
cosmetic.
|
|
50
|
+
cosmetic. The schema catches what a field looks like — a missing required field, an enum out of
|
|
51
|
+
range, a malformed step id, an interface with no transport. It cannot see a test covering a step
|
|
52
|
+
that does not exist, a screen covering a step that does not exist, or one step id used twice.
|
|
47
53
|
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
step that does not exist, and the same step id used twice.
|
|
52
|
-
|
|
53
|
-
All three of the invisible ones are the same shape: *a name that points at nothing*. A schema can
|
|
54
|
-
say what a field looks like; only a checker holding the whole card can say whether the thing it
|
|
55
|
-
names is there.
|
|
54
|
+
Those invisible ones are the same shape: *a name that points at nothing*. A schema can say what a
|
|
55
|
+
field looks like; only a checker holding the whole card — and every other card of the run — can
|
|
56
|
+
say whether the thing it names is there.
|
|
56
57
|
|
|
57
58
|
That second group is the reason this tool exists rather than a bare `ajv` invocation. A schema
|
|
58
59
|
validates *form*; the rules validate *meaning*, and meaning is where cards rot.
|
|
@@ -66,19 +67,27 @@ The schema itself is never vendored: the compiled package carries a copy of the
|
|
|
66
67
|
return after the closing fence; scalars came back with `\r` attached, ids stopped matching
|
|
67
68
|
their pattern, and one card would not parse at all. Every symptom looked like a format problem
|
|
68
69
|
and none of them was. `frontMatter` normalises before parsing.
|
|
69
|
-
- **
|
|
70
|
+
- **A field is read only in its type.** A list is read as a list, a map as a map, a name as text;
|
|
71
|
+
a value of another type is absent to the rules and to the checks, and the schema names it. A
|
|
72
|
+
rule that trusts the shape of its input stops with a stack trace on the first card that lies.
|
|
73
|
+
- **The Python implementation prints what this one prints, line for line**, including the noise
|
|
70
74
|
suppressions — a dead exclusion is not reported when there are no routes, and a step whose only
|
|
71
75
|
test failed is reported once, not twice. Two implementations that disagree about what to stay
|
|
72
|
-
quiet about are two dialects.
|
|
76
|
+
quiet about are two dialects; CI compares them on the corpora and on the fixtures.
|
|
73
77
|
|
|
74
78
|
## Layout
|
|
75
79
|
|
|
76
80
|
| File | What is in it |
|
|
77
81
|
|---|---|
|
|
78
|
-
| `src/core.ts` | Findings, front matter, config,
|
|
79
|
-
| `src/validate.ts` | The schema and the cross-card rules |
|
|
80
|
-
| `src/checks.ts` | The
|
|
82
|
+
| `src/core.ts` | Findings, front matter, config, file collection, typed reads — no rules |
|
|
83
|
+
| `src/validate.ts` | The schema and the cross-card rules for cards and form contracts |
|
|
84
|
+
| `src/checks.ts` | The five checks |
|
|
81
85
|
| `src/conformance.ts` | The corpus runners |
|
|
86
|
+
| `src/scaffold.ts` | Draft cards from an OpenAPI document |
|
|
87
|
+
| `src/gen.ts` | `gen openapi`: an OpenAPI document from the cards |
|
|
88
|
+
| `src/preview.ts` | The wireframe page of form contracts |
|
|
89
|
+
| `src/formInventory.ts` | Helper for producing the form inventory check 5 reads |
|
|
90
|
+
| `src/index.ts` | Library entry point: findings, front matter, config, card collection, card validation, checks 1–3 and the `CHECKS` map of all five, the corpus runners, the form-inventory helpers |
|
|
82
91
|
| `src/cli.ts` | Argument handling and output |
|
|
83
92
|
|
|
84
93
|
Rules never live in a helper: a rule that hides inside a utility is a rule nobody reviews.
|
package/dist/checks.d.ts
CHANGED
|
@@ -22,7 +22,10 @@ type Index = Map<string, TestCase[]>;
|
|
|
22
22
|
/** Resolve a card's test id to report entries. Full-id matching only — never a substring. */
|
|
23
23
|
export declare function findCases(testId: string, byFull: Index, byName: Index): TestCase[];
|
|
24
24
|
export declare function checkCoverage(config: Config, base: string): CheckResult;
|
|
25
|
-
/**
|
|
25
|
+
/**
|
|
26
|
+
* `implemented` is one path or several — one example joins two with ' + '. A value that is not a
|
|
27
|
+
* string is absent (1.5.1): `true` was the path `true` here and `True` in the Python prototype.
|
|
28
|
+
*/
|
|
26
29
|
export declare function evidencePaths(value: unknown): string[];
|
|
27
30
|
export declare function checkMaturity(config: Config, base: string, today?: Date): CheckResult;
|
|
28
31
|
export declare function checkStorage(config: Config, base: string): CheckResult;
|
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
14
|
import { XMLParser } from "fast-xml-parser";
|
|
13
|
-
import { Finding, expandGlobs, loadCardFiles, readDocument } from "./core.js";
|
|
15
|
+
import { Finding, expandGlobs, globMatches, isMap, loadCardFiles, map, maps, readDocument, refuseTwins, text, texts, } 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) {
|
|
@@ -301,9 +310,9 @@ export function checkCoverage(config, base) {
|
|
|
301
310
|
for (const [cardId, fm] of cards) {
|
|
302
311
|
const byStep = new Map();
|
|
303
312
|
ownProof.set(cardId, byStep);
|
|
304
|
-
for (const test of (fm["tests"]
|
|
305
|
-
const refs =
|
|
306
|
-
const testId = test
|
|
313
|
+
for (const test of maps(fm["tests"])) {
|
|
314
|
+
const refs = coversOf(test);
|
|
315
|
+
const testId = text(test["id"]) ?? "";
|
|
307
316
|
const matched = haveReport ? findCases(testId, byFull, byName) : [];
|
|
308
317
|
if (haveReport) {
|
|
309
318
|
if (matched.length === 0) {
|
|
@@ -333,14 +342,14 @@ export function checkCoverage(config, base) {
|
|
|
333
342
|
// Pass 2 — verdicts, with a variant's inherited steps credited from the original.
|
|
334
343
|
const byId = new Map(cards);
|
|
335
344
|
for (const [cardId, fm] of cards) {
|
|
336
|
-
const steps = (fm
|
|
337
|
-
const gaps =
|
|
345
|
+
const steps = stepIdsOf(fm);
|
|
346
|
+
const gaps = gapStepsOf(fm);
|
|
338
347
|
const byStep = ownProof.get(cardId);
|
|
339
348
|
const { credited, explained } = inheritance(cardId, fm, steps, byStep, byId, ownProof, haveReport, findings);
|
|
340
349
|
// Round 26 ⑬: a gap says why the proof is missing — counted apart, so "not written yet" is
|
|
341
350
|
// never read as "cannot be written".
|
|
342
|
-
for (const entry of (fm["coverage_gaps"]
|
|
343
|
-
const kind =
|
|
351
|
+
for (const entry of maps(fm["coverage_gaps"])) {
|
|
352
|
+
const kind = text(entry["kind"]) || "unwritten";
|
|
344
353
|
if (kind === "unwritten" || kind === "harness" || kind === "unreachable")
|
|
345
354
|
gapKinds[kind] += 1;
|
|
346
355
|
}
|
|
@@ -373,6 +382,19 @@ export function checkCoverage(config, base) {
|
|
|
373
382
|
},
|
|
374
383
|
};
|
|
375
384
|
}
|
|
385
|
+
/** The steps a test covers: one id or a list of them (1.5.1: an id of another type is absent). */
|
|
386
|
+
function coversOf(test) {
|
|
387
|
+
const covers = test["covers"];
|
|
388
|
+
return typeof covers === "string" ? [covers] : texts(covers);
|
|
389
|
+
}
|
|
390
|
+
/** A card's step ids, in order; a step that is not a map, or an id that is not a string, is absent. */
|
|
391
|
+
function stepIdsOf(fm) {
|
|
392
|
+
return texts(maps(fm["steps"]).map((step) => step["id"]));
|
|
393
|
+
}
|
|
394
|
+
/** The steps a card declares a gap for. */
|
|
395
|
+
function gapStepsOf(fm) {
|
|
396
|
+
return new Set(texts(maps(fm["coverage_gaps"]).map((gap) => gap["step"])));
|
|
397
|
+
}
|
|
376
398
|
/**
|
|
377
399
|
* Which inherited steps of a variant (§5.2e) the original's own tests prove. `explained` holds the
|
|
378
400
|
* steps whose missing credit a variant finding has already reported, so check 2 does not repeat it
|
|
@@ -382,18 +404,18 @@ function inheritance(cardId, fm, steps, byStep, byId, ownProof, haveReport, find
|
|
|
382
404
|
const credited = new Set();
|
|
383
405
|
const explained = new Set();
|
|
384
406
|
const variant = fm["variant_of"];
|
|
385
|
-
if (!variant
|
|
407
|
+
if (!isMap(variant))
|
|
386
408
|
return { credited, explained };
|
|
387
|
-
const inherits = (
|
|
388
|
-
const originalId =
|
|
409
|
+
const inherits = texts(variant["inherits"]);
|
|
410
|
+
const originalId = text(variant["card"]) ?? "";
|
|
389
411
|
const original = byId.get(originalId);
|
|
390
412
|
if (!original) {
|
|
391
413
|
findings.push(new Finding("variant_of_unknown", `${cardId}: \`variant_of\` names \`${originalId}\`, which no card in the set declares`));
|
|
392
414
|
inherits.forEach((s) => explained.add(s));
|
|
393
415
|
return { credited, explained };
|
|
394
416
|
}
|
|
395
|
-
const shared =
|
|
396
|
-
const codeShared = evidencePaths(original["maturity_evidence"]
|
|
417
|
+
const shared = (text(variant["shared"]) ?? "").trim().replace(LINE_SUFFIX, "");
|
|
418
|
+
const codeShared = evidencePaths(map(original["maturity_evidence"])["implemented"]).includes(shared);
|
|
397
419
|
if (!codeShared) {
|
|
398
420
|
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
421
|
}
|
|
@@ -403,8 +425,8 @@ function inheritance(cardId, fm, steps, byStep, byId, ownProof, haveReport, find
|
|
|
403
425
|
if (!wired) {
|
|
404
426
|
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
427
|
}
|
|
406
|
-
const originalSteps = new Set((original
|
|
407
|
-
const originalGaps =
|
|
428
|
+
const originalSteps = new Set(stepIdsOf(original));
|
|
429
|
+
const originalGaps = gapStepsOf(original);
|
|
408
430
|
const originalProof = ownProof.get(originalId) ?? new Map();
|
|
409
431
|
for (const step of inherits) {
|
|
410
432
|
if (!originalSteps.has(step)) {
|
|
@@ -429,12 +451,15 @@ function inheritance(cardId, fm, steps, byStep, byId, ownProof, haveReport, find
|
|
|
429
451
|
const LEVELS = ["conceived", "designed", "implemented", "tested", "in_production", "deprecated"];
|
|
430
452
|
const LINE_SUFFIX = /:\d+$/;
|
|
431
453
|
const DEFAULT_HORIZON_DAYS = 180;
|
|
432
|
-
/**
|
|
454
|
+
/**
|
|
455
|
+
* `implemented` is one path or several — one example joins two with ' + '. A value that is not a
|
|
456
|
+
* string is absent (1.5.1): `true` was the path `true` here and `True` in the Python prototype.
|
|
457
|
+
*/
|
|
433
458
|
export function evidencePaths(value) {
|
|
434
|
-
const values =
|
|
459
|
+
const values = typeof value === "string" ? [value] : texts(value);
|
|
435
460
|
const paths = [];
|
|
436
461
|
for (const item of values) {
|
|
437
|
-
for (const part of
|
|
462
|
+
for (const part of item.split("+")) {
|
|
438
463
|
const trimmed = part.trim().replace(LINE_SUFFIX, "");
|
|
439
464
|
if (trimmed)
|
|
440
465
|
paths.push(trimmed);
|
|
@@ -466,11 +491,11 @@ export function checkMaturity(config, base, today = new Date()) {
|
|
|
466
491
|
findings.push(...cardFindings);
|
|
467
492
|
let checkedPaths = 0;
|
|
468
493
|
for (const [cardId, fm] of cards) {
|
|
469
|
-
const maturity = fm["maturity"];
|
|
494
|
+
const maturity = text(fm["maturity"]) ?? "";
|
|
470
495
|
if (!LEVELS.includes(maturity))
|
|
471
496
|
continue;
|
|
472
497
|
const level = LEVELS.indexOf(maturity);
|
|
473
|
-
const evidence = fm["maturity_evidence"]
|
|
498
|
+
const evidence = map(fm["maturity_evidence"]);
|
|
474
499
|
// Tier 1 — the code the card points at exists.
|
|
475
500
|
if (level >= LEVELS.indexOf("implemented") && evidence["implemented"] && root) {
|
|
476
501
|
for (const path of evidencePaths(evidence["implemented"])) {
|
|
@@ -483,14 +508,13 @@ export function checkMaturity(config, base, today = new Date()) {
|
|
|
483
508
|
// Tier 2 — the claim is derived from the report, not from prose.
|
|
484
509
|
const passing = [];
|
|
485
510
|
const covered = new Set();
|
|
486
|
-
for (const test of (fm["tests"]
|
|
487
|
-
const
|
|
511
|
+
for (const test of maps(fm["tests"])) {
|
|
512
|
+
const testId = text(test["id"]) ?? "";
|
|
513
|
+
const matched = haveReport ? findCases(testId, byFull, byName) : [];
|
|
488
514
|
if (matched.length > 0 && matched.every((c) => c.status === "passed")) {
|
|
489
|
-
passing.push(
|
|
490
|
-
for (const ref of
|
|
491
|
-
|
|
492
|
-
covered.add(ref);
|
|
493
|
-
}
|
|
515
|
+
passing.push(testId);
|
|
516
|
+
for (const ref of coversOf(test))
|
|
517
|
+
covered.add(ref);
|
|
494
518
|
}
|
|
495
519
|
}
|
|
496
520
|
if (level >= LEVELS.indexOf("tested") && passing.length === 0) {
|
|
@@ -502,16 +526,16 @@ export function checkMaturity(config, base, today = new Date()) {
|
|
|
502
526
|
// And only when a passing test covers the LAST step — the proof §5.2e rule 4 accepts that an
|
|
503
527
|
// operation goes all the way through. Tests of the door alone (sign-in, permission) pass in
|
|
504
528
|
// 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);
|
|
529
|
+
const stepIds = stepIdsOf(fm);
|
|
507
530
|
const last = stepIds.length > 0 ? stepIds[stepIds.length - 1] : undefined;
|
|
508
|
-
if (haveReport && level < LEVELS.indexOf("implemented") &&
|
|
531
|
+
if (haveReport && level < LEVELS.indexOf("implemented") && last !== undefined && covered.has(last)) {
|
|
509
532
|
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
533
|
}
|
|
511
534
|
// Tier 3 — what cannot be verified must expire.
|
|
512
535
|
if (level >= LEVELS.indexOf("in_production")) {
|
|
536
|
+
// A dated map or a bare string (the schema's two forms); any other value is absent (1.5.1).
|
|
513
537
|
const deployed = evidence["deployed"];
|
|
514
|
-
if (
|
|
538
|
+
if (isMap(deployed)) {
|
|
515
539
|
// A day the calendar has (1.4.3): `new Date` carried 2026-02-30 over to 2 March and called it
|
|
516
540
|
// stale, where the Python prototype said it is not a date. Year 0 is none either.
|
|
517
541
|
const raw = typeof deployed.since === "string" ? deployed.since : "";
|
|
@@ -525,7 +549,7 @@ export function checkMaturity(config, base, today = new Date()) {
|
|
|
525
549
|
findings.push(new Finding("evidence_stale", `${cardId}: \`deployed\` was last affirmed ${age} days ago (horizon ${horizon}) — nobody has looked since`, "warning"));
|
|
526
550
|
}
|
|
527
551
|
}
|
|
528
|
-
else if (deployed) {
|
|
552
|
+
else if (typeof deployed === "string" && deployed) {
|
|
529
553
|
findings.push(new Finding("evidence_undated", `${cardId}: \`deployed: ${deployed}\` carries no date, so it can never go stale`, "warning"));
|
|
530
554
|
}
|
|
531
555
|
}
|
|
@@ -580,10 +604,10 @@ export function checkStorage(config, base) {
|
|
|
580
604
|
const touched = new Set();
|
|
581
605
|
let claims = 0;
|
|
582
606
|
for (const [cardId, fm] of cards) {
|
|
583
|
-
for (const claim of fm["data"]
|
|
607
|
+
for (const claim of maps(map(fm["data"])["storage"])) {
|
|
584
608
|
claims += 1;
|
|
585
|
-
const pattern = claim
|
|
586
|
-
const matched = stores.filter((store) =>
|
|
609
|
+
const pattern = text(claim["store"]) ?? "";
|
|
610
|
+
const matched = stores.filter((store) => globMatches(String(store.name), pattern));
|
|
587
611
|
if (matched.length === 0) {
|
|
588
612
|
findings.push(new Finding("unknown_store", `${cardId}: claims store \`${pattern}\`, which the storage does not have`));
|
|
589
613
|
continue;
|
|
@@ -591,7 +615,7 @@ export function checkStorage(config, base) {
|
|
|
591
615
|
for (const store of matched)
|
|
592
616
|
touched.add(String(store.name));
|
|
593
617
|
// Every environment must agree, or the card is true in one place and false in another.
|
|
594
|
-
const claimedKeys = claim
|
|
618
|
+
const claimedKeys = texts(claim["keyed_by"]);
|
|
595
619
|
if (claimedKeys.length > 0) {
|
|
596
620
|
for (const store of matched) {
|
|
597
621
|
const actual = store.keyed_by ?? [];
|
|
@@ -600,7 +624,7 @@ export function checkStorage(config, base) {
|
|
|
600
624
|
}
|
|
601
625
|
}
|
|
602
626
|
}
|
|
603
|
-
const index = claim
|
|
627
|
+
const index = text(claim["via_index"]);
|
|
604
628
|
if (index) {
|
|
605
629
|
for (const store of matched) {
|
|
606
630
|
const names = (store.indexes ?? []).map((i) => String(i.name));
|
|
@@ -706,9 +730,8 @@ function judgeCalls(contractId, name, control, shownWhen, dataOf, cardById, find
|
|
|
706
730
|
// `from` is one state or a SET of states (round 18): the control must be shown only in
|
|
707
731
|
// states that belong to the set, and in at least one of them. `none` / `any` switch the
|
|
708
732
|
// 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)] : [];
|
|
733
|
+
const rawFrom = map(card["data_transition"])["from"];
|
|
734
|
+
const fromSet = typeof rawFrom === "string" && rawFrom ? [rawFrom] : texts(rawFrom);
|
|
712
735
|
if (fromSet.length > 0 && !fromSet.includes("none") && !fromSet.includes("any")) {
|
|
713
736
|
const mismatch = shownWhen.filter((state) => !fromSet.includes(dataOf(state)));
|
|
714
737
|
if (mismatch.length > 0 || !shownWhen.some((state) => fromSet.includes(dataOf(state)))) {
|
|
@@ -717,6 +740,14 @@ function judgeCalls(contractId, name, control, shownWhen, dataOf, cardById, find
|
|
|
717
740
|
}
|
|
718
741
|
}
|
|
719
742
|
}
|
|
743
|
+
/** 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). */
|
|
744
|
+
function statesOf(value) {
|
|
745
|
+
return Array.isArray(value) ? texts(value) : undefined;
|
|
746
|
+
}
|
|
747
|
+
/** `at_least`, an integer, or 1. */
|
|
748
|
+
function floorOf(line) {
|
|
749
|
+
return Number.isInteger(line["at_least"]) ? Number(line["at_least"]) : 1;
|
|
750
|
+
}
|
|
720
751
|
export function checkForm(config, base) {
|
|
721
752
|
const findings = [];
|
|
722
753
|
const patterns = config.forms ?? [];
|
|
@@ -767,30 +798,35 @@ export function checkForm(config, base) {
|
|
|
767
798
|
}
|
|
768
799
|
const cardById = new Map(cards);
|
|
769
800
|
const contracts = [];
|
|
801
|
+
const read = [];
|
|
770
802
|
for (const path of expandGlobs(base, patterns)) {
|
|
771
803
|
const fm = readDocument(path);
|
|
772
|
-
if (fm && fm["usedesign_form"] === 1)
|
|
773
|
-
contracts.push([
|
|
804
|
+
if (isMap(fm) && fm["usedesign_form"] === 1) {
|
|
805
|
+
contracts.push([text(fm["id"]) ?? path, fm]);
|
|
806
|
+
read.push([path, fm]);
|
|
807
|
+
}
|
|
774
808
|
}
|
|
809
|
+
refuseTwins(read);
|
|
775
810
|
if (contracts.length === 0) {
|
|
776
811
|
findings.push(new Finding("form_contracts_missing", "the `forms` patterns matched no contract"));
|
|
777
812
|
}
|
|
778
813
|
const claimed = new Map();
|
|
779
814
|
let designedAhead = 0;
|
|
780
815
|
for (const [contractId, fm] of contracts) {
|
|
781
|
-
const screen =
|
|
816
|
+
const screen = text(fm["screen"]) ?? "";
|
|
782
817
|
const states = byScreen.get(screen);
|
|
783
818
|
// A contract may be written before its screen — the format's own recommended order of
|
|
784
819
|
// work — and say so with `maturity: designed` (issue #8, the forms twin of issue #3). Then
|
|
785
820
|
// an absent screen is a warning, not the same error as a screen that vanished. The flag
|
|
786
821
|
// cannot go stale silently: the day the screen renders, the contract still saying
|
|
787
822
|
// `designed` is reported too — the gate itself tells the owner to flip it.
|
|
788
|
-
const maturity =
|
|
823
|
+
const maturity = text(fm["maturity"]) || "implemented";
|
|
789
824
|
// Screen state → data state, through the contract's `states:` map; identity when unmapped.
|
|
790
|
-
|
|
825
|
+
// Looked up among the map's own keys: `toString` is not a state.
|
|
826
|
+
const stateMap = map(fm["states"]);
|
|
791
827
|
const dataOf = (state) => {
|
|
792
|
-
const spec = stateMap[state];
|
|
793
|
-
return spec &&
|
|
828
|
+
const spec = Object.prototype.hasOwnProperty.call(stateMap, state) ? stateMap[state] : undefined;
|
|
829
|
+
return (isMap(spec) && text(spec["data"])) || state;
|
|
794
830
|
};
|
|
795
831
|
if (!states) {
|
|
796
832
|
if (maturity === "designed") {
|
|
@@ -800,9 +836,9 @@ export function checkForm(config, base) {
|
|
|
800
836
|
// and comparing it with the cards needs no inventory (issue #13). Only where the config
|
|
801
837
|
// declares `cards:`: a frontend-only repository has none, and stays as quiet as before.
|
|
802
838
|
if (config.cards) {
|
|
803
|
-
for (const control of fm["controls"]
|
|
804
|
-
const name =
|
|
805
|
-
judgeCalls(contractId, name, control, control
|
|
839
|
+
for (const control of maps(fm["controls"])) {
|
|
840
|
+
const name = text(control["control_pattern"]) || text(control["control"]) || "";
|
|
841
|
+
judgeCalls(contractId, name, control, statesOf(control["shown_when"]), dataOf, cardById, findings, "warning");
|
|
806
842
|
}
|
|
807
843
|
}
|
|
808
844
|
}
|
|
@@ -818,17 +854,18 @@ export function checkForm(config, base) {
|
|
|
818
854
|
const mine = claimed.get(screen) ?? { fields: new Set(), controls: new Set(), fieldFamilies: [], controlFamilies: [] };
|
|
819
855
|
claimed.set(screen, mine);
|
|
820
856
|
const countOf = (regex, set) => [...set].filter((name) => regex.test(name)).length;
|
|
821
|
-
for (const entry of fm["presents"]
|
|
822
|
-
const pattern =
|
|
857
|
+
for (const entry of maps(fm["presents"])) {
|
|
858
|
+
const pattern = text(entry["field_pattern"]) ?? "";
|
|
859
|
+
const when = statesOf(entry["when"]);
|
|
823
860
|
if (pattern) {
|
|
824
861
|
// A family: one element per item of a list that arrives at runtime. Every rendered
|
|
825
862
|
// anchor matching the pattern is accounted for; `at_least` keeps the family from
|
|
826
863
|
// silently becoming empty — a screen rendering NO tabs because the backend returned
|
|
827
864
|
// nothing is a real failure, and without the floor nothing would notice.
|
|
828
865
|
const regex = familyRegex(pattern);
|
|
829
|
-
const atLeast =
|
|
866
|
+
const atLeast = floorOf(entry);
|
|
830
867
|
mine.fieldFamilies.push(regex);
|
|
831
|
-
for (const state of
|
|
868
|
+
for (const state of when ?? everyState) {
|
|
832
869
|
const rendered = states.get(state);
|
|
833
870
|
if (!rendered) {
|
|
834
871
|
findings.push(new Finding("form_state_missing", `${contractId}: \`${pattern}\` is required in state \`${state}\`, which the inventory never rendered`));
|
|
@@ -840,12 +877,12 @@ export function checkForm(config, base) {
|
|
|
840
877
|
}
|
|
841
878
|
}
|
|
842
879
|
}
|
|
843
|
-
fieldOutOfState(contractId, pattern,
|
|
880
|
+
fieldOutOfState(contractId, pattern, when, states, (rendered) => countOf(regex, rendered.fields) > 0, findings);
|
|
844
881
|
continue;
|
|
845
882
|
}
|
|
846
|
-
const field =
|
|
883
|
+
const field = text(entry["field"]) ?? "";
|
|
847
884
|
mine.fields.add(field);
|
|
848
|
-
for (const state of
|
|
885
|
+
for (const state of when ?? everyState) {
|
|
849
886
|
const rendered = states.get(state);
|
|
850
887
|
if (!rendered) {
|
|
851
888
|
findings.push(new Finding("form_state_missing", `${contractId}: \`${field}\` is required in state \`${state}\`, which the inventory never rendered`));
|
|
@@ -854,18 +891,18 @@ export function checkForm(config, base) {
|
|
|
854
891
|
findings.push(new Finding("element_missing", `${contractId}: \`${field}\` must be shown in state \`${state}\` and is not`));
|
|
855
892
|
}
|
|
856
893
|
}
|
|
857
|
-
fieldOutOfState(contractId, field,
|
|
894
|
+
fieldOutOfState(contractId, field, when, states, (rendered) => rendered.fields.has(field), findings);
|
|
858
895
|
}
|
|
859
|
-
for (const control of fm["controls"]
|
|
860
|
-
const pattern =
|
|
896
|
+
for (const control of maps(fm["controls"])) {
|
|
897
|
+
const pattern = text(control["control_pattern"]) ?? "";
|
|
861
898
|
const regex = pattern ? familyRegex(pattern) : null;
|
|
862
|
-
const atLeast =
|
|
863
|
-
const name = pattern ||
|
|
899
|
+
const atLeast = floorOf(control);
|
|
900
|
+
const name = pattern || (text(control["control"]) ?? "");
|
|
864
901
|
if (regex)
|
|
865
902
|
mine.controlFamilies.push(regex);
|
|
866
903
|
else
|
|
867
904
|
mine.controls.add(name);
|
|
868
|
-
const shownWhen = control
|
|
905
|
+
const shownWhen = statesOf(control["shown_when"]);
|
|
869
906
|
// For a literal both questions are "is it there"; for a family they differ: enough
|
|
870
907
|
// members for the floor, versus any member at all (which is what leaks out of state).
|
|
871
908
|
const enough = (rendered) => (regex ? countOf(regex, rendered.controls) >= atLeast : rendered.controls.has(name));
|
|
@@ -894,8 +931,8 @@ export function checkForm(config, base) {
|
|
|
894
931
|
}
|
|
895
932
|
judgeCalls(contractId, name, control, shownWhen, dataOf, cardById, findings, "error");
|
|
896
933
|
}
|
|
897
|
-
for (const entry of fm["removed"]
|
|
898
|
-
const name =
|
|
934
|
+
for (const entry of maps(fm["removed"])) {
|
|
935
|
+
const name = text(entry["control"]) ?? "";
|
|
899
936
|
mine.controls.add(name);
|
|
900
937
|
for (const [state, rendered] of states) {
|
|
901
938
|
if (rendered.controls.has(name)) {
|
|
@@ -906,9 +943,10 @@ export function checkForm(config, base) {
|
|
|
906
943
|
// A group's anchor is accounted for BY the group line: the owner named the container when
|
|
907
944
|
// grouping by it. Warning about it as undescribed would ask the owner to decide what they
|
|
908
945
|
// already decided — measured 19.08: three of fourteen warnings were exactly this noise.
|
|
909
|
-
for (const group of fm["groups"]
|
|
910
|
-
|
|
911
|
-
|
|
946
|
+
for (const group of maps(fm["groups"])) {
|
|
947
|
+
const gname = text(group["group"]);
|
|
948
|
+
if (gname)
|
|
949
|
+
mine.fields.add(gname);
|
|
912
950
|
}
|
|
913
951
|
// ── group membership ───────────────────────────────────────────────────────────────────
|
|
914
952
|
// The contract seats elements in groups; the inventory's `within` records which containers
|
|
@@ -916,16 +954,15 @@ export function checkForm(config, base) {
|
|
|
916
954
|
// where the member renders and the inventory carries the measurement. The first inventory
|
|
917
955
|
// with containers refuted its own contract: `add-set` was contracted into the footer and
|
|
918
956
|
// 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 =
|
|
957
|
+
for (const group of maps(fm["groups"])) {
|
|
958
|
+
const gname = text(group["group"]) ?? "";
|
|
921
959
|
if (!gname)
|
|
922
960
|
continue;
|
|
923
961
|
const anchorSeen = [...states.values()].some((r) => r.fields.has(gname) || (r.within !== null && [...r.within.values()].some((chain) => chain.has(gname))));
|
|
924
962
|
if (!anchorSeen) {
|
|
925
963
|
findings.push(new Finding("group_missing", `${contractId}: group \`${gname}\` is contracted but its anchor never renders`));
|
|
926
964
|
}
|
|
927
|
-
for (const
|
|
928
|
-
const member = String(memberRaw);
|
|
965
|
+
for (const member of texts(group["contains"])) {
|
|
929
966
|
// A family seated in a group: every rendered member of the family must sit there.
|
|
930
967
|
const regex = member.includes("*") ? familyRegex(member) : null;
|
|
931
968
|
for (const [state, rendered] of states) {
|