@am_shork/attest 0.4.3 → 0.6.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,11 +1,11 @@
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
3
  import { createLoader } from './loader.js';
4
- import { evalReader, loadRegistry, parseSpecs, parseChangeSpecs, listChangeNames, scanProject, staticReader, } from './locate.js';
4
+ import { evalReader, findChangeDirSpecs, loadRegistry, listChangeNames, parseSpecs, scanProject, staticReader, } from './locate.js';
5
5
  import { validateStructure, detectPotentialDrift, uncoveredIssues } from './validator.js';
6
6
  import { byCodeUnit } from './order.js';
7
7
  import { runAndCollect, BASE_EXCLUDE } from './runner.js';
8
- import { applyDelta, addedIds } from './apply.js';
8
+ import { applyDelta, addedIds, claimedIds } from './apply.js';
9
9
  import { readDeltaSource } from './static-registry.js';
10
10
  import { statusRows, statusCounts } from './status.js';
11
11
  import { evaluateGate, declaredNotRunIssues } from './gate.js';
@@ -13,6 +13,8 @@ import { renderMarkdown, staleIssue } from './render.js';
13
13
  import { mergeRedRecord, readRedRecord, redRecordPath, serialiseRedRecord, } from './red-record.js';
14
14
  import { DEFAULT_TARGET, resolveTargets } from './targets.js';
15
15
  import { writeAtomic } from './write.js';
16
+ import { applyMerge, mergedSpecPath } from './merge.js';
17
+ import { compilerIssue } from './compiler.js';
16
18
  import { mkdir, readFile } from 'node:fs/promises';
17
19
  import { basename, dirname, join } from 'node:path';
18
20
  import { relativePath } from './paths.js';
@@ -34,6 +36,9 @@ async function readRegistry(root, options, files) {
34
36
  }
35
37
  /** Static structural check (design §9: `attest check`). */
36
38
  export async function runCheck(root, options = {}) {
39
+ const unusable = compilerIssue();
40
+ if (unusable)
41
+ return [unusable];
37
42
  const scan = await scanProject(root);
38
43
  const { registry, issues } = await readRegistry(root, options, scan.reqsFiles);
39
44
  const plan = await parseSpecs(scan.specFiles, root);
@@ -41,8 +46,143 @@ export async function runCheck(root, options = {}) {
41
46
  ...issues,
42
47
  ...validateStructure(registry, plan),
43
48
  ...detectPotentialDrift(registry, plan, plan.paramRefs),
49
+ ...(await unclaimedProposedSpecIssues(root, scan, options)),
50
+ ...(await changeDirSpecIssues(root)),
51
+ ...proposedNameTakenIssues(root, scan),
44
52
  ];
45
53
  }
