@am_shork/attest 0.6.0 → 0.7.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -1,3 +1,20 @@
1
1
  /** Compare by UTF-16 code unit — the same order as a bare `Array#sort()`. */
2
2
  export declare function byCodeUnit(a: string, b: string): number;
3
+ /**
4
+ * A JSON value with every object's keys in code-unit order, at every depth.
5
+ *
6
+ * `JSON.stringify` writes object keys in insertion order, which is *how the
7
+ * source happened to be written* — the thing this module exists to keep out of
8
+ * anything compared or committed. Two callers need the same guarantee for the
9
+ * same reason, one depth apart:
10
+ *
11
+ * - `apply.ts` canonicalises a requirement to decide `add-conflict`, so an
12
+ * identical copy written with its keys in another order must not read as a
13
+ * conflict with itself.
14
+ * - `render.ts` emits a structured param as JSON, and ATX-10 holds the same
15
+ * registry to the same bytes.
16
+ *
17
+ * Arrays keep their order: an array is data whose order is part of the value.
18
+ */
19
+ export declare function sortDeep<T>(value: T): T;
3
20
  //# sourceMappingURL=order.d.ts.map
@@ -10,4 +10,29 @@
10
10
  export function byCodeUnit(a, b) {
11
11
  return a < b ? -1 : a > b ? 1 : 0;
12
12
  }
13
+ /**
14
+ * A JSON value with every object's keys in code-unit order, at every depth.
15
+ *
16
+ * `JSON.stringify` writes object keys in insertion order, which is *how the
17
+ * source happened to be written* — the thing this module exists to keep out of
18
+ * anything compared or committed. Two callers need the same guarantee for the
19
+ * same reason, one depth apart:
20
+ *
21
+ * - `apply.ts` canonicalises a requirement to decide `add-conflict`, so an
22
+ * identical copy written with its keys in another order must not read as a
23
+ * conflict with itself.
24
+ * - `render.ts` emits a structured param as JSON, and ATX-10 holds the same
25
+ * registry to the same bytes.
26
+ *
27
+ * Arrays keep their order: an array is data whose order is part of the value.
28
+ */
29
+ export function sortDeep(value) {
30
+ if (Array.isArray(value))
31
+ return value.map(sortDeep);
32
+ // `typeof null === 'object'`, and a null param is a value like any other.
33
+ if (typeof value !== 'object' || value === null)
34
+ return value;
35
+ const entries = Object.entries(value).sort(([a], [b]) => byCodeUnit(a, b));
36
+ return Object.fromEntries(entries.map(([k, v]) => [k, sortDeep(v)]));
37
+ }
13
38
  //# sourceMappingURL=order.js.map
