@am_shork/attest 0.8.0 → 0.9.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/README.md CHANGED
@@ -125,7 +125,8 @@ attest verify # run tests + coverage + drift, graded report
125
125
  attest cover # which requirements lack a scenario
126
126
  attest render # the requirements as Markdown, for people who don't read TS
127
127
  attest archive <change> # gate a proposed change: green + covered + no drift
128
- attest status <change> # what that gate still wants, without running anything
128
+ attest status <change> # per added id: scenario written? seen red? (part of that
129
+ # gate, without running anything — never a verdict)
129
130
  ```
130
131
 
131
132
  Every command takes the project root as an optional last argument, and `--json`
@@ -166,7 +167,7 @@ Every diagnostic carries a `code`, and every code has a section in
166
167
  ```
167
168
  ERROR registry-not-static (requirements/upload.reqs.ts:5)
168
169
  Value is not a literal.
169
- → https://gitlab.com/Pseudorca/attest/-/blob/v0.8.0/docs/en/troubleshooting.md#registry-not-static
170
+ → https://gitlab.com/Pseudorca/attest/-/blob/v0.9.0/docs/en/troubleshooting.md#registry-not-static
170
171
  ```
171
172
 
172
173
  The anchor **is** the code, so the link cannot point somewhere the section
@@ -190,7 +190,15 @@ export function formatStatus(result) {
190
190
  }
191
191
  const { proven } = result.counts;
192
192
  lines.push(chalk.dim(`— ${plural(result.rows.length, 'added requirement')}, ${proven} ready to archive`));
193
- lines.push(chalk.dim(`Not a verdict: run \`attest archive ${inline(result.change)}\` to run the suite.`));
193
+ // Two pointers, because this command answers less than a reader assumes on
194
+ // both sides. `archive` is the verdict it deliberately cannot give. `check` is
195
+ // the half it deliberately does not duplicate: a proposed spec no delta
196
+ // claims, or one whose merged name is taken, is a static fact about this
197
+ // change that `check` already reports — and reporting it here too would put a
198
+ // second answer to one question in the tree, which is what this project takes
199
+ // apart everywhere else. Naming it is the whole cost of not absorbing it.
200
+ lines.push(chalk.dim(`Not a verdict: run \`attest archive ${inline(result.change)}\` to run the suite, ` +
201
+ `\`attest check\` for the proposed specs themselves.`));
194
202
  return lines.join('\n');
195
203
  }
196
204
  /**
@@ -1,5 +1,5 @@
1
1
  import type { RegistryDelta } from './registry.js';
2
- import type { Issue, Registry } from './types.js';
2
+ import type { Issue, Registry, Requirement } from './types.js';
3
3
  export interface ApplyResult {
4
4
  registry: Registry;
5
5
  issues: Issue[];
@@ -12,6 +12,14 @@ export interface ApplyResult {
12
12
  * than off `delta.added`, so the two can never be scoped differently.
13
13
  */
14
14
  export declare function addedIds(d: RegistryDelta): string[];
15
+ /**
16
+ * The ids a delta MODIFIEs — the scope of `--apply`'s field-level write-back.
17
+ *
18
+ * Here for the reason `addedIds` is: two places ask, the merge that writes them
19
+ * and the refusal that checks their prefix is owned, and a delta's operations
20
+ * must not be enumerated twice.
21
+ */
22
+ export declare function modifiedIds(d: RegistryDelta): string[];
15
23
  /**
16
24
  * The ids a delta **claims**: what it adds, renames to, or modifies.
17
25
  *
@@ -33,4 +41,13 @@ export declare function claimedIds(d: RegistryDelta): string[];
33
41
  * base registry is never mutated. Applying the same delta twice is a no-op.
34
42
  */
35
43
  export declare function applyDelta(base: Registry, d: RegistryDelta): ApplyResult;
44
+ /**
45
+ * Content equality via canonical JSON (params key order does not matter).
46
+ *
47
+ * Exported because `--apply` asks the same question twice — whether a modified
48
+ * entry is already what the end state says, and whether the bytes it wrote read
49
+ * back as it — and a second spelling of "the same requirement" is a second
50
+ * answer the moment `Requirement` gains a field.
51
+ */
52
+ export declare function sameRequirement(a: Requirement, b: Requirement): boolean;
36
53
  //# sourceMappingURL=apply.d.ts.map
