@am_shork/attest 0.5.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,29 +1,68 @@
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
- import { evalReader, loadRegistry, listChangeNames, parseSpecs, scanProject, staticReader, } from './locate.js';
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';
15
13
  import { writeAtomic } from './write.js';
14
+ import { applyMerge, mergedSpecPath } from './merge.js';
15
+ import { compilerIssue } from './compiler.js';
16
16
  import { mkdir, readFile } from 'node:fs/promises';
17
17
  import { basename, dirname, join } from 'node:path';
18
18
  import { relativePath } from './paths.js';
19
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
+ }
20
53
  /**
21
54
  * Read every registry under root with the reader `options` asks for, and close
22
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.
23
60
  */
24
- async function readRegistry(root, options, files) {
61
+ async function readRegistry(root, options, files, borrowed) {
25
62
  if (!options.evaluate)
26
63
  return loadRegistry(root, staticReader(), files);
64
+ if (borrowed)
65
+ return loadRegistry(root, evalReader(borrowed), files);
27
66
  const loader = await createLoader();
28
67
  try {
29
68
  return await loadRegistry(root, evalReader(loader), files);
@@ -32,17 +71,123 @@ async function readRegistry(root, options, files) {
32
71
  await loader.close();
33
72
  }
34
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
+ }
35
101
  /** Static structural check (design §9: `attest check`). */
36
102
  export async function runCheck(root, options = {}) {
103
+ const unusable = compilerIssue();
104
+ if (unusable)
105
+ return [unusable];
37
106
  const scan = await scanProject(root);
38
- const { registry, issues } = await readRegistry(root, options, scan.reqsFiles);
39
- const plan = await parseSpecs(scan.specFiles, root);
40
- return [
41
- ...issues,
42
- ...validateStructure(registry, plan),
43
- ...detectPotentialDrift(registry, plan, plan.paramRefs),
44
- ...(await unclaimedProposedSpecIssues(root, scan, options)),
45
- ];
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
+ });
127
+ }
128
+ /**
129
+ * A proposed spec whose merged name is already taken (design §7).
130
+ *
131
+ * Merging a proposed spec is a rename in place, so the name it will take is
132
+ * decided the moment the file is created — and nothing stopped that name from
133
+ * being one a merged spec already holds. `isSpecFile` and `isProposedSpecFile`
134
+ * are deliberately disjoint, so `session.spec.ts` and `session.proposed.spec.ts`
135
+ * sit side by side without either predicate objecting; the collision only
136
+ * surfaces at the rename, where it would **destroy** the merged file.
137
+ *
138
+ * It is not a corner. A proposed spec is written beside the code it attests, so
139
+ * a change to a module that already has tests — the ordinary kind — reaches for
140
+ * exactly the colliding name.
141
+ *
142
+ * Reported here rather than only refused by `--apply` because the fix is a
143
+ * rename at authoring time and the cost of learning late is a merge stopped
144
+ * halfway. `--apply` checks it again regardless: this is the one failure mode of
145
+ * that command that would destroy work rather than halt, and a check living in a
146
+ * different command is not a guard.
147
+ */
148
+ function proposedNameTakenIssues(root, scan) {
149
+ const taken = new Set(scan.specFiles);
150
+ return scan.proposedSpecFiles
151
+ .filter((p) => taken.has(mergedSpecPath(p)))
152
+ .map((p) => ({
153
+ level: 'ERROR',
154
+ code: 'proposed-spec-name-taken',
155
+ file: relativePath(root, p),
156
+ message: `${relativePath(root, p)} merges to ${relativePath(root, mergedSpecPath(p))}, which already exists — the rename would overwrite it. ` +
157
+ `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.`,
158
+ }));
159
+ }
160
+ /**
161
+ * A spec still sitting under `changes/` (design §7).
162
+ *
163
+ * The companion to `unclaimedProposedSpecIssues`, and the reason both exist is
164
+ * the same: a spec that runs nowhere. That one covers a file at the right path
165
+ * no delta reaches; this one covers a file no *scan* reaches, which is the
166
+ * blind spot moving specs to their merged location opened underneath it.
167
+ *
168
+ * `check` rather than the gate, for the reason ATX-47 gives about its sibling
169
+ * and one more of its own: the gate does speak here, but it says
170
+ * `uncovered-requirement` — the added ids genuinely have no scenario the plan
171
+ * can see — and sends an author who has written those scenarios off to write
172
+ * them again or defer a finished requirement. `check` is both the command a
173
+ * pipeline runs first and, until this, the one that said nothing at all.
174
+ *
175
+ * No `reqId`: the finding is about a file and a path, and no requirement is
176
+ * implicated by it — the ids the file covers are exactly what cannot be read
177
+ * without walking it.
178
+ */
179
+ async function changeDirSpecIssues(root) {
180
+ const files = await findChangeDirSpecs(root);
181
+ return files.map((abs) => {
182
+ const file = relativePath(root, abs);
183
+ return {
184
+ level: 'ERROR',
185
+ code: 'spec-in-change-dir',
186
+ file,
187
+ message: `${file} is a spec under changes/, which no command walks: it runs in neither the base suite nor any change's gate. ` +
188
+ `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.`,
189
+ };
190
+ });
46
191
  }