@@ -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
@@ -569,10 +679,7 @@ export async function runArchiveApply(root, changeName, options = {}) {
569
679
  * The gate, plus what finishing the merge would need.
570
680
  *
571
681
  * One function rather than a gate and a separate `--apply` path, because the
572
- * merge must act on **this** run's verdict. A second traversal could reach a
573
- * different answer than the one just printed — and a command able to file a
574
- * change as done against a stale verdict removes the hard definition of "done"
575
- * that is this tool's whole claim.
682
+ * merge must act on **this** run's verdict (design §8).
576
683
  *
577
684
  * `merge` is absent exactly when there is nothing to act on: a rejected name, an
578
685
  * unreadable delta, or a registry that would not load.
@@ -589,7 +696,7 @@ async function archiveRun(root, changeName, options = {}) {
589
696
  const loader = await createLoader();
590
697
  try {
591
698
  const { registry: base, issues, prefixOwners } = await loadRegistry(root, evalReader(loader), scan.reqsFiles);
592
- if (issues.some((i) => i.level === 'ERROR'))
699
+ if (hasError(issues))
593
700
  return { issues };
594
701
  const deltaPath = changeDeltaPath(root, changeName);
595
702
  let delta;
@@ -615,13 +722,14 @@ async function archiveRun(root, changeName, options = {}) {
615
722
  // Static plan = merged base suite + this change's specs (design §8), by the
616
723
  // same function `status` reports against — a progress report computed over a
617
724
  // different spec set than the gate uses would be a report about nothing.
618
- const plan = await changeMergedPlan(root, delta, scan);
725
+ const { merged: plan, claimed } = await changeMergedPlan(root, delta, scan);
619
726
  // Other proposals need no exclude glob of their own: the include list below
620
727
  // is the plan's own files, and the plan holds only the proposed specs this
621
728
  // delta claims. That is what replaced `**/changes/<sibling>/**` — with the
622
729
  // specs no longer living under `changes/`, a directory glob could not have
623
730
  // told two proposals apart, and one kept alongside the claim check would be
624
731
  // a second scoping rule able to disagree with it.
732
+ const { BASE_EXCLUDE } = await import('./runner.js');
625
733
  const exclude = [...BASE_EXCLUDE, '**/archive/**'];
626
734
  // Same run scope as `verify`: only the files that declare a requirement().
627
735
  // The gate must not go red because a repo's incumbent suite happens to sit
@@ -668,8 +776,9 @@ async function archiveRun(root, changeName, options = {}) {
668
776
  prefixOwners,
669
777
  // The plan's proposed files, not every proposed file on disk: `--apply`
670
778
  // 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)),
779
+ // not this change's to move. `claimed` is the half of the plan the gate
780
+ // above ran, handed back by the function that built it.
781
+ claimedSpecs: claimedSpecsOf(root, claimed),
673
782
  mergedSpecs: scan.specFiles,
674
783
  },
675
784
  };
@@ -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,18 @@
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
- import { byCodeUnit } from './order.js';
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.
28
+ import { byCodeUnit, sortDeep } from './order.js';
29
+ import { control } from './terminal.js';
30
+ /** Whether a value reads as words in a sentence — a scalar, or a list of them. */
31
+ function isFlat(value) {
32
+ const scalar = (v) => v === null || typeof v !== 'object';
33
+ return Array.isArray(value) ? value.every(scalar) : scalar(value);
34
+ }
24
35
  const BANNER = '<!-- Generated by `attest render` — do not edit. Edit the `*.reqs.ts` registry and regenerate. -->';
25
36
  /**
26
37
  * Render the registry as a standalone Markdown document.
@@ -31,18 +42,67 @@ const BANNER = '<!-- Generated by `attest render` — do not edit. Edit the `*.r
31
42
  * worse than no gate.
32
43
  */
33
44
  export function renderMarkdown(registry) {
34
- const ids = Object.keys(registry).sort(compareIds);
45
+ const clean = sanitised(registry);
46
+ const ids = Object.keys(clean).sort(compareIds);
35
47
  const out = [BANNER, '', '# Requirements', ''];
36
48
  if (ids.length === 0) {
37
49
  out.push('_No requirements are defined yet._', '');
38
50
  return out.join('\n');
39
51
  }
40
- out.push(...overviewTable(registry, ids), '');
52
+ out.push(...overviewTable(clean, ids), '');
41
53
  for (const id of ids) {
42
- out.push(...section(id, registry[id]), '');
54
+ out.push(...section(id, clean[id]), '');
43
55
  }
44
56
  return out.join('\n');
45
57
  }
58
+ /**
59
+ * The registry with every string its author controls stripped of control
60
+ * characters (ATX-58, design §9.1).
61
+ *
62
+ * This is the entry §9.1 names: sanitising here rather than at each emitter is
63
+ * what makes a field added to `Requirement` later covered by having been added,
64
+ * and what keeps the obligation over the document rather than over stdout.
65
+ *
66
+ * Ids are not sanitised and need not be: `RegistrySchema` holds every key to
67
+ * `^[A-Z]+-\d+$` on **both** reader paths — the static one by construction, the
68
+ * evaluating one since ATX-38 — so no id can carry a control character to begin
69
+ * with. The container is still built without a prototype, for the reason
70
+ * `red-record.ts` builds its own that way: that grammar is held somewhere else,
71
+ * and a defence that reads an inherited key when the other one lapses is not a
72
+ * defence. `Object.entries` and the sort below see own keys either way.
73
+ */
74
+ function sanitised(registry) {
75
+ const out = Object.create(null);
76
+ for (const [id, req] of Object.entries(registry)) {
77
+ out[id] = {
78
+ statement: control(req.statement),
79
+ rationale: control(req.rationale),
80
+ params: Object.fromEntries(Object.entries(req.params).map(([k, v]) => [control(k), sanitisedValue(v)])),
81
+ outOfScope: req.outOfScope.map(control),
82
+ };
83
+ }
84
+ return out;
85
+ }
86
+ /**
87
+ * A param value with every string in it sanitised; numbers, booleans and `null`
88
+ * have none.
89
+ *
90
+ * Recursive, over keys as well as values. A param is a JSON value, so the
91
+ * author-controlled strings inside one are at arbitrary depth — and a walk that
92
+ * stops at the first level would leave exactly the nested ones unsanitised,
93
+ * which is the shape the params of a `kind -> payload` table have. §9.1 puts the
94
+ * defence over the whole document; a depth limit is a hole in it.
95
+ */
96
+ function sanitisedValue(value) {
97
+ if (typeof value === 'string')
98
+ return control(value);
99
+ if (Array.isArray(value))
100
+ return value.map(sanitisedValue);
101
+ if (value !== null && typeof value === 'object') {
102
+ return Object.fromEntries(Object.entries(value).map(([k, v]) => [control(k), sanitisedValue(v)]));
103
+ }
104
+ return value;
105
+ }
46
106
  /**
47
107
  * Line endings are a checkout artifact, not content.
48
108
  *
@@ -85,23 +145,51 @@ export function staleIssue(target, current, fresh) {
85
145
  message: `${why} Regenerate it with: ${target.command}`,
86
146
  };
87
147
  }
148
+ /**
149
+ * `AUTH-3` as its two ordering keys. An id this reader cannot take apart is its
150
+ * own prefix with no number, which is the reading `locate.ts`'s `idPrefix`
151
+ * gives — the two used to disagree, and one of them was wrong.
152
+ *
153
+ * `lastIndexOf('-')` with no guard was the old spelling, and on an id with no
154
+ * dash it silently dropped the last character (`AUTH` → prefix `AUT`) and
155
+ * produced `NaN` for the number. A comparator that answers `NaN` is not merely
156
+ * imprecise, it is **non-transitive**: measured on
157
+ * `['AUTH-3','AUTH-abc','AUTH-2']` the result was `AUTH-3, AUTH-abc, AUTH-2`,
158
+ * which is not a sorted permutation of anything. Both cases are handled here so
159
+ * the comparator below is total by construction rather than by the input
160
+ * happening to be well-formed.
161
+ */
162
+ function orderingKey(id) {
163
+ const at = id.indexOf('-');
164
+ const tail = at === -1 ? '' : id.slice(at + 1);
165
+ if (at === -1 || !/^\d+$/.test(tail))
166
+ return [id, -1];
167
+ return [id.slice(0, at), Number(tail)];
168
+ }
88
169
  /**
89
170
  * Order ids the way a reader expects: prefix alphabetically, then the number
90
171
  * numerically. A plain string sort puts ATX-10 between ATX-1 and ATX-2, which
91
172
  * scrambles the document as soon as a registry reaches ten requirements.
173
+ *
174
+ * Total, and a function of the ids alone (design §9.1). A malformed id cannot
175
+ * reach here through any command — `RequirementIdSchema` rejects it and `render`
176
+ * returns early on a registry that failed to load — so this is the robustness
177
+ * §9.1 asks for rather than a fix: the function is correct on its own terms
178
+ * instead of correct because something upstream is.
92
179
  */
93
180
  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);
181
+ const [prefixA, numA] = orderingKey(a);
182
+ const [prefixB, numB] = orderingKey(b);
100
183
  // byCodeUnit, not localeCompare: the ordering has to be identical on every
101
184
  // machine, or `--check` would flap with the runner's locale.
102
185
  if (prefixA !== prefixB)
103
186
  return byCodeUnit(prefixA, prefixB);
104
- return numA - numB;
187
+ if (numA !== numB)
188
+ return numA - numB;
189
+ // Same prefix and same number is only reachable for ids this reader could not
190
+ // take apart. Falling back to the whole id keeps the order total rather than
191
+ // leaving it to the sort's stability, which is a property of the engine.
192
+ return byCodeUnit(a, b);
105
193
  }
106
194
  /**
107
195
  * An index of the whole registry: id + the promise itself. Deliberately carries
@@ -126,6 +214,15 @@ function section(id, req) {
126
214
  const params = Object.entries(req.params);
127
215
  if (params.length > 0) {
128
216
  out.push('', '| Param | Value |', '| --- | --- |', ...params.map(([name, value]) => `| ${code(name)} | ${cell(formatValue(value))} |`));
217
+ // A structured param goes below the table, not in it: a fenced block cannot
218
+ // live in a table cell — `cell` strips the newlines that make it a fence —
219
+ // and a nested object squeezed onto one line is the unreadable case, which
220
+ // is precisely the shape a `kind -> payload` table has.
221
+ for (const [name, value] of params) {
222
+ if (isFlat(value))
223
+ continue;
224
+ out.push('', `${code(name)}:`, '', ...jsonBlock(value));
225
+ }
129
226
  }
130
227
  if (req.outOfScope.length > 0) {
131
228
  out.push('', '**Out of scope**', '', ...req.outOfScope.map((s) => `- ${s}`));
@@ -152,13 +249,50 @@ function interpolate(statement, params) {
152
249
  */
153
250
  function plain(value) {
154
251
  const one = (v) => v.replace(/([*_`[\]\\])/g, '\\$1');
155
- return Array.isArray(value) ? value.map((v) => one(String(v))).join(', ') : one(String(value));
252
+ return Array.isArray(value)
253
+ ? value.map((v) => one(inlineText(v))).join(', ')
254
+ : one(inlineText(value));
255
+ }
256
+ /**
257
+ * One param value as a run of text.
258
+ *
259
+ * `String(v)` on an object is `[object Object]`, and this function is the last
260
+ * place that can be stopped. It is not stopped by `non-scalar-interpolation`:
261
+ * `render` reads the registry and nothing else — no spec parse, so no
262
+ * `AttestPlan`, so no `validateStructure` — and `attest render` therefore runs
263
+ * happily on a registry `check` would refuse. Compact sorted JSON is not a good
264
+ * sentence, but it is the value, and `check` says what to do about it. A
265
+ * rendering that reports the shape wrongly is worse than one that reads oddly.
266
+ */
267
+ function inlineText(value) {
268
+ return value === null || typeof value !== 'object'
269
+ ? String(value)
270
+ : JSON.stringify(sortDeep(value));
156
271
  }
157
- /** A param value as code, for the params table. */
272
+ /** A param value as code, for the params table. Structured values go below it. */
158
273
  function formatValue(value) {
274
+ if (!isFlat(value))
275
+ return '_see below_';
159
276
  return Array.isArray(value)
160
- ? value.map((v) => code(String(v))).join(', ')
161
- : code(String(value));
277
+ ? value.map((v) => code(inlineText(v))).join(', ')
278
+ : code(inlineText(value));
279
+ }
280
+ /**
281
+ * A structured param as a fenced JSON block.
282
+ *
283
+ * Keys sorted at every depth, because `JSON.stringify` writes them in insertion
284
+ * order and ATX-10 holds the same registry to the same bytes. The fence is
285
+ * measured rather than fixed at three for the reason `code` measures its own: the
286
+ * value is author-controlled, and a JSON string may contain a run of backticks
287
+ * that closes a fence written blind.
288
+ */
289
+ function jsonBlock(value) {
290
+ const text = JSON.stringify(sortDeep(value), null, 2);
291
+ let longest = 0;
292
+ for (const run of text.matchAll(/`+/g))
293
+ longest = Math.max(longest, run[0].length);
294
+ const fence = '`'.repeat(Math.max(3, longest + 1));
295
+ return [`${fence}json`, text, fence];
162
296
  }
163
297
  /**
164
298
  * Wrap text in a code span that survives backticks in the value: the fence has
@@ -166,14 +300,35 @@ function formatValue(value) {
166
300
  * at either end needs padding spaces.
167
301
  */
168
302
  function code(value) {
169
- const longest = Math.max(0, ...[...value.matchAll(/`+/g)].map((m) => m[0].length));
303
+ // Accumulated, never `Math.max(0, ...runs)` (ATX-59). The spread puts one
304
+ // argument on the stack per backtick run, so a param holding a few hundred
305
+ // thousand of them exhausted it — `RangeError` out of `attest render` under
306
+ // the static reader, with the stack naming this function. Not backtracking,
307
+ // and found only by sweeping for more of it: same reachable path, same
308
+ // registry-chooses-the-cost shape, different mechanism.
309
+ let longest = 0;
310
+ for (const run of value.matchAll(/`+/g))
311
+ longest = Math.max(longest, run[0].length);
170
312
  const fence = '`'.repeat(longest + 1);
171
313
  const pad = value.startsWith('`') || value.endsWith('`') ? ' ' : '';
172
314
  return `${fence}${pad}${value}${pad}${fence}`;
173
315
  }
174
- /** Make prose safe inside a table cell: no row-breaking pipes, no newlines. */
316
+ /**
317
+ * Make prose safe inside a table cell: no row-breaking pipes, no newlines.
318
+ *
319
+ * Each maximal whitespace run is matched once and inspected, rather than split
320
+ * across a pattern that puts a required character after a leading quantifier
321
+ * (ATX-59). `\s+` has nothing after it to fail against, so there is no
322
+ * backtracking to be quadratic in, and the newline decision moves to the
323
+ * callback. That is the property to preserve: any rewrite that puts a literal
324
+ * behind a quantifier here reintroduces it, including the narrower character
325
+ * class that looks like the obvious repair, which came out slower. Measured in
326
+ * `[0.7.0]`.
327
+ */
175
328
  function cell(text) {
176
- return text.replace(/\s*\n\s*/g, ' ').replace(/\|/g, '\\|');
329
+ return text
330
+ .replace(/\s+/g, (ws) => (ws.includes('\n') ? ' ' : ws))
331
+ .replace(/\|/g, '\\|');
177
332
  }
178
333
  /** GitHub/GitLab slug for a `## AUTH-3` heading. */
179
334
  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