@am_shork/attest 0.6.0 → 0.7.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 +861 -37
- package/README.md +1 -1
- package/dist/cli/index.js +2 -18
- package/dist/cli/json.js +6 -1
- package/dist/cli/report.d.ts +18 -0
- package/dist/cli/report.js +41 -0
- package/dist/core/docs.d.ts +1 -1
- package/dist/core/docs.js +1 -0
- package/dist/core/gate.d.ts +48 -2
- package/dist/core/gate.js +73 -14
- package/dist/core/loader.js +13 -0
- package/dist/core/locate.d.ts +25 -0
- package/dist/core/locate.js +37 -6
- package/dist/core/merge.js +21 -8
- package/dist/core/pipeline.js +143 -31
- package/dist/core/render.js +129 -14
- package/dist/core/req-suite.d.ts +5 -0
- package/dist/core/req-suite.js +27 -0
- package/dist/core/runner.js +26 -8
- package/dist/core/skill.js +6 -2
- package/dist/core/status.js +21 -3
- package/dist/core/terminal.d.ts +23 -0
- package/dist/core/terminal.js +25 -9
- package/dist/core/validator.d.ts +5 -1
- package/dist/core/validator.js +23 -2
- package/dist/runtime.d.ts +20 -0
- package/dist/runtime.js +43 -15
- package/package.json +1 -1
package/dist/core/pipeline.js
CHANGED
|
@@ -1,14 +1,12 @@
|
|
|
1
1
|
// High-level operations composing the core layers (design §9). These are
|
|
2
2
|
// tool-agnostic; the CLI is a thin shell that calls them and renders the result.
|
|
3
|
-
import { createLoader } from './loader.js';
|
|
4
3
|
import { evalReader, findChangeDirSpecs, loadRegistry, listChangeNames, parseSpecs, scanProject, staticReader, } from './locate.js';
|
|
5
4
|
import { validateStructure, detectPotentialDrift, uncoveredIssues } from './validator.js';
|
|
6
5
|
import { byCodeUnit } from './order.js';
|
|
7
|
-
import { runAndCollect, BASE_EXCLUDE } from './runner.js';
|
|
8
6
|
import { applyDelta, addedIds, claimedIds } from './apply.js';
|
|
9
7
|
import { readDeltaSource } from './static-registry.js';
|
|
10
8
|
import { statusRows, statusCounts } from './status.js';
|
|
11
|
-
import { evaluateGate,
|
|
9
|
+
import { evaluateGate, notRunIssues } from './gate.js';
|
|
12
10
|
import { renderMarkdown, staleIssue } from './render.js';
|
|
13
11
|
import { mergeRedRecord, readRedRecord, redRecordPath, serialiseRedRecord, } from './red-record.js';
|
|
14
12
|
import { DEFAULT_TARGET, resolveTargets } from './targets.js';
|
|
@@ -19,13 +17,52 @@ import { mkdir, readFile } from 'node:fs/promises';
|
|
|
19
17
|
import { basename, dirname, join } from 'node:path';
|
|
20
18
|
import { relativePath } from './paths.js';
|
|
21
19
|
import { hasError } from './types.js';
|
|
20
|
+
// The runner half of the engine, reached only when a command actually needs it.
|
|
21
|
+
//
|
|
22
|
+
// These two are deliberately not top-level imports. `cli/index.ts` imports this
|
|
23
|
+
// module whole, so a static import here resolves and evaluates `vite`,
|
|
24
|
+
// `vitest/node` and `vitest/config` before `commander` has parsed an argument —
|
|
25
|
+
// measured at ~900 ms, paid by `check`, `cover`, `render`, `status` without
|
|
26
|
+
// `--eval` and `init`, none of which can start a run. §11's cost model is a
|
|
27
|
+
// short-lived process where cold start is the whole bill, so a fixed toll on
|
|
28
|
+
// every invocation outweighs anything scaling with requirement count.
|
|
29
|
+
//
|
|
30
|
+
// This is *not* the core/adapter package split §11 defers, and does not
|
|
31
|
+
// substitute for it: `pipeline.ts` still holds both halves, so the reachability
|
|
32
|
+
// `tests/import-boundary.spec.ts` cannot assert is still unassertable. What
|
|
33
|
+
// moves is when the cost is paid. The specifiers stay relative and the gate
|
|
34
|
+
// reads `import(...)` from the AST alongside static imports, so the boundary is
|
|
35
|
+
// checked exactly as before — that is the one property this change could have
|
|
36
|
+
// silently dropped, and the reason that test looks for dynamic imports at all.
|
|
37
|
+
/** {@link import('./loader.js').createLoader}, resolved on first use. */
|
|
38
|
+
async function createLoader() {
|
|
39
|
+
const { createLoader: create } = await import('./loader.js');
|
|
40
|
+
return create();
|
|
41
|
+
}
|
|
42
|
+
/**
|
|
43
|
+
* {@link import('./runner.js').runAndCollect}, resolved on first use.
|
|
44
|
+
*
|
|
45
|
+
* `BASE_EXCLUDE` gets no such wrapper: it has one caller, which is inside the
|
|
46
|
+
* function that starts the run it belongs to, so it is destructured there
|
|
47
|
+
* alongside nothing.
|
|
48
|
+
*/
|
|
49
|
+
async function runAndCollect(options = {}) {
|
|
50
|
+
const { runAndCollect: run } = await import('./runner.js');
|
|
51
|
+
return run(options);
|
|
52
|
+
}
|
|
22
53
|
/**
|
|
23
54
|
* Read every registry under root with the reader `options` asks for, and close
|
|
24
55
|
* whatever that reader needed. Static reading starts no Vite server at all.
|
|
56
|
+
*
|
|
57
|
+
* `borrowed` is a loader the caller is already holding open for this command.
|
|
58
|
+
* See `withLoader` for why a command with more than one thing to evaluate owns
|
|
59
|
+
* one rather than letting each read start its own.
|
|
25
60
|
*/
|
|
26
|
-
async function readRegistry(root, options, files) {
|
|
61
|
+
async function readRegistry(root, options, files, borrowed) {
|
|
27
62
|
if (!options.evaluate)
|
|
28
63
|
return loadRegistry(root, staticReader(), files);
|
|
64
|
+
if (borrowed)
|
|
65
|
+
return loadRegistry(root, evalReader(borrowed), files);
|
|
29
66
|
const loader = await createLoader();
|
|
30
67
|
try {
|
|
31
68
|
return await loadRegistry(root, evalReader(loader), files);
|
|
@@ -34,22 +71,59 @@ async function readRegistry(root, options, files) {
|
|
|
34
71
|
await loader.close();
|
|
35
72
|
}
|
|
36
73
|
}
|
|
74
|
+
/**
|
|
75
|
+
* Run `fn` with one loader for the whole command, or with none when nothing is
|
|
76
|
+
* to be evaluated.
|
|
77
|
+
*
|
|
78
|
+
* A loader is a Vite dev server: starting one is the expensive thing on this
|
|
79
|
+
* path, and `check --eval` used to start **one per change** — `readDelta`
|
|
80
|
+
* created and closed its own on every iteration of the loop over
|
|
81
|
+
* `listChangeNames`, on top of the one `readRegistry` had already opened and
|
|
82
|
+
* closed for the same command. So the cost was 1 + N servers for a command that
|
|
83
|
+
* needs one. Ownership sits here rather than in either reader because neither
|
|
84
|
+
* of them knows how many times it is about to be called.
|
|
85
|
+
*
|
|
86
|
+
* Static commands pass `undefined` all the way down and start nothing at all,
|
|
87
|
+
* which is the property `check` without `--eval` is documented on (design §5.1)
|
|
88
|
+
* and the reason this returns an optional rather than always creating one.
|
|
89
|
+
*/
|
|
90
|
+
async function withLoader(options, fn) {
|
|
91
|
+
if (!options.evaluate)
|
|
92
|
+
return fn(undefined);
|
|
93
|
+
const loader = await createLoader();
|
|
94
|
+
try {
|
|
95
|
+
return await fn(loader);
|
|
96
|
+
}
|
|
97
|
+
finally {
|
|
98
|
+
await loader.close();
|
|
99
|
+
}
|
|
100
|
+
}
|
|
37
101
|
/** Static structural check (design §9: `attest check`). */
|
|
38
102
|
export async function runCheck(root, options = {}) {
|
|
39
103
|
const unusable = compilerIssue();
|
|
40
104
|
if (unusable)
|
|
41
105
|
return [unusable];
|
|
42
106
|
const scan = await scanProject(root);
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
|
|
107
|
+
// One loader for the command, not one per thing evaluated: this is the only
|
|
108
|
+
// command that reads both the registry and every change's delta, so it is the
|
|
109
|
+
// only one where that distinction is worth anything.
|
|
110
|
+
return withLoader(options, async (loader) => {
|
|
111
|
+
const { registry, issues, unreadableFiles } = await readRegistry(root, options, scan.reqsFiles, loader);
|
|
112
|
+
const plan = await parseSpecs(scan.specFiles, root);
|
|
113
|
+
return [
|
|
114
|
+
...issues,
|
|
115
|
+
// `check` keeps reporting on a registry that only half-loaded,
|
|
116
|
+
// deliberately: its contract is breadth, and the findings from the files
|
|
117
|
+
// that *did* load are all still true. What it must not do is advise work
|
|
118
|
+
// that the load failure makes wrong — see `orphan-test` in
|
|
119
|
+
// `validateStructure`.
|
|
120
|
+
...validateStructure(registry, plan, unreadableFiles.length > 0),
|
|
121
|
+
...detectPotentialDrift(registry, plan, plan.paramRefs),
|
|
122
|
+
...(await unclaimedProposedSpecIssues(root, scan, options, loader)),
|
|
123
|
+
...(await changeDirSpecIssues(root)),
|
|
124
|
+
...proposedNameTakenIssues(root, scan),
|
|
125
|
+
];
|
|
126
|
+
});
|
|
53
127
|
}
|
|
54
128
|
/**
|
|
55
129
|
* A proposed spec whose merged name is already taken (design §7).
|
|
@@ -131,13 +205,15 @@ async function changeDirSpecIssues(root) {
|
|
|
131
205
|
* — a change delta is intent, and the commands that only report read it from
|
|
132
206
|
* source (design §5.1).
|
|
133
207
|
*/
|
|
134
|
-
async function unclaimedProposedSpecIssues(root, scan, options) {
|
|
208
|
+
async function unclaimedProposedSpecIssues(root, scan, options, loader) {
|
|
135
209
|
if (scan.proposedSpecFiles.length === 0)
|
|
136
210
|
return [];
|
|
137
211
|
const claimed = new Set();
|
|
138
212
|
const issues = [];
|
|
213
|
+
// The loop `loader` exists for: under `--eval` this used to start and stop a
|
|
214
|
+
// Vite dev server per change.
|
|
139
215
|
for (const name of await listChangeNames(root)) {
|
|
140
|
-
const read = await readDelta(root, name, options);
|
|
216
|
+
const read = await readDelta(root, name, options, loader);
|
|
141
217
|
if ('issue' in read)
|
|
142
218
|
issues.push(read.issue);
|
|
143
219
|
else
|
|
@@ -204,17 +280,21 @@ export async function runVerify(root, options = {}) {
|
|
|
204
280
|
const loader = await createLoader();
|
|
205
281
|
let registry;
|
|
206
282
|
let plan;
|
|
283
|
+
let unreadableFiles;
|
|
207
284
|
const issues = [];
|
|
208
285
|
try {
|
|
209
286
|
const loaded = await loadRegistry(root, evalReader(loader), scan.reqsFiles);
|
|
210
287
|
registry = loaded.registry;
|
|
211
288
|
issues.push(...loaded.issues);
|
|
289
|
+
unreadableFiles = loaded.unreadableFiles;
|
|
212
290
|
plan = await parseSpecs(scan.specFiles, root);
|
|
213
291
|
}
|
|
214
292
|
finally {
|
|
215
293
|
await loader.close();
|
|
216
294
|
}
|
|
217
|
-
|
|
295
|
+
// Same as `check`: everything the loaded half supports is still reported, and
|
|
296
|
+
// only the advice that a load failure would make wrong is withdrawn.
|
|
297
|
+
issues.push(...validateStructure(registry, plan, unreadableFiles.length > 0));
|
|
218
298
|
issues.push(...detectPotentialDrift(registry, plan, plan.paramRefs));
|
|
219
299
|
const attesting = attestingFiles(plan);
|
|
220
300
|
const counts = {
|
|
@@ -257,7 +337,13 @@ export async function runVerify(root, options = {}) {
|
|
|
257
337
|
if (!run.passed) {
|
|
258
338
|
issues.push({ level: 'ERROR', code: 'tests-red', message: 'Some tests are failing.' });
|
|
259
339
|
}
|
|
260
|
-
|
|
340
|
+
// The load failures first, then the absences they caused — by the same
|
|
341
|
+
// function the gate calls, which is the point. `verify` used to ask only for
|
|
342
|
+
// the absences, so an ordinary broken import surfaced as a `declared-not-run`
|
|
343
|
+
// per scenario blaming a skip, with nothing anywhere naming the file that did
|
|
344
|
+
// not load. No more specific diagnosis to pass: `added-id-unmerged` is a
|
|
345
|
+
// statement about a change's delta, and `verify` has no change.
|
|
346
|
+
issues.push(...notRunIssues(plan, run));
|
|
261
347
|
return { issues, passed: run.passed, ok: run.passed && !hasError(issues), counts };
|
|
262
348
|
}
|
|
263
349
|
/**
|
|
@@ -284,8 +370,12 @@ export async function runCover(root, options = {}) {
|
|
|
284
370
|
for (const s of plan.scenarios) {
|
|
285
371
|
counts.set(s.reqId, (counts.get(s.reqId) ?? 0) + 1);
|
|
286
372
|
}
|
|
373
|
+
// `byCodeUnit`, not a bare `.sort()`. The two give an identical result on ids
|
|
374
|
+
// that match the grammar, and that is exactly why this is worth spelling:
|
|
375
|
+
// `order.ts` exists so ordering has one spelling across the engine, which is
|
|
376
|
+
// a property that survives an edit — an identical result is not.
|
|
287
377
|
const rows = Object.keys(registry)
|
|
288
|
-
.sort()
|
|
378
|
+
.sort(byCodeUnit)
|
|
289
379
|
.map((reqId) => ({
|
|
290
380
|
reqId,
|
|
291
381
|
covered: (counts.get(reqId) ?? 0) > 0,
|
|
@@ -426,10 +516,14 @@ function changeNotFoundIssue(root, deltaPath, changeName, err) {
|
|
|
426
516
|
* under review. `archive` evaluates unconditionally because it runs the suite
|
|
427
517
|
* anyway, so declining to evaluate one more module would buy it nothing.
|
|
428
518
|
*/
|
|
429
|
-
async function readDelta(root, changeName, options) {
|
|
519
|
+
async function readDelta(root, changeName, options, borrowed) {
|
|
430
520
|
const deltaPath = changeDeltaPath(root, changeName);
|
|
431
521
|
if (options.evaluate) {
|
|
432
|
-
|
|
522
|
+
// Borrow the caller's loader when it has one, own one otherwise. The
|
|
523
|
+
// difference matters only to the caller that reads several deltas — see
|
|
524
|
+
// `withLoader` — and a lone reader should not have to build scaffolding to
|
|
525
|
+
// ask for one delta.
|
|
526
|
+
const loader = borrowed ?? (await createLoader());
|
|
433
527
|
try {
|
|
434
528
|
const mod = await loader.load(deltaPath);
|
|
435
529
|
if (!mod.default || typeof mod.default !== 'object') {
|
|
@@ -443,7 +537,10 @@ async function readDelta(root, changeName, options) {
|
|
|
443
537
|
return { issue: changeNotFoundIssue(root, deltaPath, changeName, err) };
|
|
444
538
|
}
|
|
445
539
|
finally {
|
|
446
|
-
|
|
540
|
+
// Only what this call created. Closing a borrowed loader would take it
|
|
541
|
+
// out from under the caller's next iteration.
|
|
542
|
+
if (!borrowed)
|
|
543
|
+
await loader.close();
|
|
447
544
|
}
|
|
448
545
|
}
|
|
449
546
|
let source;
|
|
@@ -512,10 +609,20 @@ function claimedByDelta(proposed, delta) {
|
|
|
512
609
|
async function changeMergedPlan(root, delta, scan) {
|
|
513
610
|
const basePlan = await parseSpecs(scan.specFiles, root);
|
|
514
611
|
const proposed = await parseSpecs(scan.proposedSpecFiles, root);
|
|
515
|
-
const
|
|
612
|
+
const claimed = claimedByDelta(proposed, delta);
|
|
516
613
|
return {
|
|
517
|
-
|
|
518
|
-
|
|
614
|
+
merged: {
|
|
615
|
+
scenarios: [...basePlan.scenarios, ...claimed.scenarios],
|
|
616
|
+
paramRefs: [...basePlan.paramRefs, ...claimed.paramRefs],
|
|
617
|
+
},
|
|
618
|
+
// Returned rather than recomputed by the caller that needs it. `--apply`
|
|
619
|
+
// renames exactly the proposed specs this change claims, and deriving that
|
|
620
|
+
// set a second time — a second `parseSpecs` and a second `claimedByDelta`
|
|
621
|
+
// over the same files — is agreement by transcription: the two answers
|
|
622
|
+
// match because the same two functions ran twice, which is the thing this
|
|
623
|
+
// codebase extracts `declaredNotRunIssues` and `uncoveredIssues` to stop
|
|
624
|
+
// relying on. One parse, one split, one answer.
|
|
625
|
+
claimed,
|
|
519
626
|
};
|
|
520
627
|
}
|
|
521
628
|
/** Per-change progress (design §9: `attest status <change>`). */
|
|
@@ -537,7 +644,7 @@ export async function runStatus(root, changeName, options = {}) {
|
|
|
537
644
|
if ('issue' in read)
|
|
538
645
|
return nothing([read.issue]);
|
|
539
646
|
const scan = await scanProject(root);
|
|
540
|
-
const plan = await changeMergedPlan(root, read.delta, scan);
|
|
647
|
+
const { merged: plan } = await changeMergedPlan(root, read.delta, scan);
|
|
541
648
|
const firstRun = await readRedRecord(root, changeName);
|
|
542
649
|
const rows = statusRows(addedIds(read.delta), plan, firstRun);
|
|
543
650
|
return { change: changeName, rows, counts: statusCounts(rows), issues: [] };
|
|
@@ -557,7 +664,10 @@ export async function runArchive(root, changeName, options = {}) {
|
|
|
557
664
|
*/
|
|
558
665
|
export async function runArchiveApply(root, changeName, options = {}) {
|
|
559
666
|
const { issues, merge } = await archiveRun(root, changeName, options);
|
|
560
|
-
|
|
667
|
+
// One predicate for the verdict, everywhere (ATX-60): the gate's WARNINGs
|
|
668
|
+
// survive `--apply` by design, so `some(level === 'ERROR')` spelled here by
|
|
669
|
+
// hand was a second definition of failure sitting next to the one that counts.
|
|
670
|
+
if (hasError(issues) || !merge)
|
|
561
671
|
return { issues, written: [] };
|
|
562
672
|
const result = await applyMerge({ root, changeName, ...merge });
|
|
563
673
|
// The gate's non-blocking output is kept: a WARNING the gate raised is still
|
|
@@ -589,7 +699,7 @@ async function archiveRun(root, changeName, options = {}) {
|
|
|
589
699
|
const loader = await createLoader();
|
|
590
700
|
try {
|
|
591
701
|
const { registry: base, issues, prefixOwners } = await loadRegistry(root, evalReader(loader), scan.reqsFiles);
|
|
592
|
-
if (issues
|
|
702
|
+
if (hasError(issues))
|
|
593
703
|
return { issues };
|
|
594
704
|
const deltaPath = changeDeltaPath(root, changeName);
|
|
595
705
|
let delta;
|
|
@@ -615,13 +725,14 @@ async function archiveRun(root, changeName, options = {}) {
|
|
|
615
725
|
// Static plan = merged base suite + this change's specs (design §8), by the
|
|
616
726
|
// same function `status` reports against — a progress report computed over a
|
|
617
727
|
// different spec set than the gate uses would be a report about nothing.
|
|
618
|
-
const plan = await changeMergedPlan(root, delta, scan);
|
|
728
|
+
const { merged: plan, claimed } = await changeMergedPlan(root, delta, scan);
|
|
619
729
|
// Other proposals need no exclude glob of their own: the include list below
|
|
620
730
|
// is the plan's own files, and the plan holds only the proposed specs this
|
|
621
731
|
// delta claims. That is what replaced `**/changes/<sibling>/**` — with the
|
|
622
732
|
// specs no longer living under `changes/`, a directory glob could not have
|
|
623
733
|
// told two proposals apart, and one kept alongside the claim check would be
|
|
624
734
|
// a second scoping rule able to disagree with it.
|
|
735
|
+
const { BASE_EXCLUDE } = await import('./runner.js');
|
|
625
736
|
const exclude = [...BASE_EXCLUDE, '**/archive/**'];
|
|
626
737
|
// Same run scope as `verify`: only the files that declare a requirement().
|
|
627
738
|
// The gate must not go red because a repo's incumbent suite happens to sit
|
|
@@ -668,8 +779,9 @@ async function archiveRun(root, changeName, options = {}) {
|
|
|
668
779
|
prefixOwners,
|
|
669
780
|
// The plan's proposed files, not every proposed file on disk: `--apply`
|
|
670
781
|
// renames what *this* change claims, and a sibling proposal's spec is
|
|
671
|
-
// not this change's to move.
|
|
672
|
-
|
|
782
|
+
// not this change's to move. `claimed` is the half of the plan the gate
|
|
783
|
+
// above ran, handed back by the function that built it.
|
|
784
|
+
claimedSpecs: claimedSpecsOf(root, claimed),
|
|
673
785
|
mergedSpecs: scan.specFiles,
|
|
674
786
|
},
|
|
675
787
|
};
|
package/dist/core/render.js
CHANGED
|
@@ -6,7 +6,7 @@
|
|
|
6
6
|
// back, so the direction OpenSpec's pure-Markdown model went (Markdown as
|
|
7
7
|
// truth, and the free-form drift that comes with it) stays closed.
|
|
8
8
|
//
|
|
9
|
-
//
|
|
9
|
+
// Four properties this file must keep:
|
|
10
10
|
// - **Intent only.** The document says what the system promises, never what is
|
|
11
11
|
// proven or green: coverage and results are verdicts, and verdicts belong to
|
|
12
12
|
// `cover` / `verify`, which recompute them on demand. Putting them here also
|
|
@@ -20,7 +20,13 @@
|
|
|
20
20
|
// - **Params interpolated into the statement.** The source says
|
|
21
21
|
// "{idleTimeoutMin} minutes"; a human reader wants "30 minutes". This is the
|
|
22
22
|
// one thing the projection gives that reading the source does not.
|
|
23
|
+
// - **None of the registry's control characters survive into it.** The
|
|
24
|
+
// document is quoted prose from a repository that may not be the reader's,
|
|
25
|
+
// and it is a *file* — committed, served, and read again long after the run
|
|
26
|
+
// that wrote it. See `sanitised` for why the defence sits here rather than
|
|
27
|
+
// at the terminal write.
|
|
23
28
|
import { byCodeUnit } from './order.js';
|
|
29
|
+
import { control } from './terminal.js';
|
|
24
30
|
const BANNER = '<!-- Generated by `attest render` — do not edit. Edit the `*.reqs.ts` registry and regenerate. -->';
|
|
25
31
|
/**
|
|
26
32
|
* Render the registry as a standalone Markdown document.
|
|
@@ -31,18 +37,65 @@ const BANNER = '<!-- Generated by `attest render` — do not edit. Edit the `*.r
|
|
|
31
37
|
* worse than no gate.
|
|
32
38
|
*/
|
|
33
39
|
export function renderMarkdown(registry) {
|
|
34
|
-
const
|
|
40
|
+
const clean = sanitised(registry);
|
|
41
|
+
const ids = Object.keys(clean).sort(compareIds);
|
|
35
42
|
const out = [BANNER, '', '# Requirements', ''];
|
|
36
43
|
if (ids.length === 0) {
|
|
37
44
|
out.push('_No requirements are defined yet._', '');
|
|
38
45
|
return out.join('\n');
|
|
39
46
|
}
|
|
40
|
-
out.push(...overviewTable(
|
|
47
|
+
out.push(...overviewTable(clean, ids), '');
|
|
41
48
|
for (const id of ids) {
|
|
42
|
-
out.push(...section(id,
|
|
49
|
+
out.push(...section(id, clean[id]), '');
|
|
43
50
|
}
|
|
44
51
|
return out.join('\n');
|
|
45
52
|
}
|
|
53
|
+
/**
|
|
54
|
+
* The registry with every string its author controls stripped of control
|
|
55
|
+
* characters (ATX-58).
|
|
56
|
+
*
|
|
57
|
+
* **At the entry rather than at each emitter**, which is the whole of why this
|
|
58
|
+
* defect existed. ATX-37 put every byte the *CLI* prints through `control`, and
|
|
59
|
+
* this document is built by concatenation that never went past it — so a
|
|
60
|
+
* statement carrying `ESC [2K CR` erased the reviewer's line and repainted a
|
|
61
|
+
* verdict, from `attest render` with no flag at all. Sanitising here means a
|
|
62
|
+
* field added to `Requirement` later is covered by having been added, instead of
|
|
63
|
+
* by someone remembering; four call sites each doing it is the arrangement that
|
|
64
|
+
* produced the gap in the first place.
|
|
65
|
+
*
|
|
66
|
+
* **Over the document, not over stdout.** The obvious fix — sanitise the
|
|
67
|
+
* terminal write — is wrong, and the loop that found this said so: `--out`
|
|
68
|
+
* carried the payload into the file too. That file is committed, served, and
|
|
69
|
+
* read later by `cat`, by `less -R`, or by a site generator, so the artifact
|
|
70
|
+
* outlives the run and the run is the wrong place to defend. It also keeps
|
|
71
|
+
* `--check` honest, since both sides of the comparison are built from here.
|
|
72
|
+
*
|
|
73
|
+
* Ids are not sanitised and need not be: `RegistrySchema` holds every key to
|
|
74
|
+
* `^[A-Z]+-\d+$` on **both** reader paths — the static one by construction, the
|
|
75
|
+
* evaluating one since ATX-38 — so no id can carry a control character to begin
|
|
76
|
+
* with. The container is still built without a prototype, for the reason
|
|
77
|
+
* `red-record.ts` builds its own that way: that grammar is held somewhere else,
|
|
78
|
+
* and a defence that reads an inherited key when the other one lapses is not a
|
|
79
|
+
* defence. `Object.entries` and the sort below see own keys either way.
|
|
80
|
+
*/
|
|
81
|
+
function sanitised(registry) {
|
|
82
|
+
const out = Object.create(null);
|
|
83
|
+
for (const [id, req] of Object.entries(registry)) {
|
|
84
|
+
out[id] = {
|
|
85
|
+
statement: control(req.statement),
|
|
86
|
+
rationale: control(req.rationale),
|
|
87
|
+
params: Object.fromEntries(Object.entries(req.params).map(([k, v]) => [control(k), sanitisedValue(v)])),
|
|
88
|
+
outOfScope: req.outOfScope.map(control),
|
|
89
|
+
};
|
|
90
|
+
}
|
|
91
|
+
return out;
|
|
92
|
+
}
|
|
93
|
+
/** A param value with its strings sanitised; numbers and booleans have none. */
|
|
94
|
+
function sanitisedValue(value) {
|
|
95
|
+
if (Array.isArray(value))
|
|
96
|
+
return value.map((v) => (typeof v === 'string' ? control(v) : v));
|
|
97
|
+
return typeof value === 'string' ? control(value) : value;
|
|
98
|
+
}
|
|
46
99
|
/**
|
|
47
100
|
* Line endings are a checkout artifact, not content.
|
|
48
101
|
*
|
|
@@ -85,23 +138,56 @@ export function staleIssue(target, current, fresh) {
|
|
|
85
138
|
message: `${why} Regenerate it with: ${target.command}`,
|
|
86
139
|
};
|
|
87
140
|
}
|
|
141
|
+
/**
|
|
142
|
+
* `AUTH-3` as its two ordering keys. An id this reader cannot take apart is its
|
|
143
|
+
* own prefix with no number, which is the reading `locate.ts`'s `idPrefix`
|
|
144
|
+
* gives — the two used to disagree, and one of them was wrong.
|
|
145
|
+
*
|
|
146
|
+
* `lastIndexOf('-')` with no guard was the old spelling, and on an id with no
|
|
147
|
+
* dash it silently dropped the last character (`AUTH` → prefix `AUT`) and
|
|
148
|
+
* produced `NaN` for the number. A comparator that answers `NaN` is not merely
|
|
149
|
+
* imprecise, it is **non-transitive**: measured on
|
|
150
|
+
* `['AUTH-3','AUTH-abc','AUTH-2']` the result was `AUTH-3, AUTH-abc, AUTH-2`,
|
|
151
|
+
* which is not a sorted permutation of anything. Both cases are handled here so
|
|
152
|
+
* the comparator below is total by construction rather than by the input
|
|
153
|
+
* happening to be well-formed.
|
|
154
|
+
*/
|
|
155
|
+
function orderingKey(id) {
|
|
156
|
+
const at = id.indexOf('-');
|
|
157
|
+
const tail = at === -1 ? '' : id.slice(at + 1);
|
|
158
|
+
if (at === -1 || !/^\d+$/.test(tail))
|
|
159
|
+
return [id, -1];
|
|
160
|
+
return [id.slice(0, at), Number(tail)];
|
|
161
|
+
}
|
|
88
162
|
/**
|
|
89
163
|
* Order ids the way a reader expects: prefix alphabetically, then the number
|
|
90
164
|
* numerically. A plain string sort puts ATX-10 between ATX-1 and ATX-2, which
|
|
91
165
|
* scrambles the document as soon as a registry reaches ten requirements.
|
|
166
|
+
*
|
|
167
|
+
* Total, and a function of the ids alone. `renderMarkdown` feeds `--check`, so
|
|
168
|
+
* an order that depends on anything else — the locale, the insertion order, the
|
|
169
|
+
* engine's tie-breaking — is a freshness gate that can disagree with the run
|
|
170
|
+
* that generated the file it is checking.
|
|
171
|
+
*
|
|
172
|
+
* A malformed id cannot reach here through any command: `RequirementIdSchema`
|
|
173
|
+
* rejects it and `render` returns early on a registry that failed to load. That
|
|
174
|
+
* is why this is robustness rather than a fix — the property being bought is
|
|
175
|
+
* that the function is correct on its own terms instead of correct because
|
|
176
|
+
* something upstream is.
|
|
92
177
|
*/
|
|
93
178
|
function compareIds(a, b) {
|
|
94
|
-
const
|
|
95
|
-
|
|
96
|
-
return [id.slice(0, at), Number(id.slice(at + 1))];
|
|
97
|
-
};
|
|
98
|
-
const [prefixA, numA] = split(a);
|
|
99
|
-
const [prefixB, numB] = split(b);
|
|
179
|
+
const [prefixA, numA] = orderingKey(a);
|
|
180
|
+
const [prefixB, numB] = orderingKey(b);
|
|
100
181
|
// byCodeUnit, not localeCompare: the ordering has to be identical on every
|
|
101
182
|
// machine, or `--check` would flap with the runner's locale.
|
|
102
183
|
if (prefixA !== prefixB)
|
|
103
184
|
return byCodeUnit(prefixA, prefixB);
|
|
104
|
-
|
|
185
|
+
if (numA !== numB)
|
|
186
|
+
return numA - numB;
|
|
187
|
+
// Same prefix and same number is only reachable for ids this reader could not
|
|
188
|
+
// take apart. Falling back to the whole id keeps the order total rather than
|
|
189
|
+
// leaving it to the sort's stability, which is a property of the engine.
|
|
190
|
+
return byCodeUnit(a, b);
|
|
105
191
|
}
|
|
106
192
|
/**
|
|
107
193
|
* An index of the whole registry: id + the promise itself. Deliberately carries
|
|
@@ -166,14 +252,43 @@ function formatValue(value) {
|
|
|
166
252
|
* at either end needs padding spaces.
|
|
167
253
|
*/
|
|
168
254
|
function code(value) {
|
|
169
|
-
|
|
255
|
+
// Accumulated, never `Math.max(0, ...runs)` (ATX-59). The spread puts one
|
|
256
|
+
// argument on the stack per backtick run, so a param holding a few hundred
|
|
257
|
+
// thousand of them exhausted it — `RangeError` out of `attest render` under
|
|
258
|
+
// the static reader, with the stack naming this function. Not backtracking,
|
|
259
|
+
// and found only by sweeping for more of it: same reachable path, same
|
|
260
|
+
// registry-chooses-the-cost shape, different mechanism.
|
|
261
|
+
let longest = 0;
|
|
262
|
+
for (const run of value.matchAll(/`+/g))
|
|
263
|
+
longest = Math.max(longest, run[0].length);
|
|
170
264
|
const fence = '`'.repeat(longest + 1);
|
|
171
265
|
const pad = value.startsWith('`') || value.endsWith('`') ? ' ' : '';
|
|
172
266
|
return `${fence}${pad}${value}${pad}${fence}`;
|
|
173
267
|
}
|
|
174
|
-
/**
|
|
268
|
+
/**
|
|
269
|
+
* Make prose safe inside a table cell: no row-breaking pipes, no newlines.
|
|
270
|
+
*
|
|
271
|
+
* Each maximal whitespace run is matched once and inspected, rather than
|
|
272
|
+
* matched by the old starred-`\s`, `\n`, starred-`\s` pattern (ATX-59). That
|
|
273
|
+
* spelling puts a required character after a leading quantifier, so a
|
|
274
|
+
* whitespace run with no newline in it was consumed, failed, and re-tried one
|
|
275
|
+
* character shorter from every position in the run — quadratic, and 8.6 seconds
|
|
276
|
+
* of CPU for 120,000 spaces in one statement, reachable through `render
|
|
277
|
+
* --check` in CI without executing a line of the project.
|
|
278
|
+
*
|
|
279
|
+
* The obvious repair does not work and was measured before this one was
|
|
280
|
+
* written: `[^\S\n]*\n[^\S\n]*`, which stops the class matching the newline,
|
|
281
|
+
* came out *slower*. The backtracking was never about which characters the
|
|
282
|
+
* class held — it was about the quantifier having something after it. `\s+`
|
|
283
|
+
* has nothing after it, so there is no failure to backtrack into, and the
|
|
284
|
+
* decision moves to the callback. Byte-identical to the old pattern: a
|
|
285
|
+
* whitespace run containing a newline collapses to one space, and a run
|
|
286
|
+
* without one is left exactly as it was.
|
|
287
|
+
*/
|
|
175
288
|
function cell(text) {
|
|
176
|
-
return text
|
|
289
|
+
return text
|
|
290
|
+
.replace(/\s+/g, (ws) => (ws.includes('\n') ? ' ' : ws))
|
|
291
|
+
.replace(/\|/g, '\\|');
|
|
177
292
|
}
|
|
178
293
|
/** GitHub/GitLab slug for a `## AUTH-3` heading. */
|
|
179
294
|
function anchor(id) {
|
|
@@ -0,0 +1,5 @@
|
|
|
1
|
+
/** How `requirement(id)` names its describe block. */
|
|
2
|
+
export declare function requirementSuiteName(id: string): string;
|
|
3
|
+
/** The requirement id a suite name carries, or `undefined` if it carries none. */
|
|
4
|
+
export declare function requirementIdOf(suiteName: string): string | undefined;
|
|
5
|
+
//# sourceMappingURL=req-suite.d.ts.map
|
|
@@ -0,0 +1,27 @@
|
|
|
1
|
+
// The one spelling of the suite name that carries a requirement id.
|
|
2
|
+
//
|
|
3
|
+
// `requirement()` writes it, `runner.ts` reads it back out of the serialized
|
|
4
|
+
// task tree, and the runtime's own guard reads it to find the requirement that
|
|
5
|
+
// owns a scenario. That is three readers of one encoding, across a process
|
|
6
|
+
// boundary — the task tree is the only channel that crosses it (design §5.4) —
|
|
7
|
+
// and two spellings of one encoding are two encodings the moment either is
|
|
8
|
+
// edited.
|
|
9
|
+
//
|
|
10
|
+
// Deliberately free of any `vitest` import, so both sides of the engine ↔
|
|
11
|
+
// runner boundary (`tests/import-boundary.spec.ts`) can depend on it.
|
|
12
|
+
/** How `requirement(id)` names its describe block. */
|
|
13
|
+
export function requirementSuiteName(id) {
|
|
14
|
+
return `[${id}]`;
|
|
15
|
+
}
|
|
16
|
+
/**
|
|
17
|
+
* `.+` rather than something narrower: a delta may propose an id the registry's
|
|
18
|
+
* grammar would refuse, and this has to round-trip whatever `requirement()`
|
|
19
|
+
* wrote so the two readers agree about it (the call `readDeltaSource` makes for
|
|
20
|
+
* the same reason).
|
|
21
|
+
*/
|
|
22
|
+
const REQ_SUITE = /^\[(.+)\]$/;
|
|
23
|
+
/** The requirement id a suite name carries, or `undefined` if it carries none. */
|
|
24
|
+
export function requirementIdOf(suiteName) {
|
|
25
|
+
return REQ_SUITE.exec(suiteName)?.[1];
|
|
26
|
+
}
|
|
27
|
+
//# sourceMappingURL=req-suite.js.map
|
package/dist/core/runner.js
CHANGED
|
@@ -5,9 +5,8 @@
|
|
|
5
5
|
import { startVitest } from 'vitest/node';
|
|
6
6
|
import { configDefaults } from 'vitest/config';
|
|
7
7
|
import { relativePath } from './paths.js';
|
|
8
|
+
import { requirementIdOf } from './req-suite.js';
|
|
8
9
|
import { byCodeUnit } from './order.js';
|
|
9
|
-
/** requirement() names each describe block `[reqId]`; recover the id from that. */
|
|
10
|
-
const REQ_SUITE = /^\[(.+)\]$/;
|
|
11
10
|
/**
|
|
12
11
|
* Vitest's own default exclusions, which every Attest run keeps on top of
|
|
13
12
|
* whatever else it excludes.
|
|
@@ -73,18 +72,15 @@ export async function runAndCollect(options = {}) {
|
|
|
73
72
|
// Reconstruct coverage from the task tree (not an in-process singleton).
|
|
74
73
|
const walk = (task) => {
|
|
75
74
|
if (task.type === 'suite') {
|
|
76
|
-
const
|
|
77
|
-
if (
|
|
78
|
-
const id = m[1];
|
|
75
|
+
const id = requirementIdOf(task.name);
|
|
76
|
+
if (id !== undefined) {
|
|
79
77
|
const set = runtimeCoverage.get(id) ?? new Set();
|
|
80
78
|
const byName = outcomes.get(id) ?? new Map();
|
|
81
|
-
for (const c of task
|
|
79
|
+
for (const c of scenariosUnder(task)) {
|
|
82
80
|
// A scenario counts as covered only if it actually executed —
|
|
83
81
|
// skipped/todo scenarios have no run result (enables §8's
|
|
84
82
|
// declared-not-run check). The same test decides whether there is
|
|
85
83
|
// an outcome to record: a skip is not a red (design §6).
|
|
86
|
-
if (c.type !== 'test')
|
|
87
|
-
continue;
|
|
88
84
|
const outcome = executedOutcome(c);
|
|
89
85
|
if (!outcome)
|
|
90
86
|
continue;
|
|
@@ -126,6 +122,28 @@ export async function runAndCollect(options = {}) {
|
|
|
126
122
|
await vitest.close();
|
|
127
123
|
}
|
|
128
124
|
}
|
|
125
|
+
/**
|
|
126
|
+
* Every scenario belonging to a requirement suite, at any depth beneath it.
|
|
127
|
+
*
|
|
128
|
+
* Direct children only was the other half of the nested-`describe` defect: a
|
|
129
|
+
* scenario grouped under one is a `test` inside a `suite` inside `[reqId]`, so
|
|
130
|
+
* even once the runtime stopped throwing it would have been invisible here —
|
|
131
|
+
* `declared-not-run`, for a scenario that ran and passed. The static parser has
|
|
132
|
+
* always recursed (`parser.ts` walks the whole subtree), and this is the seam
|
|
133
|
+
* where the two readers of one plan have to agree.
|
|
134
|
+
*
|
|
135
|
+
* Descent stops at a nested requirement suite, so a `requirement()` written
|
|
136
|
+
* inside another one keeps its own scenarios rather than donating them upward.
|
|
137
|
+
*/
|
|
138
|
+
function* scenariosUnder(suite) {
|
|
139
|
+
for (const child of suite.tasks ?? []) {
|
|
140
|
+
if (child.type === 'test')
|
|
141
|
+
yield child;
|
|
142
|
+
else if (child.type === 'suite' && requirementIdOf(child.name) === undefined) {
|
|
143
|
+
yield* scenariosUnder(child);
|
|
144
|
+
}
|
|
145
|
+
}
|
|
146
|
+
}
|
|
129
147
|
/**
|
|
130
148
|
* Did this file fail before any of its tests could exist?
|
|
131
149
|
*
|
package/dist/core/skill.js
CHANGED
|
@@ -34,6 +34,9 @@
|
|
|
34
34
|
// workflow to an agent that trusts it. The backstop is that every mistake it
|
|
35
35
|
// could cause is a diagnostic with a fix hint — a registry written the old way
|
|
36
36
|
// is `registry-not-static`, and the agent corrects itself from the report.
|
|
37
|
+
// It has happened once, so: read this file when a release adds a diagnostic.
|
|
38
|
+
// Nothing gates that — `ATX-57` catches a code the engine cannot emit, never one
|
|
39
|
+
// it can.
|
|
37
40
|
/**
|
|
38
41
|
* The one sentence that decides whether the workflow is ever loaded.
|
|
39
42
|
*
|
|
@@ -283,9 +286,10 @@ once. Branch on \`issues[].code\`, never on \`message\`:
|
|
|
283
286
|
| \`tests-red\` | a test is failing — the normal state until you are finished |
|
|
284
287
|
| \`never-red\` | an added requirement's scenario has no recorded failing run |
|
|
285
288
|
| \`uncovered-requirement\` | a requirement in the applied registry has no scenario |
|
|
286
|
-
| \`declared-not-run\` | a scenario was declared but never executed (\`skip\` / \`only\`?) |
|
|
289
|
+
| \`declared-not-run\` | a scenario was declared but never executed (\`skip\` / \`only\`?) — withdrawn when one of the two rows below already explains its file, so it never stands in for a load failure |
|
|
290
|
+
| \`spec-load-failed\` | a spec file could not be imported, so nothing in it ran. The run output carries the import error itself; this names which file it stopped |
|
|
287
291
|
| \`proposed-spec-unclaimed\` | a \`*.proposed.spec.ts\` no change's delta claims |
|
|
288
|
-
| \`added-id-unmerged\` |
|
|
292
|
+
| \`added-id-unmerged\` | the same load failure, when this change also adds an id the registry on disk lacks. The specific case, and the only one reported for that file |
|
|
289
293
|
| \`unbound-param\` | a \`{placeholder}\` has no matching \`params\` key |
|
|
290
294
|
| \`registry-not-static\` | a registry file is not a literal the engine can read |
|
|
291
295
|
| \`add-conflict\` | the delta adds an id that already exists with different content |
|