@am_shork/attest 0.7.3 → 0.8.0
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/CHANGELOG.md +1053 -172
- package/README.md +1 -1
- package/dist/cli/index.js +11 -8
- package/dist/cli/json.d.ts +26 -1
- package/dist/cli/json.js +28 -2
- package/dist/core/compiler.d.ts +32 -0
- package/dist/core/compiler.js +78 -0
- package/dist/core/docs.d.ts +1 -1
- package/dist/core/docs.js +1 -0
- package/dist/core/gate.js +3 -3
- package/dist/core/locate.d.ts +22 -2
- package/dist/core/locate.js +33 -8
- package/dist/core/parser.d.ts +12 -0
- package/dist/core/parser.js +18 -2
- package/dist/core/pipeline.d.ts +17 -1
- package/dist/core/pipeline.js +109 -17
- package/dist/core/red-record.d.ts +14 -5
- package/dist/core/red-record.js +82 -24
- package/dist/core/registry-issues.d.ts +30 -0
- package/dist/core/registry-issues.js +26 -0
- package/dist/core/registry.d.ts +12 -5
- package/dist/core/registry.js +10 -8
- package/dist/core/runner.js +21 -9
- package/dist/core/schema.d.ts +32 -27
- package/dist/core/schema.js +33 -5
- package/dist/core/skill.js +13 -0
- package/dist/core/splice.js +45 -14
- package/dist/core/static-registry.d.ts +34 -2
- package/dist/core/static-registry.js +138 -12
- package/dist/core/status.js +2 -2
- package/dist/core/terminal.js +5 -2
- package/dist/core/types.d.ts +39 -10
- package/dist/core/validator.d.ts +9 -4
- package/dist/core/validator.js +74 -21
- package/package.json +2 -2
package/dist/core/pipeline.js
CHANGED
|
@@ -14,7 +14,7 @@ import { writeAtomic } from './write.js';
|
|
|
14
14
|
import { applyMerge, mergedSpecPath } from './merge.js';
|
|
15
15
|
import { compilerIssue } from './compiler.js';
|
|
16
16
|
import { mkdir, readFile, realpath } from 'node:fs/promises';
|
|
17
|
-
import { basename, dirname, join } from 'node:path';
|
|
17
|
+
import { basename, dirname, join, resolve } from 'node:path';
|
|
18
18
|
import { isInside, relativePath } from './paths.js';
|
|
19
19
|
import { hasError } from './types.js';
|
|
20
20
|
// The runner half of the engine, reached only when a command actually needs it.
|
|
@@ -108,7 +108,7 @@ export async function runCheck(root, options = {}) {
|
|
|
108
108
|
// command that reads both the registry and every change's delta, so it is the
|
|
109
109
|
// only one where that distinction is worth anything.
|
|
110
110
|
return withLoader(options, async (loader) => {
|
|
111
|
-
const { registry, issues,
|
|
111
|
+
const { registry, issues, unreadable } = await readRegistry(root, options, scan.reqsFiles, loader);
|
|
112
112
|
const { plan, issues: unreadableSpecs } = await parseSpecs(scan.specFiles, root);
|
|
113
113
|
return [
|
|
114
114
|
...issues,
|
|
@@ -121,7 +121,7 @@ export async function runCheck(root, options = {}) {
|
|
|
121
121
|
// that *did* load are all still true. What it must not do is advise work
|
|
122
122
|
// that the load failure makes wrong — see `orphan-test` in
|
|
123
123
|
// `validateStructure`.
|
|
124
|
-
...validateStructure(registry, plan,
|
|
124
|
+
...validateStructure(registry, plan, unreadable),
|
|
125
125
|
...detectPotentialDrift(registry, plan, plan.paramRefs),
|
|
126
126
|
...(await unclaimedProposedSpecIssues(root, scan, options, loader)),
|
|
127
127
|
...(await changeDirSpecIssues(root)),
|
|
@@ -291,13 +291,13 @@ export async function runVerify(root, options = {}) {
|
|
|
291
291
|
const loader = await createLoader();
|
|
292
292
|
let registry;
|
|
293
293
|
let plan;
|
|
294
|
-
let
|
|
294
|
+
let unreadable;
|
|
295
295
|
const issues = [];
|
|
296
296
|
try {
|
|
297
297
|
const loaded = await loadRegistry(root, evalReader(loader), scan.reqsFiles);
|
|
298
298
|
registry = loaded.registry;
|
|
299
299
|
issues.push(...loaded.issues);
|
|
300
|
-
|
|
300
|
+
unreadable = loaded.unreadable;
|
|
301
301
|
const parsedSpecs = await parseSpecs(scan.specFiles, root);
|
|
302
302
|
plan = parsedSpecs.plan;
|
|
303
303
|
issues.push(...parsedSpecs.issues);
|
|
@@ -307,7 +307,7 @@ export async function runVerify(root, options = {}) {
|
|
|
307
307
|
}
|
|
308
308
|
// Same as `check`: everything the loaded half supports is still reported, and
|
|
309
309
|
// only the advice that a load failure would make wrong is withdrawn.
|
|
310
|
-
issues.push(...validateStructure(registry, plan,
|
|
310
|
+
issues.push(...validateStructure(registry, plan, unreadable));
|
|
311
311
|
issues.push(...detectPotentialDrift(registry, plan, plan.paramRefs));
|
|
312
312
|
const attesting = attestingFiles(plan);
|
|
313
313
|
const counts = {
|
|
@@ -405,15 +405,38 @@ export async function runCover(root, options = {}) {
|
|
|
405
405
|
* every time a line moves in a test file.
|
|
406
406
|
*/
|
|
407
407
|
export async function runRender(root, options = {}) {
|
|
408
|
+
// The compiler guard stays the first statement, as it is in every other entry
|
|
409
|
+
// point (ATX-56): a TypeScript with no AST API is a refusal about the
|
|
410
|
+
// toolchain, and it outranks anything this run could say about the caller's
|
|
411
|
+
// paths or registry.
|
|
408
412
|
const unusable = compilerIssue();
|
|
409
413
|
if (unusable)
|
|
410
|
-
return { markdown: '', issues: [unusable] };
|
|
414
|
+
return { markdown: '', issues: [unusable], wrote: false };
|
|
415
|
+
// Then the destination, before the registry is read, so a refused path is the
|
|
416
|
+
// only thing reported: a run that cannot write anywhere has no reason to also
|
|
417
|
+
// tell the user about their registry, and the report a pipeline reads should
|
|
418
|
+
// name the one thing that has to change.
|
|
419
|
+
const dest = options.out ? await resolveOutFile(root, options.out) : undefined;
|
|
420
|
+
if (dest && !dest.ok)
|
|
421
|
+
return { markdown: '', issues: [dest.issue], wrote: false };
|
|
411
422
|
const { registry, issues } = await readRegistry(root, options);
|
|
412
423
|
// A registry that failed to load yields a document that silently omits
|
|
413
|
-
// requirements. An incomplete spec doc is worse than none, so refuse
|
|
424
|
+
// requirements. An incomplete spec doc is worse than none, so refuse — and
|
|
425
|
+
// since the write is below this line, refusing is also what keeps a broken
|
|
426
|
+
// registry from overwriting a good committed document.
|
|
414
427
|
if (hasError(issues))
|
|
415
|
-
return { markdown: '', issues };
|
|
416
|
-
|
|
428
|
+
return { markdown: '', issues, wrote: false };
|
|
429
|
+
const markdown = renderMarkdown(registry);
|
|
430
|
+
// The write lives here rather than in the CLI, and that is the whole of what
|
|
431
|
+
// ATX-73 is structurally about: while it sat in the shell it was the one
|
|
432
|
+
// `writeAtomic` call outside this layer, and it reached the filesystem
|
|
433
|
+
// without the resolution `runInit` had performed since ATX-66. Resolving and
|
|
434
|
+
// writing in one function is what stops the two from coming apart again —
|
|
435
|
+
// a later caller cannot forget a step it never had to take.
|
|
436
|
+
if (!dest)
|
|
437
|
+
return { markdown, issues, wrote: false };
|
|
438
|
+
await writeAtomic(dest.dest, markdown);
|
|
439
|
+
return { markdown, issues, wrote: true };
|
|
417
440
|
}
|
|
418
441
|
/**
|
|
419
442
|
* Freshness gate for a committed rendering (`attest render --check`).
|
|
@@ -423,10 +446,29 @@ export async function runRender(root, options = {}) {
|
|
|
423
446
|
* alongside a gate that fails when the file no longer matches its source.
|
|
424
447
|
*/
|
|
425
448
|
export async function runRenderCheck(root, outFile, target, options = {}) {
|
|
449
|
+
// Called here despite the delegation further down, because the resolution
|
|
450
|
+
// below it runs first: the guard has to be the first statement of an entry
|
|
451
|
+
// point (ATX-56), and delegation cannot supply that once anything precedes
|
|
452
|
+
// it. Same precedence in both, for the reason `runRender` states.
|
|
453
|
+
const unusable = compilerIssue();
|
|
454
|
+
if (unusable)
|
|
455
|
+
return [unusable];
|
|
456
|
+
// Resolved before it is read, not only before it is written. A link along the
|
|
457
|
+
// path makes this compare a fresh document against a file outside the project
|
|
458
|
+
// — and both verdicts that can produce are ones the gate reports normally, so
|
|
459
|
+
// the wrong answer is indistinguishable from a right one.
|
|
460
|
+
//
|
|
461
|
+
// `target` already carries the spelling a diagnostic must echo, so the pair
|
|
462
|
+
// `resolveOutFile` wants is assembled here rather than asked of the caller:
|
|
463
|
+
// taking a `RenderOut` beside a `RenderTarget` would make the shell pass the
|
|
464
|
+
// same string twice, which is a clump waiting to disagree with itself.
|
|
465
|
+
const dest = await resolveOutFile(root, { file: outFile, display: target.display });
|
|
466
|
+
if (!dest.ok)
|
|
467
|
+
return [dest.issue];
|
|
426
468
|
const { markdown, issues } = await runRender(root, options);
|
|
427
469
|
if (hasError(issues))
|
|
428
470
|
return issues;
|
|
429
|
-
const current = await readFile(
|
|
471
|
+
const current = await readFile(dest.dest, 'utf8').catch(() => undefined);
|
|
430
472
|
const stale = staleIssue(target, current, markdown);
|
|
431
473
|
return stale ? [...issues, stale] : issues;
|
|
432
474
|
}
|
|
@@ -467,12 +509,7 @@ export async function runInit(root, names) {
|
|
|
467
509
|
writes.push({ target, dest: resolved.dest });
|
|
468
510
|
}
|
|
469
511
|
else {
|
|
470
|
-
refusals.push(
|
|
471
|
-
level: 'ERROR',
|
|
472
|
-
code: 'unsafe-target-path',
|
|
473
|
-
file: target.file,
|
|
474
|
-
message: `Refusing to write ${target.file}: it resolves to ${resolved.escape}, outside the project. Remove the link at that path and re-run.`,
|
|
475
|
-
});
|
|
512
|
+
refusals.push(unsafeTargetPathIssue(target.file, resolved.escape));
|
|
476
513
|
}
|
|
477
514
|
}
|
|
478
515
|
if (refusals.length > 0)
|
|
@@ -485,6 +522,61 @@ export async function runInit(root, names) {
|
|
|
485
522
|
}
|
|
486
523
|
return { files, issues };
|
|
487
524
|
}
|
|
525
|
+
/**
|
|
526
|
+
* Where a `render` destination really lands, or the refusal that a link
|
|
527
|
+
* redirected it (design §9: ownership of the path is checked, and who named the
|
|
528
|
+
* path decides what the refusal is about).
|
|
529
|
+
*
|
|
530
|
+
* `init` names its own destinations, so every one of them is checked; `--out`
|
|
531
|
+
* is the caller's path, and that is the whole of the difference between the two
|
|
532
|
+
* callers. A path spelled outside the project is the user asking for a file
|
|
533
|
+
* outside the project — `--out ../site/SPEC.md` is a destination, not an
|
|
534
|
+
* escape, and refusing it would be refusing the flag. What is not visible in
|
|
535
|
+
* the argument is a *link* along a path that reads as staying inside, which is
|
|
536
|
+
* the one a checkout can plant: the victim's `--out` is the string their README
|
|
537
|
+
* or CI already documents, and the content redirected is the document their
|
|
538
|
+
* repository wrote.
|
|
539
|
+
*
|
|
540
|
+
* So the check applies exactly when the destination is lexically inside the
|
|
541
|
+
* root, and it is `resolveDest`'s, unchanged. The leaf needs no separate
|
|
542
|
+
* defence and never did — `writeAtomic` lands by `rename`, which replaces a
|
|
543
|
+
* symlink rather than following it — but it is resolved with the rest anyway,
|
|
544
|
+
* because a link there is still a file the user made deliberately and this
|
|
545
|
+
* refuses rather than destroys it.
|
|
546
|
+
*
|
|
547
|
+
* Both entry points that name a destination come through here — the one that
|
|
548
|
+
* writes it and the one that reads it back to date it — which is why this is
|
|
549
|
+
* private to this module rather than something a shell is trusted to call.
|
|
550
|
+
*/
|
|
551
|
+
async function resolveOutFile(root, out) {
|
|
552
|
+
const dest = resolve(out.file);
|
|
553
|
+
const realRoot = await realpath(root).catch(() => root);
|
|
554
|
+
// Asked against both spellings of the root, because they differ whenever the
|
|
555
|
+
// project itself sits under a link: `dest` is built from the caller's cwd, so
|
|
556
|
+
// it is lexical, and testing only the resolved root would skip the check for
|
|
557
|
+
// every such checkout — failing open on exactly the layout that has links in
|
|
558
|
+
// it. `''` is the root itself, which is a directory and not a destination.
|
|
559
|
+
const rel = isInside(root, dest)
|
|
560
|
+
? relativePath(root, dest)
|
|
561
|
+
: isInside(realRoot, dest)
|
|
562
|
+
? relativePath(realRoot, dest)
|
|
563
|
+
: undefined;
|
|
564
|
+
if (rel === undefined || rel === '')
|
|
565
|
+
return { ok: true, dest };
|
|
566
|
+
const resolved = await resolveDest(realRoot, rel);
|
|
567
|
+
return resolved.ok
|
|
568
|
+
? { ok: true, dest: resolved.dest }
|
|
569
|
+
: { ok: false, issue: unsafeTargetPathIssue(out.display, resolved.escape) };
|
|
570
|
+
}
|
|
571
|
+
/** The one refusal both destination checks report (design §9). */
|
|
572
|
+
function unsafeTargetPathIssue(display, escape) {
|
|
573
|
+
return {
|
|
574
|
+
level: 'ERROR',
|
|
575
|
+
code: 'unsafe-target-path',
|
|
576
|
+
file: display,
|
|
577
|
+
message: `Refusing to write ${display}: it resolves to ${escape}, outside the project. Remove the link at that path and re-run.`,
|
|
578
|
+
};
|
|
579
|
+
}
|
|
488
580
|
/**
|
|
489
581
|
* Where a target's file really goes, or the path outside the project that
|
|
490
582
|
* asking put it at (design §9: ownership of the path is checked, not assumed).
|
|
@@ -1,17 +1,26 @@
|
|
|
1
|
-
import type { AttestPlan, Outcome, RunResult } from './types.js';
|
|
1
|
+
import type { AttestPlan, Outcome, RunResult, ScenarioRef } from './types.js';
|
|
2
2
|
/** Where the record lives, relative to the project root. */
|
|
3
3
|
export declare function redRecordPath(root: string, changeName: string): string;
|
|
4
4
|
/** The file name, exported so a diagnostic can name it without rebuilding it. */
|
|
5
5
|
export declare const RED_RECORD_FILE = "first-run.json";
|
|
6
6
|
/**
|
|
7
|
-
* reqId -> scenario name -> the outcome of its first observed run.
|
|
7
|
+
* reqId -> spec file -> scenario name -> the outcome of its first observed run.
|
|
8
8
|
*
|
|
9
9
|
* Deliberately not a list of "red scenarios": a scenario whose first run passed
|
|
10
10
|
* is the case mechanism 2 exists to catch, so it has to be recorded as a fact
|
|
11
11
|
* rather than as an absence. An absence then means only one thing — never
|
|
12
12
|
* observed at all — and the gate can report the two separately.
|
|
13
|
+
*
|
|
14
|
+
* **The file level is what identifies a scenario, and it was missing.** A
|
|
15
|
+
* scenario is `(reqId, file, name)` in the static plan and was `(reqId, name)`
|
|
16
|
+
* here, so two spec files declaring the same name under one added requirement
|
|
17
|
+
* shared an entry — and because the record is monotonic toward `fail`, one of
|
|
18
|
+
* them going red satisfied the other's first-red obligation. Nested rather than
|
|
19
|
+
* a composite key string, so no separator has to be chosen that a path or a
|
|
20
|
+
* scenario name could contain, and so the committed file stays something a
|
|
21
|
+
* reviewer reads (design §6).
|
|
13
22
|
*/
|
|
14
|
-
export type RedRecord = Record<string, Record<string, Outcome
|
|
23
|
+
export type RedRecord = Record<string, Record<string, Record<string, Outcome>>>;
|
|
15
24
|
/**
|
|
16
25
|
* Read the record for a change. A missing or unreadable file is an empty
|
|
17
26
|
* record, never an error: the first stage-1 run legitimately finds nothing, and
|
|
@@ -34,7 +43,7 @@ export declare function readRedRecord(root: string, changeName: string): Promise
|
|
|
34
43
|
* what it must not do is answer from an inherited key whoever constructed the
|
|
35
44
|
* object it was handed. `record[reqId]?.[scenario]` did exactly that.
|
|
36
45
|
*/
|
|
37
|
-
export declare function recordedOutcome(record: RedRecord,
|
|
46
|
+
export declare function recordedOutcome(record: RedRecord, ref: ScenarioRef): Outcome | undefined;
|
|
38
47
|
/**
|
|
39
48
|
* Whether this scenario has satisfied the first-red obligation (design §6).
|
|
40
49
|
*
|
|
@@ -44,7 +53,7 @@ export declare function recordedOutcome(record: RedRecord, reqId: string, scenar
|
|
|
44
53
|
* apart — the same argument that extracted `declaredNotRunIssues` and
|
|
45
54
|
* `uncoveredIssues` from the commands that had a copy each.
|
|
46
55
|
*/
|
|
47
|
-
export declare function hasRecordedRed(record: RedRecord,
|
|
56
|
+
export declare function hasRecordedRed(record: RedRecord, ref: ScenarioRef): boolean;
|
|
48
57
|
/**
|
|
49
58
|
* Fold this run's outcomes into the record, for the scenarios that carry an
|
|
50
59
|
* obligation — those covering a requirement this change ADDs.
|
package/dist/core/red-record.js
CHANGED
|
@@ -47,6 +47,32 @@ import { readFile } from 'node:fs/promises';
|
|
|
47
47
|
import { join } from 'node:path';
|
|
48
48
|
import { z } from 'zod';
|
|
49
49
|
import { byCodeUnit } from './order.js';
|
|
50
|
+
import { mergedSpecPath } from './merge.js';
|
|
51
|
+
import { toPosixPath } from './paths.js';
|
|
52
|
+
/**
|
|
53
|
+
* The file half of a scenario's identity, as this record spells it.
|
|
54
|
+
*
|
|
55
|
+
* A scenario is `(reqId, file, name)` — but the one file this record is about
|
|
56
|
+
* is a **proposed** spec, and `--apply` renames it in place as step 2 of the
|
|
57
|
+
* merge. Keyed by the name on disk, a resume after that rename looked up
|
|
58
|
+
* `app2.spec.ts` in a record written under `app2.proposed.spec.ts`, found
|
|
59
|
+
* nothing, and blocked a change whose scenarios had been observed red — the
|
|
60
|
+
* gate refusing its own evidence. Rewriting the record beside the rename was
|
|
61
|
+
* the alternative and is worse: two files that must move together, in a design
|
|
62
|
+
* whose whole resume story is that each step is separately idempotent.
|
|
63
|
+
*
|
|
64
|
+
* So the key is the **merged** spelling from the start, which the rename cannot
|
|
65
|
+
* move. It still separates two spec files, which is the collapse this key
|
|
66
|
+
* exists to prevent; what it deliberately does not separate is one file from
|
|
67
|
+
* its own proposed name, because those are the same file.
|
|
68
|
+
*
|
|
69
|
+
* Through `toPosixPath` because `mergedSpecPath` rebuilds the path with
|
|
70
|
+
* `node:path`, which on Windows hands back a backslash — and this is a
|
|
71
|
+
* comparison key against a plan built on POSIX separators (`paths.ts`).
|
|
72
|
+
*/
|
|
73
|
+
function fileKey(file) {
|
|
74
|
+
return toPosixPath(mergedSpecPath(file));
|
|
75
|
+
}
|
|
50
76
|
/**
|
|
51
77
|
* **The record is a map, so it is built without a prototype.**
|
|
52
78
|
*
|
|
@@ -92,7 +118,7 @@ export const RED_RECORD_FILE = 'first-run.json';
|
|
|
92
118
|
* The gate was never at risk — `hasRecordedRed` compares against `'fail'`, so an
|
|
93
119
|
* unrecognised value blocks exactly like a missing one. This closes the report.
|
|
94
120
|
*/
|
|
95
|
-
const RedRecordSchema = z.record(z.string(), z.record(z.string(), z.enum(['pass', 'fail'])));
|
|
121
|
+
const RedRecordSchema = z.record(z.string(), z.record(z.string(), z.record(z.string(), z.enum(['pass', 'fail']))));
|
|
96
122
|
/**
|
|
97
123
|
* Read the record for a change. A missing or unreadable file is an empty
|
|
98
124
|
* record, never an error: the first stage-1 run legitimately finds nothing, and
|
|
@@ -122,8 +148,15 @@ export async function readRedRecord(root, changeName) {
|
|
|
122
148
|
}
|
|
123
149
|
// `JSON.parse` yields `any`; nothing about the file is known until the schema
|
|
124
150
|
// says so, including whether it is an object at all (`JSON.parse('null')`).
|
|
125
|
-
|
|
126
|
-
|
|
151
|
+
// The version is checked rather than inferred from whether the shape happens
|
|
152
|
+
// to parse. A version-1 record keyed `reqId -> name` would fail the schema
|
|
153
|
+
// anyway and be discarded whole, which is the safe direction — but "discarded
|
|
154
|
+
// because the format moved" and "discarded because the file is corrupt" are
|
|
155
|
+
// different facts, and reading one as the other is how a format change gets
|
|
156
|
+
// made without anyone deciding to make it.
|
|
157
|
+
if (!isRecord(parsed) || parsed['version'] !== 2)
|
|
158
|
+
return emptyMap();
|
|
159
|
+
const result = RedRecordSchema.safeParse(parsed['firstRun']);
|
|
127
160
|
return result.success ? adopt(result.data) : emptyMap();
|
|
128
161
|
}
|
|
129
162
|
/**
|
|
@@ -139,8 +172,16 @@ export async function readRedRecord(root, changeName) {
|
|
|
139
172
|
*/
|
|
140
173
|
function adopt(parsed) {
|
|
141
174
|
const record = emptyMap();
|
|
142
|
-
for (const [id,
|
|
143
|
-
|
|
175
|
+
for (const [id, byFile] of Object.entries(parsed)) {
|
|
176
|
+
const files = emptyMap();
|
|
177
|
+
// Every level, not just the two that existed before: the file level is as
|
|
178
|
+
// much a key taken from a string this module did not choose as the other
|
|
179
|
+
// two, and a container with a prototype is what the whole comment above is
|
|
180
|
+
// about.
|
|
181
|
+
for (const [file, outcomes] of Object.entries(byFile)) {
|
|
182
|
+
files[file] = Object.assign(emptyMap(), outcomes);
|
|
183
|
+
}
|
|
184
|
+
record[id] = files;
|
|
144
185
|
}
|
|
145
186
|
return record;
|
|
146
187
|
}
|
|
@@ -153,13 +194,17 @@ function adopt(parsed) {
|
|
|
153
194
|
* what it must not do is answer from an inherited key whoever constructed the
|
|
154
195
|
* object it was handed. `record[reqId]?.[scenario]` did exactly that.
|
|
155
196
|
*/
|
|
156
|
-
export function recordedOutcome(record,
|
|
157
|
-
if (!Object.hasOwn(record, reqId))
|
|
197
|
+
export function recordedOutcome(record, ref) {
|
|
198
|
+
if (!Object.hasOwn(record, ref.reqId))
|
|
158
199
|
return undefined;
|
|
159
|
-
const
|
|
160
|
-
|
|
200
|
+
const byFile = record[ref.reqId];
|
|
201
|
+
const key = fileKey(ref.file);
|
|
202
|
+
if (byFile === undefined || !Object.hasOwn(byFile, key))
|
|
161
203
|
return undefined;
|
|
162
|
-
|
|
204
|
+
const outcomes = byFile[key];
|
|
205
|
+
if (outcomes === undefined || !Object.hasOwn(outcomes, ref.name))
|
|
206
|
+
return undefined;
|
|
207
|
+
return outcomes[ref.name];
|
|
163
208
|
}
|
|
164
209
|
/**
|
|
165
210
|
* Whether this scenario has satisfied the first-red obligation (design §6).
|
|
@@ -170,8 +215,8 @@ export function recordedOutcome(record, reqId, scenario) {
|
|
|
170
215
|
* apart — the same argument that extracted `declaredNotRunIssues` and
|
|
171
216
|
* `uncoveredIssues` from the commands that had a copy each.
|
|
172
217
|
*/
|
|
173
|
-
export function hasRecordedRed(record,
|
|
174
|
-
return recordedOutcome(record,
|
|
218
|
+
export function hasRecordedRed(record, ref) {
|
|
219
|
+
return recordedOutcome(record, ref) === 'fail';
|
|
175
220
|
}
|
|
176
221
|
/**
|
|
177
222
|
* Fold this run's outcomes into the record, for the scenarios that carry an
|
|
@@ -190,42 +235,55 @@ export function mergeRedRecord(existing, plan, run, addedIds) {
|
|
|
190
235
|
// machines write byte-identical files (the same reason `render` orders ids
|
|
191
236
|
// through one code-unit comparator).
|
|
192
237
|
for (const id of Object.keys(existing).sort(byCodeUnit)) {
|
|
193
|
-
const
|
|
194
|
-
if (
|
|
238
|
+
const byFile = existing[id];
|
|
239
|
+
if (byFile === undefined)
|
|
195
240
|
continue;
|
|
196
|
-
|
|
241
|
+
const files = emptyMap();
|
|
242
|
+
for (const file of Object.keys(byFile).sort(byCodeUnit)) {
|
|
243
|
+
const outcomes = byFile[file];
|
|
244
|
+
if (outcomes === undefined)
|
|
245
|
+
continue;
|
|
246
|
+
files[file] = Object.assign(emptyMap(), outcomes);
|
|
247
|
+
}
|
|
248
|
+
record[id] = files;
|
|
197
249
|
}
|
|
198
250
|
let changed = false;
|
|
199
251
|
for (const s of plan.scenarios) {
|
|
200
252
|
if (!added.has(s.reqId))
|
|
201
253
|
continue;
|
|
202
|
-
const outcome = run.outcomes.get(s.reqId)?.get(s.name);
|
|
254
|
+
const outcome = run.outcomes.get(s.reqId)?.get(s.file)?.get(s.name);
|
|
203
255
|
// No outcome means the scenario did not execute. That is `declared-not-run`,
|
|
204
256
|
// reported by the gate on its own terms; writing nothing here leaves the
|
|
205
257
|
// obligation unobserved rather than inventing a state for it.
|
|
206
258
|
if (!outcome)
|
|
207
259
|
continue;
|
|
208
260
|
const forId = (record[s.reqId] ??= emptyMap());
|
|
261
|
+
const forFile = (forId[fileKey(s.file)] ??= emptyMap());
|
|
209
262
|
// Monotonic toward `fail`: a recorded fail is final, a recorded pass can
|
|
210
263
|
// still be corrected by a real one. See the note at the top of this file.
|
|
211
|
-
if (
|
|
264
|
+
if (forFile[s.name] === 'fail' || forFile[s.name] === outcome)
|
|
212
265
|
continue;
|
|
213
|
-
|
|
266
|
+
forFile[s.name] = outcome;
|
|
214
267
|
changed = true;
|
|
215
268
|
}
|
|
216
|
-
// Sort within each requirement for the same
|
|
269
|
+
// Sort within each requirement, and within each file, for the same
|
|
270
|
+
// byte-stability reason.
|
|
217
271
|
for (const id of Object.keys(record)) {
|
|
218
|
-
const
|
|
219
|
-
for (const
|
|
220
|
-
sorted
|
|
272
|
+
const sortedFiles = emptyMap();
|
|
273
|
+
for (const file of Object.keys(record[id]).sort(byCodeUnit)) {
|
|
274
|
+
const sorted = emptyMap();
|
|
275
|
+
for (const name of Object.keys(record[id][file]).sort(byCodeUnit)) {
|
|
276
|
+
sorted[name] = record[id][file][name];
|
|
277
|
+
}
|
|
278
|
+
sortedFiles[file] = sorted;
|
|
221
279
|
}
|
|
222
|
-
record[id] =
|
|
280
|
+
record[id] = sortedFiles;
|
|
223
281
|
}
|
|
224
282
|
return { record, changed };
|
|
225
283
|
}
|
|
226
284
|
/** Serialise the record. A trailing newline, so the file is a well-formed text file. */
|
|
227
285
|
export function serialiseRedRecord(changeName, record) {
|
|
228
|
-
const file = { version:
|
|
286
|
+
const file = { version: 2, change: changeName, firstRun: record };
|
|
229
287
|
return `${JSON.stringify(file, null, 2)}\n`;
|
|
230
288
|
}
|
|
231
289
|
/** Container check only — what is *in* it is the schema's business, not this. */
|
|
@@ -0,0 +1,30 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* One structural failure from validating a registry.
|
|
3
|
+
*
|
|
4
|
+
* Deliberately not the validator's own issue type: `issues` is reached through
|
|
5
|
+
* the `./define` subpath, so its element type is a public contract, and §10's
|
|
6
|
+
* fourth encapsulation rule keeps a dependency's type off one. These two fields
|
|
7
|
+
* are the whole of what this project consumes — nothing reads `code`,
|
|
8
|
+
* `expected`, `received` or `fatal`.
|
|
9
|
+
*
|
|
10
|
+
* `path` is `PropertyKey[]` rather than `(string | number)[]` because that is
|
|
11
|
+
* the wider of the two spellings zod has used, so a validator issue assigns to
|
|
12
|
+
* this without a cast in either direction. Keep it the wider one: narrowing it
|
|
13
|
+
* would put the next reshape of that type back on the boundary.
|
|
14
|
+
*/
|
|
15
|
+
export interface RegistryValidationIssue {
|
|
16
|
+
path: PropertyKey[];
|
|
17
|
+
message: string;
|
|
18
|
+
}
|
|
19
|
+
/**
|
|
20
|
+
* The message `RegistryValidationError` carries, available without one.
|
|
21
|
+
*
|
|
22
|
+
* Three sites report `registry-invalid` — the throwing path in
|
|
23
|
+
* `defineRequirements`, and the two readers in `locate.ts` and
|
|
24
|
+
* `static-registry.ts` that report it without throwing — and their messages
|
|
25
|
+
* have to be identical rather than merely similar. Sharing this function is
|
|
26
|
+
* what makes that structural rather than a coincidence maintained by hand, so
|
|
27
|
+
* a new site formats through here and does not grow a fourth spelling.
|
|
28
|
+
*/
|
|
29
|
+
export declare function registryValidationMessage(issues: readonly RegistryValidationIssue[]): string;
|
|
30
|
+
//# sourceMappingURL=registry-issues.d.ts.map
|
|
@@ -0,0 +1,26 @@
|
|
|
1
|
+
// The shape of a registry schema failure, and the one rendering of it
|
|
2
|
+
// (design §5.1, §10).
|
|
3
|
+
//
|
|
4
|
+
// Separate from registry.ts because `./define` maps to that whole module, so
|
|
5
|
+
// everything it exports is a public contract. What has to be public here is the
|
|
6
|
+
// element type of `RegistryValidationError.issues`; the formatter does not, and
|
|
7
|
+
// both of its callers are internal.
|
|
8
|
+
/**
|
|
9
|
+
* The message `RegistryValidationError` carries, available without one.
|
|
10
|
+
*
|
|
11
|
+
* Three sites report `registry-invalid` — the throwing path in
|
|
12
|
+
* `defineRequirements`, and the two readers in `locate.ts` and
|
|
13
|
+
* `static-registry.ts` that report it without throwing — and their messages
|
|
14
|
+
* have to be identical rather than merely similar. Sharing this function is
|
|
15
|
+
* what makes that structural rather than a coincidence maintained by hand, so
|
|
16
|
+
* a new site formats through here and does not grow a fourth spelling.
|
|
17
|
+
*/
|
|
18
|
+
export function registryValidationMessage(issues) {
|
|
19
|
+
// `.map(String)` and not a bare `.join('.')`: under `PropertyKey[]` a segment
|
|
20
|
+
// can be a symbol, and `join` throws rather than converting one.
|
|
21
|
+
const fields = issues
|
|
22
|
+
.map((i) => ` - ${i.path.map(String).join('.') || '(root)'}: ${i.message}`)
|
|
23
|
+
.join('\n');
|
|
24
|
+
return `Invalid requirement registry:\n${fields}`;
|
|
25
|
+
}
|
|
26
|
+
//# sourceMappingURL=registry-issues.js.map
|
package/dist/core/registry.d.ts
CHANGED
|
@@ -1,10 +1,18 @@
|
|
|
1
|
-
import { z } from 'zod';
|
|
2
1
|
import { type RegistryInput, type RequirementInput } from './schema.js';
|
|
2
|
+
import { type RegistryValidationIssue } from './registry-issues.js';
|
|
3
3
|
import type { Registry, Requirement } from './types.js';
|
|
4
|
-
|
|
4
|
+
export type { RegistryValidationIssue } from './registry-issues.js';
|
|
5
|
+
/**
|
|
6
|
+
* Thrown when a registry fails structural validation (design §5.1).
|
|
7
|
+
*
|
|
8
|
+
* `issues` is typed as this project's own {@link RegistryValidationIssue} and
|
|
9
|
+
* not the validator's, because this class is reached through the `./define`
|
|
10
|
+
* subpath and the field is therefore a public contract — the same boundary
|
|
11
|
+
* `Issue.message` holds, with the difference that `Issue` was designed with it.
|
|
12
|
+
*/
|
|
5
13
|
export declare class RegistryValidationError extends Error {
|
|
6
|
-
readonly issues:
|
|
7
|
-
constructor(issues:
|
|
14
|
+
readonly issues: RegistryValidationIssue[];
|
|
15
|
+
constructor(issues: RegistryValidationIssue[]);
|
|
8
16
|
}
|
|
9
17
|
/**
|
|
10
18
|
* A `const` type parameter infers an array literal as a tuple, and would infer
|
|
@@ -139,5 +147,4 @@ export declare function delta<const T extends RegistryDelta>(d: T): DefinedDelta
|
|
|
139
147
|
* caller wrote is still the literal they hold.
|
|
140
148
|
*/
|
|
141
149
|
export declare function withProposedRequirements<T extends RegistryDelta>(d: T): T;
|
|
142
|
-
export {};
|
|
143
150
|
//# sourceMappingURL=registry.d.ts.map
|
package/dist/core/registry.js
CHANGED
|
@@ -1,22 +1,24 @@
|
|
|
1
1
|
// Requirement registry authoring API (design §2, §5.1, §7).
|
|
2
2
|
// defineRequirements validates against RegistrySchema and applies defaults;
|
|
3
3
|
// delta() declares a change delta, applied by applyDelta (see apply.ts).
|
|
4
|
-
import { z } from 'zod';
|
|
5
4
|
import { RegistrySchema, } from './schema.js';
|
|
6
|
-
|
|
5
|
+
import { registryValidationMessage, } from './registry-issues.js';
|
|
6
|
+
/**
|
|
7
|
+
* Thrown when a registry fails structural validation (design §5.1).
|
|
8
|
+
*
|
|
9
|
+
* `issues` is typed as this project's own {@link RegistryValidationIssue} and
|
|
10
|
+
* not the validator's, because this class is reached through the `./define`
|
|
11
|
+
* subpath and the field is therefore a public contract — the same boundary
|
|
12
|
+
* `Issue.message` holds, with the difference that `Issue` was designed with it.
|
|
13
|
+
*/
|
|
7
14
|
export class RegistryValidationError extends Error {
|
|
8
15
|
issues;
|
|
9
16
|
constructor(issues) {
|
|
10
|
-
super(
|
|
17
|
+
super(registryValidationMessage(issues));
|
|
11
18
|
this.issues = issues;
|
|
12
19
|
this.name = 'RegistryValidationError';
|
|
13
20
|
}
|
|
14
21
|
}
|
|
15
|
-
function formatIssues(issues) {
|
|
16
|
-
return issues
|
|
17
|
-
.map((i) => ` - ${i.path.join('.') || '(root)'}: ${i.message}`)
|
|
18
|
-
.join('\n');
|
|
19
|
-
}
|
|
20
22
|
/**
|
|
21
23
|
* Declare a requirement registry. Validates structure against RegistrySchema
|
|
22
24
|
* (SHALL/MUST keyword, non-empty rationale, well-formed IDs, scalar params) and
|
package/dist/core/runner.js
CHANGED
|
@@ -70,12 +70,19 @@ export async function runAndCollect(options = {}) {
|
|
|
70
70
|
const runtimeCoverage = new Map();
|
|
71
71
|
const outcomes = new Map();
|
|
72
72
|
// Reconstruct coverage from the task tree (not an in-process singleton).
|
|
73
|
-
|
|
73
|
+
//
|
|
74
|
+
// `file` is threaded down from the file task rather than read off the suite,
|
|
75
|
+
// because it is the only level that carries a path — and it is what keeps
|
|
76
|
+
// two spec files declaring the same scenario name under one `requirement()`
|
|
77
|
+
// from collapsing into a single entry that either of them could satisfy.
|
|
78
|
+
const walk = (task, file) => {
|
|
74
79
|
if (task.type === 'suite') {
|
|
75
80
|
const id = requirementIdOf(task.name);
|
|
76
81
|
if (id !== undefined) {
|
|
77
|
-
const
|
|
78
|
-
const
|
|
82
|
+
const byFile = runtimeCoverage.get(id) ?? new Map();
|
|
83
|
+
const outcomesByFile = outcomes.get(id) ?? new Map();
|
|
84
|
+
const set = byFile.get(file) ?? new Set();
|
|
85
|
+
const byName = outcomesByFile.get(file) ?? new Map();
|
|
79
86
|
for (const c of scenariosUnder(task)) {
|
|
80
87
|
// A scenario counts as covered only if it actually executed —
|
|
81
88
|
// skipped/todo scenarios have no run result (enables §8's
|
|
@@ -87,12 +94,14 @@ export async function runAndCollect(options = {}) {
|
|
|
87
94
|
set.add(c.name);
|
|
88
95
|
byName.set(c.name, outcome);
|
|
89
96
|
}
|
|
90
|
-
|
|
91
|
-
|
|
97
|
+
byFile.set(file, set);
|
|
98
|
+
outcomesByFile.set(file, byName);
|
|
99
|
+
runtimeCoverage.set(id, byFile);
|
|
100
|
+
outcomes.set(id, outcomesByFile);
|
|
92
101
|
}
|
|
93
102
|
}
|
|
94
103
|
for (const c of task.tasks ?? [])
|
|
95
|
-
walk(c);
|
|
104
|
+
walk(c, file);
|
|
96
105
|
};
|
|
97
106
|
// Assigned, never cast. `TaskLike` is the narrow view this walk needs, and
|
|
98
107
|
// Vitest's `File` satisfies it structurally — so the compiler is the thing
|
|
@@ -101,12 +110,15 @@ export async function runAndCollect(options = {}) {
|
|
|
101
110
|
// exactly the change that would silently empty the runtime coverage.
|
|
102
111
|
const unloadedFiles = [];
|
|
103
112
|
for (const file of vitest.state.getFiles()) {
|
|
104
|
-
walk(file);
|
|
105
113
|
// Relative and POSIX for the reason everything derived from the root is
|
|
106
114
|
// (`paths.ts`): this becomes an `Issue.file`, which a `--json` consumer
|
|
107
|
-
// diffs across two CI runs that may not share an operating system
|
|
115
|
+
// diffs across two CI runs that may not share an operating system — and,
|
|
116
|
+
// since the coverage maps are keyed by it, a comparison key against a plan
|
|
117
|
+
// built the same way.
|
|
118
|
+
const relative = relativePath(options.root ?? process.cwd(), file.filepath);
|
|
119
|
+
walk(file, relative);
|
|
108
120
|
if (failedToLoad(file)) {
|
|
109
|
-
unloadedFiles.push(
|
|
121
|
+
unloadedFiles.push(relative);
|
|
110
122
|
}
|
|
111
123
|
}
|
|
112
124
|
unloadedFiles.sort(byCodeUnit);
|