54
+ /**
55
+ * A proposed spec whose merged name is already taken (design §7).
56
+ *
57
+ * Merging a proposed spec is a rename in place, so the name it will take is
58
+ * decided the moment the file is created — and nothing stopped that name from
59
+ * being one a merged spec already holds. `isSpecFile` and `isProposedSpecFile`
60
+ * are deliberately disjoint, so `session.spec.ts` and `session.proposed.spec.ts`
61
+ * sit side by side without either predicate objecting; the collision only
62
+ * surfaces at the rename, where it would **destroy** the merged file.
63
+ *
64
+ * It is not a corner. A proposed spec is written beside the code it attests, so
65
+ * a change to a module that already has tests — the ordinary kind — reaches for
66
+ * exactly the colliding name.
67
+ *
68
+ * Reported here rather than only refused by `--apply` because the fix is a
69
+ * rename at authoring time and the cost of learning late is a merge stopped
70
+ * halfway. `--apply` checks it again regardless: this is the one failure mode of
71
+ * that command that would destroy work rather than halt, and a check living in a
72
+ * different command is not a guard.
73
+ */
74
+ function proposedNameTakenIssues(root, scan) {
75
+ const taken = new Set(scan.specFiles);
76
+ return scan.proposedSpecFiles
77
+ .filter((p) => taken.has(mergedSpecPath(p)))
78
+ .map((p) => ({
79
+ level: 'ERROR',
80
+ code: 'proposed-spec-name-taken',
81
+ file: relativePath(root, p),
82
+ message: `${relativePath(root, p)} merges to ${relativePath(root, mergedSpecPath(p))}, which already exists — the rename would overwrite it. ` +
83
+ `Give the proposed spec a name whose merged form is free; several spec files may sit beside one module, but only one may hold each name.`,
84
+ }));
85
+ }
86
+ /**
87
+ * A spec still sitting under `changes/` (design §7).
88
+ *
89
+ * The companion to `unclaimedProposedSpecIssues`, and the reason both exist is
90
+ * the same: a spec that runs nowhere. That one covers a file at the right path
91
+ * no delta reaches; this one covers a file no *scan* reaches, which is the
92
+ * blind spot moving specs to their merged location opened underneath it.
93
+ *
94
+ * `check` rather than the gate, for the reason ATX-47 gives about its sibling
95
+ * and one more of its own: the gate does speak here, but it says
96
+ * `uncovered-requirement` — the added ids genuinely have no scenario the plan
97
+ * can see — and sends an author who has written those scenarios off to write
98
+ * them again or defer a finished requirement. `check` is both the command a
99
+ * pipeline runs first and, until this, the one that said nothing at all.
100
+ *
101
+ * No `reqId`: the finding is about a file and a path, and no requirement is
102
+ * implicated by it — the ids the file covers are exactly what cannot be read
103
+ * without walking it.
104
+ */
105
+ async function changeDirSpecIssues(root) {
106
+ const files = await findChangeDirSpecs(root);
107
+ return files.map((abs) => {
108
+ const file = relativePath(root, abs);
109
+ return {
110
+ level: 'ERROR',
111
+ code: 'spec-in-change-dir',
112
+ file,
113
+ message: `${file} is a spec under changes/, which no command walks: it runs in neither the base suite nor any change's gate. ` +
114
+ `Move it beside the code it attests and name it *.proposed.spec.ts — that is where a change's specs live, and it is what lets merging one be a rename in place.`,
115
+ };
116
+ });
117
+ }
118
+ /**
119
+ * A `*.proposed.spec.ts` no change's delta claims (design §7).
120
+ *
121
+ * A proposed spec is kept out of every normal run by its name and pulled into a
122
+ * gate run by its requirement ids, so one whose ids no delta mentions is a file
123
+ * that never executes anywhere — the silent drop this marker would otherwise
124
+ * cost. The base suite cannot catch it (the file is excluded there by design)
125
+ * and neither can the gate (it is scoped to what its own delta claims), which
126
+ * leaves `check` as the only command positioned to see it at all.
127
+ *
128
+ * A delta that cannot be read is reported as itself rather than turned into
129
+ * accusations against its specs: not knowing what a change claims is a different
130
+ * finding from knowing it claims nothing, and `check` already owes the first one
131
+ * — a change delta is intent, and the commands that only report read it from
132
+ * source (design §5.1).
133
+ */
134
+ async function unclaimedProposedSpecIssues(root, scan, options) {
135
+ if (scan.proposedSpecFiles.length === 0)
136
+ return [];
137
+ const claimed = new Set();
138
+ const issues = [];
139
+ for (const name of await listChangeNames(root)) {
140
+ const read = await readDelta(root, name, options);
141
+ if ('issue' in read)
142
+ issues.push(read.issue);
143
+ else
144
+ for (const id of claimedIds(read.delta))
145
+ claimed.add(id);
146
+ }
147
+ const proposed = await parseSpecs(scan.proposedSpecFiles, root);
148
+ const claimedFiles = new Set(proposed.scenarios.filter((s) => claimed.has(s.reqId)).map((s) => s.file));
149
+ // Reported per file, not per scenario: the file is the unit a run includes,
150
+ // so it is the unit that did or did not execute, and one line per scenario
151
+ // would say the same thing several times about one unread file. Driven off
152
+ // the scanned files rather than off the parsed scenarios, so a proposed spec
153
+ // that declares no scenario at all is reported too — that one is claimed by
154
+ // nothing for a second reason, and reading the plan alone cannot see it.
155
+ for (const abs of scan.proposedSpecFiles) {
156
+ const file = relativePath(root, abs);
157
+ if (claimedFiles.has(file))
158
+ continue;
159
+ issues.push({
160
+ level: 'ERROR',
161
+ code: 'proposed-spec-unclaimed',
162
+ file,
163
+ message: `${file} is a proposed spec, but no change under changes/ declares a requirement it covers, ` +
164
+ `so no gate run will ever include it. Add its requirement ids to that change's ` +
165
+ `${CHANGE_DELTA_FILE}, or rename the file to *.spec.ts if the behaviour has already merged.`,
166
+ });
167
+ }
168
+ return issues;
169
+ }
170
+ /**
171
+ * A verdict reached without running anything — the shape a refusal takes.
172
+ *
173
+ * `passed` is false rather than true: nothing ran, and a result claiming the
174
+ * suite passed because it never started is the silent-green this tool exists to
175
+ * refuse. The counts are zero for the same reason `Running 0 spec files` would
176
+ * be honest — they report what was looked at, and nothing was.
177
+ */
178
+ function emptyVerify(issues) {
179
+ return {
180
+ issues,
181
+ passed: false,
182
+ ok: false,
183
+ counts: { requirements: 0, scenarios: 0, specFiles: 0, attesting: 0 },
184
+ };
185
+ }
46
186
  /**
47
187
  * The spec files a run should execute: the ones that declare a `requirement()`.
48
188
  *
@@ -57,6 +197,9 @@ function attestingFiles(plan) {
57
197
  }
58
198
  /** Executable verification (design §9: `attest verify`). */