47
192
  /**
48
193
  * A `*.proposed.spec.ts` no change's delta claims (design §7).
@@ -60,13 +205,15 @@ export async function runCheck(root, options = {}) {
60
205
  * — a change delta is intent, and the commands that only report read it from
61
206
  * source (design §5.1).
62
207
  */
63
- async function unclaimedProposedSpecIssues(root, scan, options) {
208
+ async function unclaimedProposedSpecIssues(root, scan, options, loader) {
64
209
  if (scan.proposedSpecFiles.length === 0)
65
210
  return [];
66
211
  const claimed = new Set();
67
212
  const issues = [];
213
+ // The loop `loader` exists for: under `--eval` this used to start and stop a
214
+ // Vite dev server per change.
68
215
  for (const name of await listChangeNames(root)) {
69
- const read = await readDelta(root, name, options);
216
+ const read = await readDelta(root, name, options, loader);
70
217
  if ('issue' in read)
71
218
  issues.push(read.issue);
72
219
  else
@@ -96,6 +243,22 @@ async function unclaimedProposedSpecIssues(root, scan, options) {
96
243
  }
97
244
  return issues;
98
245
  }
246
+ /**
247
+ * A verdict reached without running anything — the shape a refusal takes.
248
+ *
249
+ * `passed` is false rather than true: nothing ran, and a result claiming the
250
+ * suite passed because it never started is the silent-green this tool exists to
251
+ * refuse. The counts are zero for the same reason `Running 0 spec files` would
252
+ * be honest — they report what was looked at, and nothing was.
253
+ */
254
+ function emptyVerify(issues) {
255
+ return {
256
+ issues,
257
+ passed: false,
258
+ ok: false,
259
+ counts: { requirements: 0, scenarios: 0, specFiles: 0, attesting: 0 },
260
+ };
261
+ }
99
262
  /**
100
263
  * The spec files a run should execute: the ones that declare a `requirement()`.
101
264
  *
@@ -110,21 +273,28 @@ function attestingFiles(plan) {
110
273
  }
111
274
  /** Executable verification (design §9: `attest verify`). */
112
275
  export async function runVerify(root, options = {}) {
276
+ const unusable = compilerIssue();
277
+ if (unusable)
278
+ return emptyVerify([unusable]);
113
279
  const scan = await scanProject(root);
114
280
  const loader = await createLoader();
115
281
  let registry;
116
282
  let plan;
283
+ let unreadableFiles;
117
284
  const issues = [];
118
285
  try {
119
286
  const loaded = await loadRegistry(root, evalReader(loader), scan.reqsFiles);
120
287
  registry = loaded.registry;
121
288
  issues.push(...loaded.issues);
289
+ unreadableFiles = loaded.unreadableFiles;
122
290
  plan = await parseSpecs(scan.specFiles, root);
123
291
  }
124
292
  finally {
125
293
  await loader.close();
126
294
  }
127
- 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));
128
298
  issues.push(...detectPotentialDrift(registry, plan, plan.paramRefs));
129
299
  const attesting = attestingFiles(plan);
130
300
  const counts = {
@@ -167,7 +337,13 @@ export async function runVerify(root, options = {}) {
167
337
  if (!run.passed) {
168
338
  issues.push({ level: 'ERROR', code: 'tests-red', message: 'Some tests are failing.' });
169
339
  }
170
- 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));
171
347
  return { issues, passed: run.passed, ok: run.passed && !hasError(issues), counts };
172
348
  }
