@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.
@@ -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, declaredNotRunIssues } from './gate.js';
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
- const { registry, issues } = await readRegistry(root, options, scan.reqsFiles);
44
- const plan = await parseSpecs(scan.specFiles, root);
45
- return [
46
- ...issues,
47
- ...validateStructure(registry, plan),
48
- ...detectPotentialDrift(registry, plan, plan.paramRefs),
49
- ...(await unclaimedProposedSpecIssues(root, scan, options)),
50
- ...(await changeDirSpecIssues(root)),
51
- ...proposedNameTakenIssues(root, scan),
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
- issues.push(...validateStructure(registry, plan));
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
- issues.push(...declaredNotRunIssues(plan, run));
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
- const loader = await createLoader();
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
- await loader.close();
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 changePlan = claimedByDelta(proposed, delta);
612
+ const claimed = claimedByDelta(proposed, delta);
516
613
  return {
517
- scenarios: [...basePlan.scenarios, ...changePlan.scenarios],
518
- paramRefs: [...basePlan.paramRefs, ...changePlan.paramRefs],
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
- if (issues.some((i) => i.level === 'ERROR') || !merge)
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.some((i) => i.level === 'ERROR'))
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
- claimedSpecs: claimedSpecsOf(root, claimedByDelta(await parseSpecs(scan.proposedSpecFiles, root), delta)),
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
  };
@@ -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
- // Three properties this file must keep:
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 ids = Object.keys(registry).sort(compareIds);
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(registry, ids), '');
47
+ out.push(...overviewTable(clean, ids), '');
41
48
  for (const id of ids) {
42
- out.push(...section(id, registry[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 split = (id) => {
95
- const at = id.lastIndexOf('-');
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
- return numA - numB;
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
- const longest = Math.max(0, ...[...value.matchAll(/`+/g)].map((m) => m[0].length));
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
- /** Make prose safe inside a table cell: no row-breaking pipes, no newlines. */
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.replace(/\s*\n\s*/g, ' ').replace(/\|/g, '\\|');
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
@@ -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 m = REQ_SUITE.exec(task.name);
77
- if (m) {
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.tasks ?? []) {
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
  *
@@ -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\` | a spec failed to load, and this change adds an id the registry on disk lacks |
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 |