@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/CHANGELOG.md +715 -53
- package/README.md +3 -2
- package/dist/cli/report.js +9 -1
- package/dist/core/apply.d.ts +18 -1
- package/dist/core/apply.js +19 -2
- package/dist/core/locate.d.ts +19 -4
- package/dist/core/locate.js +86 -36
- package/dist/core/merge.js +218 -72
- package/dist/core/pipeline.js +45 -23
- package/dist/core/skill.js +88 -27
- package/dist/core/splice.d.ts +62 -3
- package/dist/core/splice.js +252 -13
- package/dist/core/static-registry.d.ts +42 -0
- package/dist/core/static-registry.js +131 -17
- package/dist/core/validator.d.ts +1 -0
- package/dist/core/validator.js +22 -0
- package/package.json +1 -1
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> #
|
|
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.
|
|
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
|
package/dist/cli/report.js
CHANGED
|
@@ -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
|
-
|
|
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
|
/**
|
package/dist/core/apply.d.ts
CHANGED
|
@@ -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
|
package/dist/core/apply.js
CHANGED
|
@@ -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
|
-
/**
|
|
181
|
-
|
|
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) {
|
package/dist/core/locate.d.ts
CHANGED
|
@@ -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
|
|
171
|
-
*
|
|
172
|
-
*
|
|
173
|
-
*
|
|
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;
|
package/dist/core/locate.js
CHANGED
|
@@ -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
|
-
|
|
54
|
-
|
|
55
|
-
const
|
|
56
|
-
|
|
57
|
-
const
|
|
58
|
-
|
|
59
|
-
|
|
60
|
-
|
|
61
|
-
|
|
62
|
-
|
|
63
|
-
|
|
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
|
-
|
|
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
|
|
366
|
-
*
|
|
367
|
-
*
|
|
368
|
-
*
|
|
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
|
-
|
|
378
|
-
|
|
379
|
-
|
|
380
|
-
|
|
381
|
-
|
|
382
|
-
|
|
383
|
-
|
|
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
|
-
|
|
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);
|
package/dist/core/merge.js
CHANGED
|
@@ -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
|
|
8
|
-
//
|
|
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;
|
|
71
|
-
// REMOVED cannot say which comments belonged to the entry it
|
|
72
|
-
//
|
|
73
|
-
//
|
|
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
|
|
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
|
|
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
|
-
|
|
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
|
-
|
|
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)
|
|
172
|
-
const
|
|
173
|
-
|
|
174
|
-
|
|
175
|
-
|
|
176
|
-
|
|
177
|
-
|
|
178
|
-
|
|
179
|
-
|
|
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
|
//
|