@@ -14,6 +14,16 @@ import { byCodeUnit, sortDeep } from './order.js';
14
14
  export function addedIds(d) {
15
15
  return Object.keys(d.added ?? {});
16
16
  }
17
+ /**
18
+ * The ids a delta MODIFIEs — the scope of `--apply`'s field-level write-back.
19
+ *
20
+ * Here for the reason `addedIds` is: two places ask, the merge that writes them
21
+ * and the refusal that checks their prefix is owned, and a delta's operations
22
+ * must not be enumerated twice.
23
+ */
24
+ export function modifiedIds(d) {
25
+ return Object.keys(d.modified ?? {}).sort(byCodeUnit);
26
+ }
17
27
  /**
18
28
  * The ids a delta **claims**: what it adds, renames to, or modifies.
19
29
  *
@@ -177,8 +187,15 @@ function firstMessage(error) {
177
187
  const path = first.path.map(String).join('.');
178
188
  return path ? `${path}: ${first.message}` : first.message;
179
189
  }
180
- /** Content equality via canonical JSON (params key order does not matter). */
181
- function sameRequirement(a, b) {
190
+ /**
191
+ * Content equality via canonical JSON (params key order does not matter).
192
+ *
193
+ * Exported because `--apply` asks the same question twice — whether a modified
194
+ * entry is already what the end state says, and whether the bytes it wrote read
195
+ * back as it — and a second spelling of "the same requirement" is a second
196
+ * answer the moment `Requirement` gains a field.
197
+ */
198
+ export function sameRequirement(a, b) {
182
199
  return canonical(a) === canonical(b);
183
200
  }
184
201
  function canonical(req) {
@@ -27,6 +27,20 @@ export declare const isSpecFile: (name: string) => boolean;
27
27
  * serial `for await` spent one filesystem round-trip per directory, which is
28
28
  * the dominant cost of `check` on a large repo. The result is sorted, so the
29
29
  * order never depends on which `readdir` happened to resolve first.
30
+ *
31
+ * Concurrency is bounded, for the reason `parseSpecs` bounds its own: the input
32
+ * size is not ours to choose, `check` being what this project tells people to
33
+ * run first on an untrusted fork MR. What must not come back is a fan-out whose
34
+ * peak is the shape of the tree rather than a constant — which is what
35
+ * recursing through `Promise.all(subdirs)` gave, and what
36
+ * `tests/locate-fanout.spec.ts` pins.
37
+ *
38
+ * A level at a time, rather than one pool over a queue that grows as directories
39
+ * are discovered: the pool would have to keep workers alive while the queue is
40
+ * momentarily empty but another worker may still push to it, and that
41
+ * termination condition is the part worth not owning. The cost is a barrier per
42
+ * depth, which is paid in tree *depth* — small, and bounded by the filesystem —
43
+ * while the fan-out being bounded is paid in tree *width*, which is not.
30
44
  */
31
45
  export declare function findFiles(root: string, match: (name: string) => boolean): Promise<string[]>;
32
46
  /** The file sets every command needs, collected in one pass. */
@@ -167,10 +181,11 @@ export declare function idPrefix(id: string): string;
167
181
  * avoid: input size is not ours to choose here, `check` being what this project
168
182
  * tells people to run first on an untrusted fork MR. Measured in `[0.7.0]`.
169
183
  *
170
- * `findFiles` above is deliberately left unbounded: its fan-out is real, but the
171
- * failure it invites is descriptor exhaustion, which no measurement on either
172
- * development platform could produce (see CHANGELOG.md, `Under consideration`).
173
- * The memory here needed no such evidence it is arithmetic, and portable.
184
+ * `findFiles` above bounds its own fan-out the same way and through the same
185
+ * helper. It was left unbounded when this one was capped, on the grounds that
186
+ * the failure it invited — descriptor exhaustion — could not be produced on
187
+ * either development platform; what closed it is that the peak itself is
188
+ * portable arithmetic, which is the standard this half was accepted on.
174
189
  */
175
190
  export declare function parseSpecs(files: string[], displayRoot: string): Promise<{
176
191
  plan: AttestPlan;
@@ -40,6 +40,44 @@ export const isProposedSpecFile = (name) => name.endsWith('.proposed.spec.ts');
40
40
  * of them until its gate passes.
41
41
  */
42
42
  export const isSpecFile = (name) => name.endsWith('.spec.ts') && !isProposedSpecFile(name);
43
+ /**
44
+ * Run `fn` over every item with at most `limit` of them in flight.
45
+ *
46
+ * Extracted at the second call site rather than the first: this file has two
47
+ * unbounded fan-outs to close, one over directories and one over files, and
48
+ * a shape written twice is a shape one of the two copies will eventually be
49
+ * fixed without.
50
+ *
51
+ * The order `fn` is *called* in is the input order; the order it *completes* in
52
+ * is not, so a caller that needs a stable result either indexes into a
53
+ * preallocated array by `index` or sorts afterwards. Both callers here do one of
54
+ * those, deliberately.
55
+ *
56
+ * No result is collected and none is needed — both callers write into something
57
+ * they already own, and a version returning `T[]` would have to choose an
58
+ * ordering on their behalf. A throw from `fn` propagates and abandons the rest,
59
+ * which is the existing behaviour at both sites: `parseSpecs` catches per file
60
+ * so that one hostile spec scraps only itself (ATX-65), and a failed `readdir`
61
+ * really does end the walk.
62
+ */
63
+ async function forEachBounded(items, limit, fn) {
64
+ let cursor = 0;
65
+ const worker = async () => {
66
+ for (let i = cursor++; i < items.length; i = cursor++) {
67
+ await fn(items[i], i);
68
+ }
69
+ };
70
+ await Promise.all(Array.from({ length: Math.min(limit, items.length) }, worker));
71
+ }
72
+ /**
73
+ * How many directories are open at once during the walk. The same figure as
74
+ * `PARSE_CONCURRENCY` and deliberately not the same constant: the two bound
75
+ * different resources, and sharing one would make either impossible to tune
76
+ * without moving the other. High enough that the walk stays I/O bound on any
77
+ * real project, low enough that what is in flight is a constant rather than the
78
+ * shape of the input.
79
+ */
80
+ const WALK_CONCURRENCY = 32;
43
81
  /**
44
82
  * Recursively find files under root whose basename matches `match`.
45
83
  *
@@ -47,25 +85,41 @@ export const isSpecFile = (name) => name.endsWith('.spec.ts') && !isProposedSpec
47
85
  * serial `for await` spent one filesystem round-trip per directory, which is
48
86
  * the dominant cost of `check` on a large repo. The result is sorted, so the
49
87
  * order never depends on which `readdir` happened to resolve first.
88
+ *
89
+ * Concurrency is bounded, for the reason `parseSpecs` bounds its own: the input
90
+ * size is not ours to choose, `check` being what this project tells people to
91
+ * run first on an untrusted fork MR. What must not come back is a fan-out whose
92
+ * peak is the shape of the tree rather than a constant — which is what
93
+ * recursing through `Promise.all(subdirs)` gave, and what
94
+ * `tests/locate-fanout.spec.ts` pins.
95
+ *
96
+ * A level at a time, rather than one pool over a queue that grows as directories
97
+ * are discovered: the pool would have to keep workers alive while the queue is
98
+ * momentarily empty but another worker may still push to it, and that
99
+ * termination condition is the part worth not owning. The cost is a barrier per
100
+ * depth, which is paid in tree *depth* — small, and bounded by the filesystem —
101
+ * while the fan-out being bounded is paid in tree *width*, which is not.
50
102
  */
51
103
  export async function findFiles(root, match) {
52
104
  const out = [];
53
- const walk = async (dir) => {
54
- const entries = await readdir(dir, { withFileTypes: true });
55
- const subdirs = [];
56
- for (const e of entries) {
57
- const full = join(dir, e.name);
58
- if (e.isDirectory()) {
59
- if (!SKIP_DIRS.has(e.name))
60
- subdirs.push(full);
61
- }
62
- else if (e.isFile() && match(e.name)) {
63
- out.push(full);
105
+ let level = [root];
106
+ while (level.length > 0) {
107
+ const next = [];
108
+ await forEachBounded(level, WALK_CONCURRENCY, async (dir) => {
109
+ const entries = await readdir(dir, { withFileTypes: true });
110
+ for (const e of entries) {
111
+ const full = join(dir, e.name);
112
+ if (e.isDirectory()) {
113
+ if (!SKIP_DIRS.has(e.name))
114
+ next.push(full);
115
+ }
116
+ else if (e.isFile() && match(e.name)) {
117
+ out.push(full);
118
+ }
64
119
  }
65
- }
66
- await Promise.all(subdirs.map(walk));
67
- };
68
- await walk(root);
120
+ });
121
+ level = next;
122
+ }
69
123
  return out.sort(byCodeUnit);
70
124
  }
71
125
  /**
@@ -362,10 +416,11 @@ const PARSE_CONCURRENCY = 32;
362
416
  * avoid: input size is not ours to choose here, `check` being what this project
363
417
  * tells people to run first on an untrusted fork MR. Measured in `[0.7.0]`.
364
418
  *
365
- * `findFiles` above is deliberately left unbounded: its fan-out is real, but the
366
- * failure it invites is descriptor exhaustion, which no measurement on either
367
- * development platform could produce (see CHANGELOG.md, `Under consideration`).
368
- * The memory here needed no such evidence it is arithmetic, and portable.
419
+ * `findFiles` above bounds its own fan-out the same way and through the same
420
+ * helper. It was left unbounded when this one was capped, on the grounds that
421
+ * the failure it invited — descriptor exhaustion — could not be produced on
422
+ * either development platform; what closed it is that the peak itself is
423
+ * portable arithmetic, which is the standard this half was accepted on.
369
424
  */
370
425
  export async function parseSpecs(files, displayRoot) {
371
426
  // Indexed rather than appended, so the merge below follows the input order
@@ -374,24 +429,19 @@ export async function parseSpecs(files, displayRoot) {
374
429
  // Same index space, so a file contributes either a parse or an issue and the
375
430
  // two lists cannot disagree about which file is which.
376
431
  const failures = new Array(files.length);
377
- let next = 0;
378
- const worker = async () => {
379
- for (let i = next++; i < files.length; i = next++) {
380
- const file = files[i];
381
- const display = relativePath(displayRoot, file);
382
- // Per file, for the reason `readGuarded` exists above: these run
383
- // concurrently, so one throw rejects the whole `Promise.all` and takes
384
- // the command with it. A hostile spec scraps only itself (ATX-65).
385
- try {
386
- parsed[i] = parseSpecFile(display, await readFile(file, 'utf8'));
387
- }
388
- catch (err) {
389
- parsed[i] = { scenarios: [], paramRefs: [] };
390
- failures[i] = { ...unreadableIssue(err), file: display };
391
- }
432
+ await forEachBounded(files, PARSE_CONCURRENCY, async (file, i) => {
433
+ const display = relativePath(displayRoot, file);
434
+ // Per file, for the reason `readGuarded` exists above: these run
435
+ // concurrently, so one throw would abandon the rest and take the command
436
+ // with it. A hostile spec scraps only itself (ATX-65).
437
+ try {
438
+ parsed[i] = parseSpecFile(display, await readFile(file, 'utf8'));
392
439
  }
393
- };
394
- await Promise.all(Array.from({ length: Math.min(PARSE_CONCURRENCY, files.length) }, worker));
440
+ catch (err) {
441
+ parsed[i] = { scenarios: [], paramRefs: [] };
442
+ failures[i] = { ...unreadableIssue(err), file: display };
443
+ }
444
+ });
395
445
  const plan = { scenarios: [], paramRefs: [] };
396
446
  for (const one of parsed) {
397
447
  plan.scenarios.push(...one.scenarios);
@@ -4,8 +4,11 @@
4
4
  // until now a human then transcribed it by hand with nothing checking the
5
5
  // transcription. This is that step, and the whole of why it is allowed to exist
6
6
  // where the `AGENTS.md` merge tool was not: the registry is a literal Attest
7
- // defines, so the result of an edit is checkable by re-reading it, and the edit
8
- // itself is a pure insertion (`splice.ts`).
7
+ // defines, so the result of an edit is checkable by re-reading it and this
8
+ // re-reads it (`verifyWritten`), rather than resting on the sentence. The edits
9
+ // are bounded to match: an ADDED requirement is a pure insertion, and a MODIFIED
10
+ // one replaces the span of a single value inside an entry it never rewrites
11
+ // (`splice.ts`).
9
12
  //
10
13
  // **It is re-runnable, not atomic.** No primitive spans one edit, N renames and
11
14
  // a directory move, and a scratch copy of the project root would have to be
@@ -30,10 +33,11 @@
30
33
  // at runtime on the happy path, and a later reordering would look harmless.
31
34
  import { mkdir, readFile, rename, stat } from 'node:fs/promises';
32
35
  import { join, dirname, basename } from 'node:path';
33
- import { repointImport, spliceRequirements, UnwritableValue } from './splice.js';
36
+ import { repointImport, spliceModifications, spliceRequirements, UnwritableValue, } from './splice.js';
37
+ import { readRegistrySource } from './static-registry.js';
34
38
  import { writeAtomic } from './write.js';
35
39
  import { idPrefix } from './locate.js';
36
- import { addedIds } from './apply.js';
40
+ import { addedIds, modifiedIds, sameRequirement } from './apply.js';
37
41
  import { byCodeUnit } from './order.js';
38
42
  import { relativePath } from './paths.js';
39
43
  /** `x.proposed.spec.ts` -> `x.spec.ts`, in place. */
@@ -48,8 +52,8 @@ export function mergedSpecPath(proposed) {
48
52
  * acting on it, and a merge that did the half it understood would put the file
49
53
  * into a state no verdict describes.
50
54
  */
51
- async function refusals(input, stamp) {
52
- const issues = [];
55
+ async function refusals(input, stamp, planned) {
56
+ const issues = [...planned];
53
57
  const { delta, root, changeName } = input;
54
58
  const archive = archivePath(input, stamp);
55
59
  // 0) The destination already exists. Checked here, before anything is
@@ -67,28 +71,34 @@ async function refusals(input, stamp) {
67
71
  });
68
72
  }
69
73
  // 1) Operations this does not perform. The gate applies all four in memory to
70
- // reach its verdict; only writing them back is limited to ADDED, because
71
- // REMOVED cannot say which comments belonged to the entry it deletes and
72
- // MODIFIED is a formatting-preserving edit inside an existing literal. Both
73
- // are the "destructive on a file the user cannot regenerate" shape.
74
+ // reach its verdict; writing them back covers ADDED and MODIFIED, and stops
75
+ // there because REMOVED cannot say which comments belonged to the entry it
76
+ // deletes the "destructive on a file the user cannot regenerate" shape.
77
+ // MODIFIED was refused by this same clause until it was written at the
78
+ // granularity of a *value* rather than an entry, which is where that sentence
79
+ // stops applying and REMOVED's does not: an entry being deleted has no smaller
80
+ // span to fall back to (`splice.ts`).
74
81
  const unsupported = [
75
82
  delta.renamed?.length ? 'renamed' : '',
76
83
  delta.removed?.length ? 'removed' : '',
77
- Object.keys(delta.modified ?? {}).length ? 'modified' : '',
78
84
  ].filter(Boolean);
79
85
  if (unsupported.length > 0) {
80
86
  issues.push({
81
87
  level: 'ERROR',
82
88
  code: 'apply-unsupported-delta',
83
89
  file: relativePath(root, join(root, 'changes', changeName)),
84
- message: `--apply writes back ADDED requirements only, and this change's delta also carries ${unsupported.join(', ')}. ` +
90
+ message: `--apply writes back ADDED and MODIFIED requirements, and this change's delta also carries ${unsupported.join(', ')}. ` +
85
91
  `The gate above still checked all of it — merge the remaining operations into the registry by hand, then run this command again to confirm.`,
86
92
  });
87
93
  }
88
- // 2) An added id whose prefix no registry file claims. Which file it belongs
94
+ // 2) An id whose prefix no registry file claims. Which file it belongs
89
95
  // in — or whether a file should be created for it — is not something the gate
90
- // verified, and guessing would file a requirement somewhere nobody chose.
91
- for (const id of unmergedAdded(input)) {
96
+ // verified, and guessing would file a requirement somewhere nobody chose. Over
97
+ // modified ids as well as added ones: an unowned prefix is why the merge cannot
98
+ // find the file, and reporting it as "no entry to modify" would send the reader
99
+ // to look inside a file this never opened.
100
+ const unowned = new Set([...unmergedAdded(input), ...modifiedIds(input.delta)]);
101
+ for (const id of [...unowned].sort(byCodeUnit)) {
92
102
  if (input.prefixOwners[idPrefix(id)] === undefined) {
93
103
  issues.push({
94
104
  level: 'ERROR',
@@ -146,6 +156,182 @@ function registryTargets(input, reqIds) {
146
156
  function unmergedAdded(input) {
147
157
  return addedIds(input.delta).filter((id) => !Object.hasOwn(input.base, id));
148
158
  }
159
+ /**
160
+ * The requirements this change modifies that the registry on disk does not
161
+ * already hold in its end state.
162
+ *
163
+ * The MODIFIED half of re-runnability, and deliberately the same shape as
164
+ * `unmergedAdded`: derived from the tree as it currently is rather than from a
165
+ * list of what a previous run meant to do. An entry already equal to `applied`
166
+ * is not edited, so a re-run after a crash writes what is left and nothing else
167
+ * — and a delta restating a value it does not change writes nothing at all.
168
+ */
169
+ function unmergedModified(input) {
170
+ const out = [];
171
+ for (const id of modifiedIds(input.delta)) {
172
+ const before = input.base[id];
173
+ const after = input.applied[id];
174
+ // A modified id the base does not have is `modify-missing`, which the gate
175
+ // reports and this never reaches; skipped rather than diagnosed a second
176
+ // time, because two commands answering for one condition is how they come to
177
+ // disagree.
178
+ if (!before || !after || sameRequirement(before, after))
179
+ continue;
180
+ out.push({ id, before, after });
181
+ }
182
+ return out;
183
+ }
184
+ /**
185
+ * What every registry file should end up holding, or why it cannot — computed
186
+ * without writing anything.
187
+ *
188
+ * Split from the write for one reason: a refusal that fires after two files have
189
+ * landed makes "refused whole" a claim with an exception in it, which is the
190
+ * kind of claim nobody can rely on (the note on `refusals` step 0 says the same
191
+ * thing about the archive destination). Both edits to a file are composed here,
192
+ * modification before splice, and the splice re-reads the text the modification
193
+ * produced rather than an offset taken before it — an insertion point is a
194
+ * position in a file, and the modification has just moved bytes in front of it.
195
+ */
196
+ async function planRegistries(input) {
197
+ const { root } = input;
198
+ const issues = [];
199
+ const added = new Map();
200
+ const modified = new Map();
201
+ for (const id of unmergedAdded(input)) {
202
+ const file = input.prefixOwners[idPrefix(id)];
203
+ if (!file)
204
+ continue; // Refused as `apply-no-prefix-owner`; not diagnosed twice.
205
+ const group = added.get(file) ?? {};
206
+ group[id] = input.applied[id];
207
+ added.set(file, group);
208
+ }
209
+ for (const change of unmergedModified(input)) {
210
+ const file = input.prefixOwners[idPrefix(change.id)];
211
+ if (!file)
212
+ continue;
213
+ modified.set(file, [...(modified.get(file) ?? []), change]);
214
+ }
215
+ const files = [];
216
+ for (const file of [...new Set([...added.keys(), ...modified.keys()])].sort(byCodeUnit)) {
217
+ const source = await readFile(file, 'utf8');
218
+ const mods = modified.get(file) ?? [];
219
+ const adds = added.get(file);
220
+ const ids = [...mods.map((m) => m.id), ...Object.keys(adds ?? {})].sort(byCodeUnit);
221
+ const changed = spliceModifications(file, source, mods);
222
+ if (!changed) {
223
+ issues.push(unreadable(root, file));
224
+ continue;
225
+ }
226
+ if (!changed.ok) {
227
+ issues.push(...changed.refusals.map((r) => modifyRefusal(root, file, r)));
228
+ continue;
229
+ }
230
+ let text = changed.source;
231
+ if (adds) {
232
+ try {
233
+ const spliced = spliceRequirements(file, text, adds);
234
+ if (spliced === undefined) {
235
+ issues.push(unreadable(root, file));
236
+ continue;
237
+ }
238
+ text = spliced;
239
+ }
240
+ catch (err) {
241
+ // The emitter refused a value it cannot write as source — today only a
242
+ // `__proto__` param key, which the schema rejects before `--apply` runs.
243
+ // Caught rather than left to the CLI's crash envelope because `--apply`
244
+ // is destructive and a bare stack is the shape a resume cannot read.
245
+ if (!(err instanceof UnwritableValue))
246
+ throw err;
247
+ issues.push({
248
+ level: 'ERROR',
249
+ code: 'internal-error',
250
+ file: relativePath(root, file),
251
+ message: `${relativePath(root, file)} could not be written: ${err.message}.`,
252
+ });
253
+ continue;
254
+ }
255
+ }
256
+ files.push({ file, source, text, ids });
257
+ }
258
+ return { files, issues };
259
+ }
260
+ /**
261
+ * Whether the file just written reads back as the requirements it was written
262
+ * from — the check the whole argument for editing a hand-written registry rests
263
+ * on, performed rather than argued.
264
+ *
265
+ * "The result of an edit is checkable by re-reading it" is the clause that
266
+ * separates this from the merge tool the `AGENTS.md` proposal was rejected for,
267
+ * and until MODIFIED existed nothing did the re-reading: a pure insertion is
268
+ * right by construction, so the sentence was load-bearing without being
269
+ * exercised. A replacement is not, so the re-read is here for both — the added
270
+ * entries included, because a check that covered only the newer half would leave
271
+ * the older claim in exactly the state this is fixing.
272
+ *
273
+ * What it cannot see is the other half, and saying so is the point: a re-read
274
+ * compares *values*, and a comment or a blank line that went missing does not
275
+ * appear in a `Registry` at all. That is why the edit is bounded to the span of
276
+ * one value rather than trusted to this (`splice.ts`).
277
+ */
278
+ async function verifyWritten(input, plan) {
279
+ const { root } = input;
280
+ const back = readRegistrySource(plan.file, await readFile(plan.file, 'utf8'));
281
+ const wrong = back.ok
282
+ ? plan.ids.filter((id) => {
283
+ const written = back.registry[id];
284
+ const proved = input.applied[id];
285
+ return !written || !proved || !sameRequirement(written, proved);
286
+ })
287
+ : plan.ids;
288
+ if (wrong.length === 0)
289
+ return undefined;
290
+ return {
291
+ level: 'ERROR',
292
+ code: 'internal-error',
293
+ file: relativePath(root, plan.file),
294
+ message: `${relativePath(root, plan.file)} was written, but reading it back does not give the requirements the gate proved (${wrong.join(', ')}). ` +
295
+ `Check that file against the change's delta before running anything else — the merge stopped here, so the steps after it have not run.`,
296
+ };
297
+ }
298
+ /** The registry the gate read a moment ago, unreadable to the merge. */
299
+ function unreadable(root, file) {
300
+ // Unreachable through the command, so it is reported as the internal
301
+ // inconsistency it is rather than as a diagnosis about the user's registry.
302
+ return {
303
+ level: 'ERROR',
304
+ code: 'internal-error',
305
+ file: relativePath(root, file),
306
+ message: `${relativePath(root, file)} read as a registry for the gate but not for the merge.`,
307
+ };
308
+ }
309
+ /**
310
+ * One value the merge declined to write over, as a diagnostic.
311
+ *
312
+ * The code is the one an unsupported operation already carries: to a consumer
313
+ * branching on `code`, "this delta has a part `--apply` does not write" is the
314
+ * same fact whichever part it is, and the narrowing that made most MODIFIED
315
+ * deltas writable should not cost anyone a new string to handle. The `reqId` and
316
+ * the field are what changed, and both are in the message where the reader is.
317
+ */
318
+ function modifyRefusal(root, file, refusal) {
319
+ const where = refusal.field ? `${refusal.reqId}.${refusal.field}` : refusal.reqId;
320
+ const why = {
321
+ comment: `a comment sits inside the value it would replace, and which side of that edit the comment belongs to is not something --apply can decide`,
322
+ 'entry-not-found': `the registry file that owns its prefix holds no entry for it`,
323
+ 'not-a-literal': `the value it would replace is not written as a literal`,
324
+ 'param-dropped': `the proved end state does not carry that param, and a delta has no way to say one was removed`,
325
+ };
326
+ return {
327
+ level: 'ERROR',
328
+ code: 'apply-unsupported-delta',
329
+ reqId: refusal.reqId,
330
+ file: relativePath(root, file),
331
+ message: `--apply cannot write "${where}" into ${relativePath(root, file)}: ${why[refusal.reason]}. ` +
332
+ `Merge this requirement by hand, then run this command again to confirm — nothing has been written.`,
333
+ };
334
+ }
149
335
  /**
150
336
  * Perform the merge, or refuse it whole.
151
337
  *
@@ -163,67 +349,27 @@ export async function applyMerge(input) {
163
349
  // another — rare, and silent when it happens, which is the combination this
164
350
  // repository treats as worth the line.
165
351
  const stamp = new Date().toISOString().slice(0, 10);
166
- const refused = await refusals(input, stamp);
352
+ // Every registry edit is computed before any of them is written, which is what
353
+ // makes "refused whole" true of the write-back and not only of the delta's
354
+ // shape: a comment sitting where a modification would land, or a value the
355
+ // emitter cannot write, is now found with the tree still untouched. Before
356
+ // MODIFIED existed the text generation could only fail on a `__proto__` key
357
+ // and it failed one file at a time, so this was the same claim by luck.
358
+ const planned = await planRegistries(input);
359
+ const refused = await refusals(input, stamp, planned.issues);
167
360
  if (refused.length > 0)
168
361
  return { issues: refused, written: [] };
169
362
  const { root } = input;
170
363
  const written = [];
171
- // --- 1) Splice, first. See the note at the top of this file.
172
- const byFile = new Map();
173
- for (const id of unmergedAdded(input)) {
174
- const file = input.prefixOwners[idPrefix(id)];
175
- const group = byFile.get(file) ?? {};
176
- group[id] = input.applied[id];
177
- byFile.set(file, group);
178
- }
179
- for (const file of [...byFile.keys()].sort(byCodeUnit)) {
180
- const source = await readFile(file, 'utf8');
181
- let spliced;
182
- try {
183
- spliced = spliceRequirements(file, source, byFile.get(file));
184
- }
185
- catch (err) {
186
- // The emitter refused a value it cannot write as source — today only a
187
- // `__proto__` param key, which the schema rejects before `--apply` runs.
188
- // Caught rather than left to the CLI's crash envelope so the account of
189
- // what this merge had already written survives: `--apply` is destructive
190
- // and half a merge reported as a bare stack is the shape a resume cannot
191
- // read. The write for *this* file has not happened — the throw is in the
192
- // text generation, above `writeAtomic`.
193
- if (!(err instanceof UnwritableValue))
194
- throw err;
195
- return {
196
- issues: [
197
- {
198
- level: 'ERROR',
199
- code: 'internal-error',
200
- file: relativePath(root, file),
201
- message: `${relativePath(root, file)} could not be written: ${err.message}.`,
202
- },
203
- ],
204
- written,
205
- };
206
- }
207
- if (spliced === undefined) {
208
- // Unreachable through the command — the gate read this file as a literal
209
- // moments ago — so it is reported as the internal inconsistency it is
210
- // rather than as a diagnosis about the user's registry.
211
- return {
212
- issues: [
213
- {
214
- level: 'ERROR',
215
- code: 'internal-error',
216
- file: relativePath(root, file),
217
- message: `${relativePath(root, file)} read as a registry for the gate but not for the merge.`,
218
- },
219
- ],
220
- written,
221
- };
222
- }
223
- if (spliced !== source) {
224
- await writeAtomic(file, spliced);
225
- written.push(relativePath(root, file));
226
- }
364
+ // --- 1) Write the registries, first. See the note at the top of this file.
365
+ for (const plan of planned.files) {
366
+ if (plan.text === plan.source)
367
+ continue;
368
+ await writeAtomic(plan.file, plan.text);
369
+ written.push(relativePath(root, plan.file));
370
+ const mismatch = await verifyWritten(input, plan);
371
+ if (mismatch)
372
+ return { issues: [mismatch], written };
227
373
  }
228
374
  // --- 2) Repoint each claimed spec's delta import, then rename it in place.
229
375
  //