173
349
  /**
@@ -182,6 +358,9 @@ export async function runVerify(root, options = {}) {
182
358
  * shape it can take.
183
359
  */
184
360
  export async function runCover(root, options = {}) {
361
+ const unusable = compilerIssue();
362
+ if (unusable)
363
+ return { rows: [], issues: [unusable] };
185
364
  const scan = await scanProject(root);
186
365
  const { registry, issues: loadIssues } = await readRegistry(root, options, scan.reqsFiles);
187
366
  if (hasError(loadIssues))
@@ -191,8 +370,12 @@ export async function runCover(root, options = {}) {
191
370
  for (const s of plan.scenarios) {
192
371
  counts.set(s.reqId, (counts.get(s.reqId) ?? 0) + 1);
193
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.
194
377
  const rows = Object.keys(registry)
195
- .sort()
378
+ .sort(byCodeUnit)
196
379
  .map((reqId) => ({
197
380
  reqId,
198
381
  covered: (counts.get(reqId) ?? 0) > 0,
@@ -209,6 +392,9 @@ export async function runCover(root, options = {}) {
209
392
  * every time a line moves in a test file.
210
393
  */
211
394
  export async function runRender(root, options = {}) {
395
+ const unusable = compilerIssue();
396
+ if (unusable)
397
+ return { markdown: '', issues: [unusable] };
212
398
  const { registry, issues } = await readRegistry(root, options);
213
399
  // A registry that failed to load yields a document that silently omits
214
400
  // requirements. An incomplete spec doc is worse than none, so refuse.
@@ -330,10 +516,14 @@ function changeNotFoundIssue(root, deltaPath, changeName, err) {
330
516
  * under review. `archive` evaluates unconditionally because it runs the suite
331
517
  * anyway, so declining to evaluate one more module would buy it nothing.
332
518
  */
333
- async function readDelta(root, changeName, options) {
519
+ async function readDelta(root, changeName, options, borrowed) {
334
520
  const deltaPath = changeDeltaPath(root, changeName);
335
521
  if (options.evaluate) {
336
- 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());
337
527
  try {
338
528
  const mod = await loader.load(deltaPath);
339
529
  if (!mod.default || typeof mod.default !== 'object') {
@@ -347,7 +537,10 @@ async function readDelta(root, changeName, options) {
347
537
  return { issue: changeNotFoundIssue(root, deltaPath, changeName, err) };
348
538
  }
349
539
  finally {
350
- 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();
351
544
  }
352
545
  }
353
546
  let source;
@@ -370,6 +563,24 @@ async function readDelta(root, changeName, options) {
370
563
  },
371
564
  };
372
565
  }
566
+ /**
567
+ * One entry per claimed proposed spec, carrying the ids it declares a scenario
568
+ * for — what `--apply` needs to know which registry to repoint its import at.
569
+ *
570
+ * Built from the plan rather than by re-parsing, so the files `--apply` touches
571
+ * are exactly the files the gate ran.
572
+ */
573
+ function claimedSpecsOf(root, plan) {
574
+ const byFile = new Map();
575
+ for (const sc of plan.scenarios) {
576
+ const set = byFile.get(sc.file) ?? new Set();
577
+ set.add(sc.reqId);
578
+ byFile.set(sc.file, set);
579
+ }
580
+ return [...byFile.entries()]
581
+ .sort(([a], [b]) => byCodeUnit(a, b))
582
+ .map(([file, ids]) => ({ file: join(root, file), reqIds: [...ids].sort(byCodeUnit) }));
583
+ }
373
584
  /** Split a plan over the proposed specs into the part one delta claims. */
374
585
  function claimedByDelta(proposed, delta) {
375
586
  const claimed = new Set(claimedIds(delta));
@@ -398,14 +609,25 @@ function claimedByDelta(proposed, delta) {
398
609
  async function changeMergedPlan(root, delta, scan) {
399
610
  const basePlan = await parseSpecs(scan.specFiles, root);
400
611
  const proposed = await parseSpecs(scan.proposedSpecFiles, root);
401
- const changePlan = claimedByDelta(proposed, delta);
612
+ const claimed = claimedByDelta(proposed, delta);
402
613
  return {
403
- scenarios: [...basePlan.scenarios, ...changePlan.scenarios],
404
- 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,
405
626
  };
406
627
  }
407
628
  /** Per-change progress (design §9: `attest status <change>`). */
408
629
  export async function runStatus(root, changeName, options = {}) {
630
+ const unusable = compilerIssue();
409
631
  const nothing = (issues) => ({
410
632
  change: changeName,
411
633
  rows: [],
@@ -416,27 +638,69 @@ export async function runStatus(root, changeName, options = {}) {
416
638
  // gate checks it: under `--eval` the name reaches a module that is executed.
417
639
  if (!isSafeChangeName(changeName))
418
640
  return nothing([invalidChangeNameIssue(changeName)]);
641
+ if (unusable)
642
+ return nothing([unusable]);
419
643
  const read = await readDelta(root, changeName, options);
420
644
  if ('issue' in read)
421
645
  return nothing([read.issue]);
422
646
  const scan = await scanProject(root);
423
- const plan = await changeMergedPlan(root, read.delta, scan);
647
+ const { merged: plan } = await changeMergedPlan(root, read.delta, scan);
424
648
  const firstRun = await readRedRecord(root, changeName);
425
649
  const rows = statusRows(addedIds(read.delta), plan, firstRun);
426
650
  return { change: changeName, rows, counts: statusCounts(rows), issues: [] };
427
651
  }
428
652
  /** Archive gate for a change (design §8, §9: `attest archive <change>`). */
429
653
  export async function runArchive(root, changeName, options = {}) {
654
+ return (await archiveRun(root, changeName, options)).issues;
655
+ }
656
+ /**
657
+ * The gate, and — only if it passes — the merge it approved
658
+ * (`attest archive <change> --apply`).
659
+ *
660
+ * The gate runs first and unconditionally, in this same call. A merge acting on
661
+ * an earlier run's verdict would let an unfinished change be filed as done, and
662
+ * "done" having a hard definition is the whole claim of the tool; so a red gate
663
+ * writes nothing, and there is no flag that skips it.
664
+ */
665
+ export async function runArchiveApply(root, changeName, options = {}) {
666
+ const { issues, merge } = await archiveRun(root, changeName, options);
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)
671
+ return { issues, written: [] };
672
+ const result = await applyMerge({ root, changeName, ...merge });
673
+ // The gate's non-blocking output is kept: a WARNING the gate raised is still
674
+ // true after the merge, and dropping it here would make `--apply` quieter than
675
+ // the same command without it.
676
+ return { issues: [...issues, ...result.issues], written: result.written };
677
+ }
678
+ /**
679
+ * The gate, plus what finishing the merge would need.
680
+ *
681
+ * One function rather than a gate and a separate `--apply` path, because the
682
+ * merge must act on **this** run's verdict. A second traversal could reach a
683
+ * different answer than the one just printed — and a command able to file a
684
+ * change as done against a stale verdict removes the hard definition of "done"
685
+ * that is this tool's whole claim.
686
+ *
687
+ * `merge` is absent exactly when there is nothing to act on: a rejected name, an
688
+ * unreadable delta, or a registry that would not load.
689
+ */
690
+ async function archiveRun(root, changeName, options = {}) {
430
691
  // Checked before any loader starts: nothing should be resolved, let alone
431
692
  // evaluated, on behalf of a name we have already rejected.
432
693
  if (!isSafeChangeName(changeName))
433
- return [invalidChangeNameIssue(changeName)];
694
+ return { issues: [invalidChangeNameIssue(changeName)] };
695
+ const unusable = compilerIssue();
696
+ if (unusable)
697
+ return { issues: [unusable] };
434
698
  const scan = await scanProject(root);
435
699
  const loader = await createLoader();
436
700
  try {
437
- const { registry: base, issues } = await loadRegistry(root, evalReader(loader), scan.reqsFiles);
438
- if (issues.some((i) => i.level === 'ERROR'))
439
- return issues;
701
+ const { registry: base, issues, prefixOwners } = await loadRegistry(root, evalReader(loader), scan.reqsFiles);
702
+ if (hasError(issues))
703
+ return { issues };
440
704
  const deltaPath = changeDeltaPath(root, changeName);
441
705
  let delta;
442
706
  try {
@@ -453,21 +717,22 @@ export async function runArchive(root, changeName, options = {}) {
453
717
  delta = mod.default;
454
718
  }
455
719
  catch (err) {
456
- return [changeNotFoundIssue(root, deltaPath, changeName, err)];
720
+ return { issues: [changeNotFoundIssue(root, deltaPath, changeName, err)] };
457
721
  }
458
722
  const applied = applyDelta(base, delta);
459
723
  if (applied.issues.length > 0)
460
- return applied.issues;
724
+ return { issues: applied.issues };
461
725
  // Static plan = merged base suite + this change's specs (design §8), by the
462
726
  // same function `status` reports against — a progress report computed over a
463
727
  // different spec set than the gate uses would be a report about nothing.
464
- const plan = await changeMergedPlan(root, delta, scan);
728
+ const { merged: plan, claimed } = await changeMergedPlan(root, delta, scan);
465
729
  // Other proposals need no exclude glob of their own: the include list below
466
730
  // is the plan's own files, and the plan holds only the proposed specs this
467
731
  // delta claims. That is what replaced `**/changes/<sibling>/**` — with the
468
732
  // specs no longer living under `changes/`, a directory glob could not have
469
733
  // told two proposals apart, and one kept alongside the claim check would be
470
734
  // a second scoping rule able to disagree with it.
735
+ const { BASE_EXCLUDE } = await import('./runner.js');
471
736
  const exclude = [...BASE_EXCLUDE, '**/archive/**'];
472
737
  // Same run scope as `verify`: only the files that declare a requirement().
473
738
  // The gate must not go red because a repo's incumbent suite happens to sit
@@ -498,14 +763,28 @@ export async function runArchive(root, changeName, options = {}) {
498
763
  // Against `base`, not `applied`: the child run imported the registry from
499
764
  // disk, so what matters is what that file has, not what the gate computed.
500
765
  const unmergedAddedIds = added.filter((id) => !Object.hasOwn(base, id));
501
- return evaluateGate({
502
- registry: applied.registry,
503
- plan,
504
- run,
505
- addedIds: added,
506
- unmergedAddedIds,
507
- firstRun,
508
- });
766
+ return {
767
+ issues: evaluateGate({
768
+ registry: applied.registry,
769
+ plan,
770
+ run,
771
+ addedIds: added,
772
+ unmergedAddedIds,
773
+ firstRun,
774
+ }),
775
+ merge: {
776
+ delta,
777
+ base,
778
+ applied: applied.registry,
779
+ prefixOwners,
780
+ // The plan's proposed files, not every proposed file on disk: `--apply`
781
+ // renames what *this* change claims, and a sibling proposal's spec is
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),
785
+ mergedSpecs: scan.specFiles,
786
+ },
787
+ };
509
788
  }
510
789
  finally {
511
790
  await loader.close();