59
199
  export async function runVerify(root, options = {}) {
200
+ const unusable = compilerIssue();
201
+ if (unusable)
202
+ return emptyVerify([unusable]);
60
203
  const scan = await scanProject(root);
61
204
  const loader = await createLoader();
62
205
  let registry;
@@ -103,6 +246,7 @@ export async function runVerify(root, options = {}) {
103
246
  passed: true,
104
247
  runtimeCoverage: new Map(),
105
248
  outcomes: new Map(),
249
+ unloadedFiles: [],
106
250
  }
107
251
  : await runAndCollect({
108
252
  root,
@@ -128,6 +272,9 @@ export async function runVerify(root, options = {}) {
128
272
  * shape it can take.
129
273
  */
130
274
  export async function runCover(root, options = {}) {
275
+ const unusable = compilerIssue();
276
+ if (unusable)
277
+ return { rows: [], issues: [unusable] };
131
278
  const scan = await scanProject(root);
132
279
  const { registry, issues: loadIssues } = await readRegistry(root, options, scan.reqsFiles);
133
280
  if (hasError(loadIssues))
@@ -155,6 +302,9 @@ export async function runCover(root, options = {}) {
155
302
  * every time a line moves in a test file.
156
303
  */
157
304
  export async function runRender(root, options = {}) {
305
+ const unusable = compilerIssue();
306
+ if (unusable)
307
+ return { markdown: '', issues: [unusable] };
158
308
  const { registry, issues } = await readRegistry(root, options);
159
309
  // A registry that failed to load yields a document that silently omits
160
310
  // requirements. An incomplete spec doc is worse than none, so refuse.
@@ -215,10 +365,9 @@ export async function runInit(root, names) {
215
365
  * MR title.
216
366
  *
217
367
  * The test is path safety, not a character whitelist: the name never reaches a
218
- * glob (only *sibling* names do, and changeExcludeGlobs escapes those), so
219
- * `feat(auth)` or a name with a space is a perfectly good directory and there
220
- * is no reason for the gate to refuse it. Rejecting only what can escape the
221
- * directory keeps the guard exactly as wide as the danger.
368
+ * glob, so `feat(auth)` or a name with a space is a perfectly good directory
369
+ * and there is no reason for the gate to refuse it. Rejecting only what can
370
+ * escape the directory keeps the guard exactly as wide as the danger.
222
371
  */
223
372
  function isSafeChangeName(name) {
224
373
  return (name !== '' &&
@@ -229,29 +378,21 @@ function isSafeChangeName(name) {
229
378
  !name.includes('\0'));
230
379
  }
231
380
  /**
232
- * The globs that keep *other* proposals out of a change's gate run.
381
+ * Escape a path so a run's `include` matches that file and nothing else.
233
382
  *
234
- * Sibling names come from `readdir`, not from the guard above, so they are
235
- * escaped: a directory called `feat(auth)` pasted in raw is a *pattern*, it
236
- * matches nothing, and that sibling's specs silently join the run quietly
237
- * widening the scope of the one check that decides "done".
383
+ * The backslash comes first, and it is the one that matters most: it is glob's
384
+ * own escape character, so a path containing one is not merely unescaped, it
385
+ * silently rewrites the pattern around it `a\b` reads as an escaped `b` and
386
+ * matches `ab`, never the file itself. These paths come from the tree, not from
387
+ * the change-name guard above, so they are escaped rather than refused: a file
388
+ * that exists is not ours to reject.
389
+ *
390
+ * This used to escape *sibling change names* too, for the exclude globs that
391
+ * kept other proposals out of a gate run. Those globs are gone with the
392
+ * directory that made them possible; the include list is now the plan's own
393
+ * files, which is a set rather than a pattern.
238
394
  */
239
- export function changeExcludeGlobs(others) {
240
- return others.map((n) => `**/changes/${escapeGlob(n)}/**`);
241
- }
242
395
  function escapeGlob(name) {
243
- // The backslash comes first, and it is the one that matters most: it is
244
- // glob's own escape character, so a name containing one is not merely
245
- // unescaped, it silently rewrites the pattern around it. On POSIX a
246
- // backslash is a legal filename character, so `changes/a\b` produced the
247
- // exclude glob `**/changes/a\b/**`, which globbing reads as an escaped `b` —
248
- // matching `ab`, never the directory itself. That sibling's specs then
249
- // joined the gate run, quietly widening the scope of the one check that
250
- // decides whether a change is done.
251
- //
252
- // Escaped rather than rejected, because these names are not the change name
253
- // the guard above screens: they come from `readdir`, and a directory that
254
- // exists is not ours to refuse.
255
396
  return name.replace(/[\\*?[\]{}()!+@|^$]/g, '\\$&');
256
397
  }
257
398
  /** The delta a change is declared in (design §7). */
@@ -325,14 +466,53 @@ async function readDelta(root, changeName, options) {
325
466
  },
326
467
  };
327
468
  }
469
+ /**
470
+ * One entry per claimed proposed spec, carrying the ids it declares a scenario
471
+ * for — what `--apply` needs to know which registry to repoint its import at.
472
+ *
473
+ * Built from the plan rather than by re-parsing, so the files `--apply` touches
474
+ * are exactly the files the gate ran.
475
+ */
476
+ function claimedSpecsOf(root, plan) {
477
+ const byFile = new Map();
478
+ for (const sc of plan.scenarios) {
479
+ const set = byFile.get(sc.file) ?? new Set();
480
+ set.add(sc.reqId);
481
+ byFile.set(sc.file, set);
482
+ }
483
+ return [...byFile.entries()]
484
+ .sort(([a], [b]) => byCodeUnit(a, b))
485
+ .map(([file, ids]) => ({ file: join(root, file), reqIds: [...ids].sort(byCodeUnit) }));
486
+ }
487
+ /** Split a plan over the proposed specs into the part one delta claims. */
488
+ function claimedByDelta(proposed, delta) {
489
+ const claimed = new Set(claimedIds(delta));
490
+ // A file is claimed whole or not at all. Scenarios in one file can name
491
+ // several requirements, and running half a file is not something Vitest can
492
+ // be asked for — the run scope is a set of files — so a per-scenario split
493
+ // would make the static plan describe a run that cannot happen.
494
+ const files = new Set(proposed.scenarios.filter((s) => claimed.has(s.reqId)).map((s) => s.file));
495
+ const scenarios = proposed.scenarios.filter((s) => files.has(s.file));
496
+ // A `ParamRef` records a requirement and a scenario name, not a file, so it
497
+ // is carried by the scenario it was seen in rather than filtered on its own.
498
+ // Dropping the pair down to `reqId` would let a param read in a *merged*
499
+ // scenario silence the drift heuristic for a proposed one, and vice versa.
500
+ const key = (reqId, scenario) => JSON.stringify([reqId, scenario]);
501
+ const kept = new Set(scenarios.map((s) => key(s.reqId, s.name)));
502
+ return {
503
+ scenarios,
504
+ paramRefs: proposed.paramRefs.filter((p) => kept.has(key(p.reqId, p.scenario))),
505
+ };
506
+ }
328
507
  /**
329
508
  * The static plan a change is gated and reported against: the base suite plus
330
- * the change's own specs (design §8). Takes the spec files rather than finding
331
- * them, so a caller that already scanned the tree does not walk it twice.
509
+ * the specs this change's delta claims (design §8). Takes the scan rather than
510
+ * walking the tree again, so a caller that already scanned does not repeat it.
332
511
  */
333
- async function changeMergedPlan(root, changeName, specFiles) {
334
- const basePlan = await parseSpecs(specFiles, root);
335
- const changePlan = await parseChangeSpecs(root, changeName);
512
+ async function changeMergedPlan(root, delta, scan) {
513
+ const basePlan = await parseSpecs(scan.specFiles, root);
514
+ const proposed = await parseSpecs(scan.proposedSpecFiles, root);
515
+ const changePlan = claimedByDelta(proposed, delta);
336
516
  return {
337
517
  scenarios: [...basePlan.scenarios, ...changePlan.scenarios],
338
518
  paramRefs: [...basePlan.paramRefs, ...changePlan.paramRefs],
@@ -340,6 +520,7 @@ async function changeMergedPlan(root, changeName, specFiles) {
340
520
  }
341
521
  /** Per-change progress (design §9: `attest status <change>`). */
342
522
  export async function runStatus(root, changeName, options = {}) {
523
+ const unusable = compilerIssue();
343
524
  const nothing = (issues) => ({
344
525
  change: changeName,
345
526
  rows: [],
@@ -350,27 +531,66 @@ export async function runStatus(root, changeName, options = {}) {
350
531
  // gate checks it: under `--eval` the name reaches a module that is executed.
351
532
  if (!isSafeChangeName(changeName))
352
533
  return nothing([invalidChangeNameIssue(changeName)]);
534
+ if (unusable)
535
+ return nothing([unusable]);
353
536
  const read = await readDelta(root, changeName, options);
354
537
  if ('issue' in read)
355
538
  return nothing([read.issue]);
356
539
  const scan = await scanProject(root);
357
- const plan = await changeMergedPlan(root, changeName, scan.specFiles);
540
+ const plan = await changeMergedPlan(root, read.delta, scan);
358
541
  const firstRun = await readRedRecord(root, changeName);
359
542
  const rows = statusRows(addedIds(read.delta), plan, firstRun);
360
543
  return { change: changeName, rows, counts: statusCounts(rows), issues: [] };
361
544
  }
362
545
  /** Archive gate for a change (design §8, §9: `attest archive <change>`). */
363
546
  export async function runArchive(root, changeName, options = {}) {
547
+ return (await archiveRun(root, changeName, options)).issues;
548
+ }
549
+ /**
550
+ * The gate, and — only if it passes — the merge it approved
551
+ * (`attest archive <change> --apply`).
552
+ *
553
+ * The gate runs first and unconditionally, in this same call. A merge acting on
554
+ * an earlier run's verdict would let an unfinished change be filed as done, and
555
+ * "done" having a hard definition is the whole claim of the tool; so a red gate
556
+ * writes nothing, and there is no flag that skips it.
557
+ */
558
+ export async function runArchiveApply(root, changeName, options = {}) {
559
+ const { issues, merge } = await archiveRun(root, changeName, options);
560
+ if (issues.some((i) => i.level === 'ERROR') || !merge)
561
+ return { issues, written: [] };
562
+ const result = await applyMerge({ root, changeName, ...merge });
563
+ // The gate's non-blocking output is kept: a WARNING the gate raised is still
564
+ // true after the merge, and dropping it here would make `--apply` quieter than
565
+ // the same command without it.
566
+ return { issues: [...issues, ...result.issues], written: result.written };
567
+ }
568
+ /**
569
+ * The gate, plus what finishing the merge would need.
570
+ *
571
+ * 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.
576
+ *
577
+ * `merge` is absent exactly when there is nothing to act on: a rejected name, an
578
+ * unreadable delta, or a registry that would not load.
579
+ */
580
+ async function archiveRun(root, changeName, options = {}) {
364
581
  // Checked before any loader starts: nothing should be resolved, let alone
365
582
  // evaluated, on behalf of a name we have already rejected.
366
583
  if (!isSafeChangeName(changeName))
367
- return [invalidChangeNameIssue(changeName)];
584
+ return { issues: [invalidChangeNameIssue(changeName)] };
585
+ const unusable = compilerIssue();
586
+ if (unusable)
587
+ return { issues: [unusable] };
368
588
  const scan = await scanProject(root);
369
589
  const loader = await createLoader();
370
590
  try {
371
- const { registry: base, issues } = await loadRegistry(root, evalReader(loader), scan.reqsFiles);
591
+ const { registry: base, issues, prefixOwners } = await loadRegistry(root, evalReader(loader), scan.reqsFiles);
372
592
  if (issues.some((i) => i.level === 'ERROR'))
373
- return issues;
593
+ return { issues };
374
594
  const deltaPath = changeDeltaPath(root, changeName);
375
595
  let delta;
376
596
  try {
@@ -387,22 +607,22 @@ export async function runArchive(root, changeName, options = {}) {
387
607
  delta = mod.default;
388
608
  }
389
609
  catch (err) {
390
- return [changeNotFoundIssue(root, deltaPath, changeName, err)];
610
+ return { issues: [changeNotFoundIssue(root, deltaPath, changeName, err)] };
391
611
  }
392
612
  const applied = applyDelta(base, delta);
393
613
  if (applied.issues.length > 0)
394
- return applied.issues;
614
+ return { issues: applied.issues };
395
615
  // Static plan = merged base suite + this change's specs (design §8), by the
396
616
  // same function `status` reports against — a progress report computed over a
397
617
  // different spec set than the gate uses would be a report about nothing.
398
- const plan = await changeMergedPlan(root, changeName, scan.specFiles);
399
- // Run base specs + this change's specs; exclude other proposals and archive.
400
- const others = (await listChangeNames(root)).filter((n) => n !== changeName);
401
- const exclude = [
402
- ...BASE_EXCLUDE,
403
- '**/archive/**',
404
- ...changeExcludeGlobs(others),
405
- ];
618
+ const plan = await changeMergedPlan(root, delta, scan);
619
+ // Other proposals need no exclude glob of their own: the include list below
620
+ // is the plan's own files, and the plan holds only the proposed specs this
621
+ // delta claims. That is what replaced `**/changes/<sibling>/**` — with the
622
+ // specs no longer living under `changes/`, a directory glob could not have
623
+ // told two proposals apart, and one kept alongside the claim check would be
624
+ // a second scoping rule able to disagree with it.
625
+ const exclude = [...BASE_EXCLUDE, '**/archive/**'];
406
626
  // Same run scope as `verify`: only the files that declare a requirement().
407
627
  // The gate must not go red because a repo's incumbent suite happens to sit
408
628
  // under the same root as the change being archived.
@@ -429,7 +649,30 @@ export async function runArchive(root, changeName, options = {}) {
429
649
  await writeAtomic(redRecordPath(root, changeName), serialiseRedRecord(changeName, merged.record));
430
650
  }
431
651
  }
432
- return evaluateGate({ registry: applied.registry, plan, run, addedIds: added, firstRun });
652
+ // Against `base`, not `applied`: the child run imported the registry from
653
+ // disk, so what matters is what that file has, not what the gate computed.
654
+ const unmergedAddedIds = added.filter((id) => !Object.hasOwn(base, id));
655
+ return {
656
+ issues: evaluateGate({
657
+ registry: applied.registry,
658
+ plan,
659
+ run,
660
+ addedIds: added,
661
+ unmergedAddedIds,
662
+ firstRun,
663
+ }),
664
+ merge: {
665
+ delta,
666
+ base,
667
+ applied: applied.registry,
668
+ prefixOwners,
669
+ // The plan's proposed files, not every proposed file on disk: `--apply`
670
+ // 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)),
673
+ mergedSpecs: scan.specFiles,
674
+ },
675
+ };
433
676
  }
434
677
  finally {
435
678
  await loader.close();
@@ -75,10 +75,69 @@ export interface RegistryDelta {
75
75
  }[];
76
76
  }
77
77
  /**
78
- * Declare a registry delta for a change. This is an identity pass-through that
79
- * fixes the authoring surface; ordered idempotent application
80
- * (RENAMED -> REMOVED -> MODIFIED -> ADDED) lives in applyDelta (design §7).
78
+ * The requirements a delta ADDs, keyed by id and typed at the params written —
79
+ * the same shape {@link defineRequirements} returns, over the half of a change
80
+ * that introduces requirements.
81
+ *
82
+ * MODIFIED ids are deliberately absent. A modified requirement's end state is
83
+ * the base entry with the patch applied, and the base is not in this file; a
84
+ * view that showed the patch alone would answer `params.x` with the proposed
85
+ * value and `params.y` with `undefined` for a key the requirement has. That is
86
+ * a shape that reads as the merged requirement and is not one, which is worse
87
+ * than not offering it.
88
+ */
89
+ type ProposedRequirements<T extends RegistryDelta> = T extends {
90
+ added: infer A;
91
+ } ? {
92
+ [K in keyof A]: Omit<Requirement, 'params'> & {
93
+ params: DefinedParams<A[K]>;
94
+ };
95
+ } : unknown;
96
+ /**
97
+ * What `delta()` returns: the delta itself, also readable as the registry of
98
+ * what it proposes.
99
+ *
100
+ * The union is safe by grammar rather than by convention — a requirement id
101
+ * matches `/^[A-Z]+-\d+$/` (`RequirementIdSchema`), so no id can ever be spelled
102
+ * `added`, `modified`, `removed` or `renamed`. That is what lets one value carry
103
+ * both surfaces without either shadowing the other.
104
+ *
105
+ * There is no index signature, unlike {@link DefinedRegistry}. That one keeps
106
+ * its because `reqs[id]` with an `id: string` is a real pattern in a shared test
107
+ * helper over a whole registry; the ids a single change adds are few and known
108
+ * at the call site, so the stricter shape costs nothing and rejects a mistyped
109
+ * id outright instead of leaving it to `check`.
110
+ */
111
+ export type DefinedDelta<T extends RegistryDelta> = T & ProposedRequirements<T>;
112
+ /**
113
+ * Declare a registry delta for a change (design §7). Ordered idempotent
114
+ * application (RENAMED -> REMOVED -> MODIFIED -> ADDED) lives in applyDelta.
115
+ *
116
+ * The returned value is the delta *and* the registry of the requirements it
117
+ * adds, so a stage-1 scenario reads a proposed param with the expression a
118
+ * merged one uses — `reqs['AUTH-7'].params.totpWindowSec`, not
119
+ * `d.added!['AUTH-7']!.params!.totpWindowSec as number`. Stage 1 is where the
120
+ * scenario must be written and must go red, which made it the one stage where
121
+ * the workflow pushed the author off `params`, the single source the whole tool
122
+ * exists to reward — and then billed them the rewrite at merge for having
123
+ * complied. Now merging a spec changes its import and nothing else.
124
+ *
125
+ * Still no validation, deliberately. A delta carrying an id the registry would
126
+ * refuse is an `add-invalid` / `rename-target-invalid` ERROR from the gate
127
+ * (ATX-41), which is where a reviewer sees it; throwing here would move that
128
+ * verdict into whichever command happened to evaluate the file first.
129
+ */
130
+ export declare function delta<const T extends RegistryDelta>(d: T): DefinedDelta<T>;
131
+ /**
132
+ * A delta, also keyed by the ids it ADDs. The one place that shape is built.
133
+ *
134
+ * Both readers go through this — `delta()` on the evaluated path, and
135
+ * `readDeltaSource` on the static one — because the differential suite asserts
136
+ * the two agree about what a delta *is*, and a second copy of this three-line
137
+ * spread is exactly the kind of agreement that holds by transcription until it
138
+ * does not. A new object rather than a mutated argument, so the literal a
139
+ * caller wrote is still the literal they hold.
81
140
  */
82
- export declare function delta(d: RegistryDelta): RegistryDelta;
141
+ export declare function withProposedRequirements<T extends RegistryDelta>(d: T): T;
83
142
  export {};
84
143
  //# sourceMappingURL=registry.d.ts.map
@@ -34,11 +34,37 @@ export function defineRequirements(input) {
34
34
  return result.data;
35
35
  }
36
36
  /**
37
- * Declare a registry delta for a change. This is an identity pass-through that
38
- * fixes the authoring surface; ordered idempotent application
39
- * (RENAMED -> REMOVED -> MODIFIED -> ADDED) lives in applyDelta (design §7).
37
+ * Declare a registry delta for a change (design §7). Ordered idempotent
38
+ * application (RENAMED -> REMOVED -> MODIFIED -> ADDED) lives in applyDelta.
39
+ *
40
+ * The returned value is the delta *and* the registry of the requirements it
41
+ * adds, so a stage-1 scenario reads a proposed param with the expression a
42
+ * merged one uses — `reqs['AUTH-7'].params.totpWindowSec`, not
43
+ * `d.added!['AUTH-7']!.params!.totpWindowSec as number`. Stage 1 is where the
44
+ * scenario must be written and must go red, which made it the one stage where
45
+ * the workflow pushed the author off `params`, the single source the whole tool
46
+ * exists to reward — and then billed them the rewrite at merge for having
47
+ * complied. Now merging a spec changes its import and nothing else.
48
+ *
49
+ * Still no validation, deliberately. A delta carrying an id the registry would
50
+ * refuse is an `add-invalid` / `rename-target-invalid` ERROR from the gate
51
+ * (ATX-41), which is where a reviewer sees it; throwing here would move that
52
+ * verdict into whichever command happened to evaluate the file first.
40
53
  */
41
54
  export function delta(d) {
42
- return d;
55
+ return withProposedRequirements(d);
56
+ }
57
+ /**
58
+ * A delta, also keyed by the ids it ADDs. The one place that shape is built.
59
+ *
60
+ * Both readers go through this — `delta()` on the evaluated path, and
61
+ * `readDeltaSource` on the static one — because the differential suite asserts
62
+ * the two agree about what a delta *is*, and a second copy of this three-line
63
+ * spread is exactly the kind of agreement that holds by transcription until it
64
+ * does not. A new object rather than a mutated argument, so the literal a
65
+ * caller wrote is still the literal they hold.
66
+ */
67
+ export function withProposedRequirements(d) {
68
+ return { ...d, ...(d.added ?? {}) };
43
69
  }
44
70
  //# sourceMappingURL=registry.js.map