@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.
- package/CHANGELOG.md +1063 -143
- package/README.md +1 -1
- package/dist/cli/index.js +13 -11
- package/dist/cli/json.js +6 -1
- package/dist/cli/report.d.ts +18 -0
- package/dist/cli/report.js +41 -0
- package/dist/core/compiler.d.ts +40 -0
- package/dist/core/compiler.js +64 -0
- package/dist/core/docs.d.ts +1 -1
- package/dist/core/docs.js +6 -0
- package/dist/core/gate.d.ts +48 -2
- package/dist/core/gate.js +73 -14
- package/dist/core/loader.js +13 -0
- package/dist/core/locate.d.ts +62 -0
- package/dist/core/locate.js +94 -11
- package/dist/core/merge.d.ts +54 -0
- package/dist/core/merge.js +257 -0
- package/dist/core/pipeline.d.ts +13 -0
- package/dist/core/pipeline.js +319 -40
- package/dist/core/render.js +129 -14
- package/dist/core/req-suite.d.ts +5 -0
- package/dist/core/req-suite.js +27 -0
- package/dist/core/runner.js +26 -8
- package/dist/core/skill.js +52 -12
- package/dist/core/splice.d.ts +52 -0
- package/dist/core/splice.js +189 -0
- package/dist/core/static-registry.d.ts +30 -0
- package/dist/core/static-registry.js +36 -0
- package/dist/core/status.js +21 -3
- package/dist/core/terminal.d.ts +23 -0
- package/dist/core/terminal.js +25 -9
- package/dist/core/validator.d.ts +5 -1
- package/dist/core/validator.js +23 -2
- package/dist/runtime.d.ts +20 -0
- package/dist/runtime.js +43 -15
- package/package.json +1 -1
package/dist/core/locate.js
CHANGED
|
@@ -196,6 +196,12 @@ export function staticReader() {
|
|
|
196
196
|
* prefix a duplicate-prefix ERROR.
|
|
197
197
|
*
|
|
198
198
|
* Pass `files` to reuse a {@link scanProject} result instead of re-walking.
|
|
199
|
+
*
|
|
200
|
+
* `prefixOwners` comes back with the registry because the rule deciding it —
|
|
201
|
+
* first claim wins, in sorted file order — must have exactly one spelling. It is
|
|
202
|
+
* what `duplicate-prefix` is computed from here, and what tells `--apply` which
|
|
203
|
+
* file an ADDED id belongs in (design §7); a second walk arriving at its own
|
|
204
|
+
* answer would be a second rule the moment either was edited.
|
|
199
205
|
*/
|
|
200
206
|
export async function loadRegistry(root, reader, files) {
|
|
201
207
|
const paths = files ?? (await scanProject(root)).reqsFiles;
|
|
@@ -204,18 +210,23 @@ export async function loadRegistry(root, reader, files) {
|
|
|
204
210
|
const loaded = await Promise.all(paths.map(async (file) => ({ file, outcome: await reader.read(file) })));
|
|
205
211
|
const registry = {};
|
|
206
212
|
const issues = [];
|
|
213
|
+
const unreadableFiles = [];
|
|
207
214
|
// Which file first claimed each id prefix. The prefix is the only unit above
|
|
208
215
|
// the requirement (design §11) and nothing allocates it, so two files
|
|
209
216
|
// claiming one is the collision no command would otherwise report — the ids
|
|
210
217
|
// themselves can stay distinct forever while the space they share has two
|
|
211
218
|
// owners. First seen in sorted file order, so which file is called the owner
|
|
212
219
|
// does not depend on I/O timing.
|
|
220
|
+
// Absolute paths, so a caller that has to *write* to the owning file does not
|
|
221
|
+
// have to turn a display path back into one; the messages below relativise at
|
|
222
|
+
// the point they are built, which is where the reader's path belongs anyway.
|
|
213
223
|
const prefixOwner = new Map();
|
|
214
224
|
for (const entry of loaded) {
|
|
215
225
|
const display = relativePath(root, entry.file);
|
|
216
226
|
const { outcome } = entry;
|
|
217
227
|
if ('issue' in outcome) {
|
|
218
228
|
issues.push({ ...outcome.issue, file: display });
|
|
229
|
+
unreadableFiles.push(display);
|
|
219
230
|
continue;
|
|
220
231
|
}
|
|
221
232
|
// One issue per colliding prefix rather than per requirement: the fact is
|
|
@@ -223,13 +234,12 @@ export async function loadRegistry(root, reader, files) {
|
|
|
223
234
|
// prefix would otherwise report the same sentence forty times.
|
|
224
235
|
const reported = new Set();
|
|
225
236
|
for (const [id, req] of Object.entries(outcome.registry)) {
|
|
226
|
-
const
|
|
227
|
-
const prefix = dash === -1 ? id : id.slice(0, dash);
|
|
237
|
+
const prefix = idPrefix(id);
|
|
228
238
|
const owner = prefixOwner.get(prefix);
|
|
229
239
|
if (owner === undefined) {
|
|
230
|
-
prefixOwner.set(prefix,
|
|
240
|
+
prefixOwner.set(prefix, entry.file);
|
|
231
241
|
}
|
|
232
|
-
else if (owner !==
|
|
242
|
+
else if (owner !== entry.file && !reported.has(prefix)) {
|
|
233
243
|
reported.add(prefix);
|
|
234
244
|
// No `reqId`: this is about two files, not about any one of the
|
|
235
245
|
// requirements that happen to reveal it.
|
|
@@ -237,7 +247,7 @@ export async function loadRegistry(root, reader, files) {
|
|
|
237
247
|
level: 'ERROR',
|
|
238
248
|
code: 'duplicate-prefix',
|
|
239
249
|
file: display,
|
|
240
|
-
message: `Id prefix "${prefix}-" is already declared by "${owner}". Give each registry file a prefix of its own.`,
|
|
250
|
+
message: `Id prefix "${prefix}-" is already declared by "${relativePath(root, owner)}". Give each registry file a prefix of its own.`,
|
|
241
251
|
});
|
|
242
252
|
}
|
|
243
253
|
if (Object.hasOwn(registry, id)) {
|
|
@@ -254,8 +264,27 @@ export async function loadRegistry(root, reader, files) {
|
|
|
254
264
|
}
|
|
255
265
|
}
|
|
256
266
|
}
|
|
257
|
-
return { registry, issues };
|
|
267
|
+
return { registry, issues, prefixOwners: Object.fromEntries(prefixOwner), unreadableFiles };
|
|
268
|
+
}
|
|
269
|
+
/**
|
|
270
|
+
* The part of an id that names the space it lives in — `AUTH` of `AUTH-3`.
|
|
271
|
+
*
|
|
272
|
+
* The prefix is the only unit above the requirement (design §11) and two things
|
|
273
|
+
* now depend on agreeing about it: `duplicate-prefix`, and which file `--apply`
|
|
274
|
+
* writes an ADDED requirement into. An id with no dash is its own prefix, which
|
|
275
|
+
* cannot arise from `RequirementIdSchema` and is handled anyway because this
|
|
276
|
+
* also runs over ids a delta proposed.
|
|
277
|
+
*/
|
|
278
|
+
export function idPrefix(id) {
|
|
279
|
+
const dash = id.indexOf('-');
|
|
280
|
+
return dash === -1 ? id : id.slice(0, dash);
|
|
258
281
|
}
|
|
282
|
+
/**
|
|
283
|
+
* How many spec files are read at once. High enough that the walk stays I/O
|
|
284
|
+
* bound on any real project, low enough that the number of sources alive is a
|
|
285
|
+
* constant rather than the size of the input.
|
|
286
|
+
*/
|
|
287
|
+
const PARSE_CONCURRENCY = 32;
|
|
259
288
|
/**
|
|
260
289
|
* Parse the given spec files into one merged plan (paths shown relative to
|
|
261
290
|
* `displayRoot`). Files are read concurrently; the merge follows the input
|
|
@@ -264,14 +293,37 @@ export async function loadRegistry(root, reader, files) {
|
|
|
264
293
|
* The paths are POSIX on every platform (see `paths.ts`): they are not only
|
|
265
294
|
* displayed, they become the child run's `include` globs, where a Windows
|
|
266
295
|
* separator would silently match nothing.
|
|
296
|
+
*
|
|
297
|
+
* Each source is parsed as it arrives rather than after all of them. The
|
|
298
|
+
* previous `Promise.all(files.map(readFile))` held every spec file in memory at
|
|
299
|
+
* once — measured at ~47 MiB on a synthetic tree of 6000 files — for input size
|
|
300
|
+
* that is not ours to choose, since `check` is the command this project tells
|
|
301
|
+
* people to run first on an untrusted fork MR. Parsing at the point of arrival
|
|
302
|
+
* makes the peak `PARSE_CONCURRENCY` sources instead of `files.length`, and the
|
|
303
|
+
* plan is the only thing that still grows with the tree.
|
|
304
|
+
*
|
|
305
|
+
* `findFiles` above is deliberately left unbounded: its fan-out is real, but the
|
|
306
|
+
* failure it invites is descriptor exhaustion, which no measurement on either
|
|
307
|
+
* development platform could produce (see CHANGELOG.md, `Under consideration`).
|
|
308
|
+
* The memory here needed no such evidence — it is arithmetic, and portable.
|
|
267
309
|
*/
|
|
268
310
|
export async function parseSpecs(files, displayRoot) {
|
|
269
|
-
|
|
311
|
+
// Indexed rather than appended, so the merge below follows the input order
|
|
312
|
+
// whatever order the reads finish in.
|
|
313
|
+
const parsed = new Array(files.length);
|
|
314
|
+
let next = 0;
|
|
315
|
+
const worker = async () => {
|
|
316
|
+
for (let i = next++; i < files.length; i = next++) {
|
|
317
|
+
const file = files[i];
|
|
318
|
+
const source = await readFile(file, 'utf8');
|
|
319
|
+
parsed[i] = parseSpecFile(relativePath(displayRoot, file), source);
|
|
320
|
+
}
|
|
321
|
+
};
|
|
322
|
+
await Promise.all(Array.from({ length: Math.min(PARSE_CONCURRENCY, files.length) }, worker));
|
|
270
323
|
const plan = { scenarios: [], paramRefs: [] };
|
|
271
|
-
for (const
|
|
272
|
-
|
|
273
|
-
plan.
|
|
274
|
-
plan.paramRefs.push(...parsed.paramRefs);
|
|
324
|
+
for (const one of parsed) {
|
|
325
|
+
plan.scenarios.push(...one.scenarios);
|
|
326
|
+
plan.paramRefs.push(...one.paramRefs);
|
|
275
327
|
}
|
|
276
328
|
return plan;
|
|
277
329
|
}
|
|
@@ -279,6 +331,37 @@ export async function parseSpecs(files, displayRoot) {
|
|
|
279
331
|
export async function parseAllSpecFiles(root) {
|
|
280
332
|
return parseSpecs(await findFiles(root, isSpecFile), root);
|
|
281
333
|
}
|
|
334
|
+
/**
|
|
335
|
+
* Spec-shaped files sitting under `root/changes` — the location a change's
|
|
336
|
+
* specs used to live at, and which nothing walks any more (design §7).
|
|
337
|
+
*
|
|
338
|
+
* Two correct decisions compose into a blind spot. `changes` is in `SKIP_DIRS`,
|
|
339
|
+
* so `scanProject` cannot reach these; and the explicit include `attest archive`
|
|
340
|
+
* once applied to `changes/<name>/specs/` went away with `changeExcludeGlobs`
|
|
341
|
+
* when specs moved to their merged location. A spec left here therefore executes
|
|
342
|
+
* in no suite and no gate, and the only command that says anything is the gate,
|
|
343
|
+
* blaming coverage for a file it cannot see.
|
|
344
|
+
*
|
|
345
|
+
* Walks through `findFiles` rather than a second walker: `SKIP_DIRS` is consulted
|
|
346
|
+
* for *sub*directories only, so starting the walk at `changes` itself both
|
|
347
|
+
* reaches these files and keeps skipping `node_modules` beneath them.
|
|
348
|
+
*
|
|
349
|
+
* Both spellings are wrong here and both are returned. A `*.spec.ts` is the
|
|
350
|
+
* pre-move layout; a `*.proposed.spec.ts` is the right marker at the wrong path,
|
|
351
|
+
* which is the half-done migration and no more visible than the other.
|
|
352
|
+
*/
|
|
353
|
+
export async function findChangeDirSpecs(root) {
|
|
354
|
+
try {
|
|
355
|
+
return await findFiles(join(root, 'changes'), (n) => isSpecFile(n) || isProposedSpecFile(n));
|
|
356
|
+
}
|
|
357
|
+
catch {
|
|
358
|
+
// No `changes/` at all — the ordinary case for a project with nothing in
|
|
359
|
+
// flight, and not a finding. Swallowed the same way `listChangeNames`
|
|
360
|
+
// swallows it, so the two cannot disagree about what a missing directory
|
|
361
|
+
// means.
|
|
362
|
+
return [];
|
|
363
|
+
}
|
|
364
|
+
}
|
|
282
365
|
/** List the names of proposed changes under `root/changes`. */
|
|
283
366
|
export async function listChangeNames(root) {
|
|
284
367
|
try {
|
|
@@ -0,0 +1,54 @@
|
|
|
1
|
+
import type { RegistryDelta } from './registry.js';
|
|
2
|
+
import type { Issue, Registry } from './types.js';
|
|
3
|
+
/** `x.proposed.spec.ts` -> `x.spec.ts`, in place. */
|
|
4
|
+
export declare function mergedSpecPath(proposed: string): string;
|
|
5
|
+
export interface MergeInputs {
|
|
6
|
+
root: string;
|
|
7
|
+
changeName: string;
|
|
8
|
+
delta: RegistryDelta;
|
|
9
|
+
/** The registry on disk, as the gate read it. */
|
|
10
|
+
base: Registry;
|
|
11
|
+
/**
|
|
12
|
+
* The registry the gate reached by applying the delta — the source of what
|
|
13
|
+
* gets written.
|
|
14
|
+
*
|
|
15
|
+
* Not `delta.added`, and the difference is the point: the delta holds the
|
|
16
|
+
* *authoring* shape, where `params` and `outOfScope` may be absent, while this
|
|
17
|
+
* is the schema-parsed result the gate actually gave its verdict on. Splicing
|
|
18
|
+
* from here means the bytes written to the registry are exactly the
|
|
19
|
+
* requirement that was proved green, rather than a second normalisation of it.
|
|
20
|
+
*/
|
|
21
|
+
applied: Registry;
|
|
22
|
+
/** Prefix -> the absolute registry file that owns it (see `loadRegistry`). */
|
|
23
|
+
prefixOwners: Record<string, string>;
|
|
24
|
+
/**
|
|
25
|
+
* The proposed specs this change claims, absolute, each with the requirement
|
|
26
|
+
* ids it declares a scenario for.
|
|
27
|
+
*
|
|
28
|
+
* The ids are here because the rename is not the whole of merging a spec: one
|
|
29
|
+
* import has to be repointed from the change's delta at the registry, and
|
|
30
|
+
* *which* registry is decided by the prefix of the ids that spec attests.
|
|
31
|
+
*/
|
|
32
|
+
claimedSpecs: {
|
|
33
|
+
file: string;
|
|
34
|
+
reqIds: string[];
|
|
35
|
+
}[];
|
|
36
|
+
/** Every merged spec on disk, absolute — what a rename could collide with. */
|
|
37
|
+
mergedSpecs: string[];
|
|
38
|
+
}
|
|
39
|
+
export interface MergeResult {
|
|
40
|
+
/** Refusals. Non-empty means **nothing was written**. */
|
|
41
|
+
issues: Issue[];
|
|
42
|
+
/** Paths touched, relative to root, in the order they were touched. */
|
|
43
|
+
written: string[];
|
|
44
|
+
}
|
|
45
|
+
/**
|
|
46
|
+
* Perform the merge, or refuse it whole.
|
|
47
|
+
*
|
|
48
|
+
* The caller must have run the gate in the same invocation and found it green;
|
|
49
|
+
* a merge that files a change as done without re-checking is the one thing this
|
|
50
|
+
* tool must not ship, because "done" having a hard definition is the whole
|
|
51
|
+
* claim.
|
|
52
|
+
*/
|
|
53
|
+
export declare function applyMerge(input: MergeInputs): Promise<MergeResult>;
|
|
54
|
+
//# sourceMappingURL=merge.d.ts.map
|
|
@@ -0,0 +1,257 @@
|
|
|
1
|
+
// Finishing the merge the gate approved (design §7, §8).
|
|
2
|
+
//
|
|
3
|
+
// `attest archive <change>` proves a delta is green, covered and drift-free, and
|
|
4
|
+
// until now a human then transcribed it by hand with nothing checking the
|
|
5
|
+
// transcription. This is that step, and the whole of why it is allowed to exist
|
|
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`).
|
|
9
|
+
//
|
|
10
|
+
// **It is re-runnable, not atomic.** No primitive spans one edit, N renames and
|
|
11
|
+
// a directory move, and a scratch copy of the project root would have to be
|
|
12
|
+
// swapped back through the same non-atomic set again. So instead every step is
|
|
13
|
+
// derived from the tree as it currently is rather than reconciled against a
|
|
14
|
+
// fixed list: a requirement already in the registry is not spliced, a spec
|
|
15
|
+
// already renamed is not in `claimedSpecs`, and a change folder already moved is
|
|
16
|
+
// not there to move. Running the command again after a failure finishes it.
|
|
17
|
+
//
|
|
18
|
+
// **The step order is a correctness constraint, pinned at both ends.**
|
|
19
|
+
//
|
|
20
|
+
// - *The splice goes first.* A renamed spec joins the base plan **unfiltered**,
|
|
21
|
+
// so its scenarios are read against the merged registry. Rename before splice
|
|
22
|
+
// and every one of them is an `orphan-test` naming an id the registry does not
|
|
23
|
+
// have yet — a tree `check` calls broken for a reason that is not the real one.
|
|
24
|
+
// - *The folder move goes last.* `changes/<name>/` is the **input**. Move it
|
|
25
|
+
// first and a crash leaves a half-merged registry with no delta left to
|
|
26
|
+
// re-derive from, which is the one genuinely unrecoverable state in the design.
|
|
27
|
+
//
|
|
28
|
+
// The two steps between them are free. Nothing enforces this ordering but the
|
|
29
|
+
// code below and ATX-53, which is why it is stated here as well: it is invisible
|
|
30
|
+
// at runtime on the happy path, and a later reordering would look harmless.
|
|
31
|
+
import { mkdir, readFile, rename, stat } from 'node:fs/promises';
|
|
32
|
+
import { join, dirname, basename } from 'node:path';
|
|
33
|
+
import { repointImport, spliceRequirements } from './splice.js';
|
|
34
|
+
import { writeAtomic } from './write.js';
|
|
35
|
+
import { idPrefix } from './locate.js';
|
|
36
|
+
import { addedIds } from './apply.js';
|
|
37
|
+
import { byCodeUnit } from './order.js';
|
|
38
|
+
import { relativePath } from './paths.js';
|
|
39
|
+
/** `x.proposed.spec.ts` -> `x.spec.ts`, in place. */
|
|
40
|
+
export function mergedSpecPath(proposed) {
|
|
41
|
+
return join(dirname(proposed), basename(proposed).replace(/\.proposed\.spec\.ts$/, '.spec.ts'));
|
|
42
|
+
}
|
|
43
|
+
/**
|
|
44
|
+
* Why this merge cannot be performed mechanically, if it cannot.
|
|
45
|
+
*
|
|
46
|
+
* Every one of these is a refusal of the whole operation rather than a partial
|
|
47
|
+
* apply: the point of the command is that the gate's verdict now covers the step
|
|
48
|
+
* acting on it, and a merge that did the half it understood would put the file
|
|
49
|
+
* into a state no verdict describes.
|
|
50
|
+
*/
|
|
51
|
+
async function refusals(input, stamp) {
|
|
52
|
+
const issues = [];
|
|
53
|
+
const { delta, root, changeName } = input;
|
|
54
|
+
const archive = archivePath(input, stamp);
|
|
55
|
+
// 0) The destination already exists. Checked here, before anything is
|
|
56
|
+
// written, rather than at the move it is about: every refusal in this function
|
|
57
|
+
// leaves the tree untouched, and one that fired after two steps had landed
|
|
58
|
+
// would make "refused whole" a claim with an exception in it — which is the
|
|
59
|
+
// kind of claim nobody can rely on.
|
|
60
|
+
if (await exists(archive)) {
|
|
61
|
+
issues.push({
|
|
62
|
+
level: 'ERROR',
|
|
63
|
+
code: 'apply-unsupported-delta',
|
|
64
|
+
file: relativePath(root, archive),
|
|
65
|
+
message: `${relativePath(root, archive)} already exists, so this change cannot be archived there. ` +
|
|
66
|
+
`Move or remove that directory, then run this command again.`,
|
|
67
|
+
});
|
|
68
|
+
}
|
|
69
|
+
// 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
|
+
const unsupported = [
|
|
75
|
+
delta.renamed?.length ? 'renamed' : '',
|
|
76
|
+
delta.removed?.length ? 'removed' : '',
|
|
77
|
+
Object.keys(delta.modified ?? {}).length ? 'modified' : '',
|
|
78
|
+
].filter(Boolean);
|
|
79
|
+
if (unsupported.length > 0) {
|
|
80
|
+
issues.push({
|
|
81
|
+
level: 'ERROR',
|
|
82
|
+
code: 'apply-unsupported-delta',
|
|
83
|
+
file: relativePath(root, join(root, 'changes', changeName)),
|
|
84
|
+
message: `--apply writes back ADDED requirements only, and this change's delta also carries ${unsupported.join(', ')}. ` +
|
|
85
|
+
`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
|
+
});
|
|
87
|
+
}
|
|
88
|
+
// 2) An added id whose prefix no registry file claims. Which file it belongs
|
|
89
|
+
// 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)) {
|
|
92
|
+
if (input.prefixOwners[idPrefix(id)] === undefined) {
|
|
93
|
+
issues.push({
|
|
94
|
+
level: 'ERROR',
|
|
95
|
+
code: 'apply-no-prefix-owner',
|
|
96
|
+
reqId: id,
|
|
97
|
+
message: `No registry file declares the "${idPrefix(id)}-" prefix, so --apply cannot tell where "${id}" belongs. ` +
|
|
98
|
+
`Create the registry file for that prefix, or give the requirement a prefix an existing file already owns, then run this command again.`,
|
|
99
|
+
});
|
|
100
|
+
}
|
|
101
|
+
}
|
|
102
|
+
// 3) A rename that would land on a file that already exists. This is the one
|
|
103
|
+
// failure mode of this command that would destroy work rather than stop, so
|
|
104
|
+
// it is checked here as well as reported statically by `check`.
|
|
105
|
+
const taken = new Set(input.mergedSpecs);
|
|
106
|
+
for (const { file: spec } of input.claimedSpecs) {
|
|
107
|
+
const dest = mergedSpecPath(spec);
|
|
108
|
+
if (taken.has(dest)) {
|
|
109
|
+
issues.push({
|
|
110
|
+
level: 'ERROR',
|
|
111
|
+
code: 'proposed-spec-name-taken',
|
|
112
|
+
file: relativePath(root, spec),
|
|
113
|
+
message: `Merging ${relativePath(root, spec)} would rename it onto ${relativePath(root, dest)}, which already exists. ` +
|
|
114
|
+
`Rename the proposed spec so its merged name is free — two spec files may sit beside the same module, but only one may hold each name.`,
|
|
115
|
+
});
|
|
116
|
+
}
|
|
117
|
+
}
|
|
118
|
+
// 4) A spec whose ids land in more than one registry file. Its delta import
|
|
119
|
+
// has to be repointed at *a* registry, and there is no single right answer —
|
|
120
|
+
// one import cannot serve two files, so this is a change that has to be split
|
|
121
|
+
// or merged by hand rather than one this can guess at.
|
|
122
|
+
for (const { file: spec, reqIds } of input.claimedSpecs) {
|
|
123
|
+
if (registryTargets(input, reqIds).length > 1) {
|
|
124
|
+
issues.push({
|
|
125
|
+
level: 'ERROR',
|
|
126
|
+
code: 'apply-unsupported-delta',
|
|
127
|
+
file: relativePath(root, spec),
|
|
128
|
+
message: `${relativePath(root, spec)} attests requirements that this change files into more than one registry file, ` +
|
|
129
|
+
`so its import of the delta cannot be repointed at a single one. Split the spec by registry file, or merge this change by hand.`,
|
|
130
|
+
});
|
|
131
|
+
}
|
|
132
|
+
}
|
|
133
|
+
// By code, then by file. Concatenating the two into one key would let a long
|
|
134
|
+
// code and a short one collide across the boundary, so the order of a report
|
|
135
|
+
// could depend on where that seam happened to fall.
|
|
136
|
+
return issues.sort((a, b) => byCodeUnit(a.code, b.code) || byCodeUnit(a.file ?? '', b.file ?? ''));
|
|
137
|
+
}
|
|
138
|
+
/** The distinct registry files the given ids belong in, by prefix ownership. */
|
|
139
|
+
function registryTargets(input, reqIds) {
|
|
140
|
+
const owners = reqIds
|
|
141
|
+
.map((id) => input.prefixOwners[idPrefix(id)])
|
|
142
|
+
.filter((f) => f !== undefined);
|
|
143
|
+
return [...new Set(owners)].sort(byCodeUnit);
|
|
144
|
+
}
|
|
145
|
+
/** The ids this change adds that the registry on disk does not have yet. */
|
|
146
|
+
function unmergedAdded(input) {
|
|
147
|
+
return addedIds(input.delta).filter((id) => !Object.hasOwn(input.base, id));
|
|
148
|
+
}
|
|
149
|
+
/**
|
|
150
|
+
* Perform the merge, or refuse it whole.
|
|
151
|
+
*
|
|
152
|
+
* The caller must have run the gate in the same invocation and found it green;
|
|
153
|
+
* a merge that files a change as done without re-checking is the one thing this
|
|
154
|
+
* tool must not ship, because "done" having a hard definition is the whole
|
|
155
|
+
* claim.
|
|
156
|
+
*/
|
|
157
|
+
export async function applyMerge(input) {
|
|
158
|
+
// Read once, here, and passed down. The comment on `archivePath` has always
|
|
159
|
+
// claimed the refusal and the move cannot disagree about the date; until this
|
|
160
|
+
// it computed a fresh `new Date()` on every call, four times per merge, so
|
|
161
|
+
// the property it named was precisely the one not provided. A merge that
|
|
162
|
+
// straddles midnight would have refused against one directory and written to
|
|
163
|
+
// another — rare, and silent when it happens, which is the combination this
|
|
164
|
+
// repository treats as worth the line.
|
|
165
|
+
const stamp = new Date().toISOString().slice(0, 10);
|
|
166
|
+
const refused = await refusals(input, stamp);
|
|
167
|
+
if (refused.length > 0)
|
|
168
|
+
return { issues: refused, written: [] };
|
|
169
|
+
const { root } = input;
|
|
170
|
+
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
|
+
const spliced = spliceRequirements(file, source, byFile.get(file));
|
|
182
|
+
if (spliced === undefined) {
|
|
183
|
+
// Unreachable through the command — the gate read this file as a literal
|
|
184
|
+
// moments ago — so it is reported as the internal inconsistency it is
|
|
185
|
+
// rather than as a diagnosis about the user's registry.
|
|
186
|
+
return {
|
|
187
|
+
issues: [
|
|
188
|
+
{
|
|
189
|
+
level: 'ERROR',
|
|
190
|
+
code: 'internal-error',
|
|
191
|
+
file: relativePath(root, file),
|
|
192
|
+
message: `${relativePath(root, file)} read as a registry for the gate but not for the merge.`,
|
|
193
|
+
},
|
|
194
|
+
],
|
|
195
|
+
written,
|
|
196
|
+
};
|
|
197
|
+
}
|
|
198
|
+
if (spliced !== source) {
|
|
199
|
+
await writeAtomic(file, spliced);
|
|
200
|
+
written.push(relativePath(root, file));
|
|
201
|
+
}
|
|
202
|
+
}
|
|
203
|
+
// --- 2) Repoint each claimed spec's delta import, then rename it in place.
|
|
204
|
+
//
|
|
205
|
+
// Repoint *before* rename, and that order is resumable rather than tidy. A
|
|
206
|
+
// crash between them leaves a proposed spec — still claimed, so still in this
|
|
207
|
+
// list next run — that already reads the registry, which step 1 has just
|
|
208
|
+
// written the id into; so the re-run's gate is green and the remaining rename
|
|
209
|
+
// happens. The other order leaves a merged spec that no longer appears in
|
|
210
|
+
// `claimedSpecs` at all, with a broken import nothing would come back for.
|
|
211
|
+
const deltaPath = join(root, 'changes', input.changeName, 'requirements.delta.ts');
|
|
212
|
+
const claimed = [...input.claimedSpecs].sort((a, b) => byCodeUnit(a.file, b.file));
|
|
213
|
+
for (const { file: spec, reqIds } of claimed) {
|
|
214
|
+
const target = registryTargets(input, reqIds)[0];
|
|
215
|
+
if (target) {
|
|
216
|
+
const source = await readFile(spec, 'utf8');
|
|
217
|
+
const repointed = repointImport(spec, source, deltaPath, target);
|
|
218
|
+
if (repointed !== source)
|
|
219
|
+
await writeAtomic(spec, repointed);
|
|
220
|
+
}
|
|
221
|
+
const dest = mergedSpecPath(spec);
|
|
222
|
+
await rename(spec, dest);
|
|
223
|
+
written.push(relativePath(root, dest));
|
|
224
|
+
}
|
|
225
|
+
// --- 3) Move the change folder, last.
|
|
226
|
+
const from = join(root, 'changes', input.changeName);
|
|
227
|
+
const to = archivePath(input, stamp);
|
|
228
|
+
if (await exists(from)) {
|
|
229
|
+
await mkdir(dirname(to), { recursive: true });
|
|
230
|
+
await rename(from, to);
|
|
231
|
+
written.push(relativePath(root, to));
|
|
232
|
+
}
|
|
233
|
+
return { issues: [], written };
|
|
234
|
+
}
|
|
235
|
+
/**
|
|
236
|
+
* Where a change is archived to. One function because two places ask — the
|
|
237
|
+
* refusal that checks it is free, and the move that performs it — and a date
|
|
238
|
+
* computed twice could straddle midnight and disagree with itself.
|
|
239
|
+
*
|
|
240
|
+
* So the date is not computed here: `stamp` comes from `applyMerge`, which
|
|
241
|
+
* reads it once for the whole operation. A pure function of its arguments is
|
|
242
|
+
* what makes "the two places cannot disagree" a property of the code rather
|
|
243
|
+
* than of how fast it ran.
|
|
244
|
+
*/
|
|
245
|
+
function archivePath(input, stamp) {
|
|
246
|
+
return join(input.root, 'archive', `${stamp}-${input.changeName}`);
|
|
247
|
+
}
|
|
248
|
+
async function exists(path) {
|
|
249
|
+
try {
|
|
250
|
+
await stat(path);
|
|
251
|
+
return true;
|
|
252
|
+
}
|
|
253
|
+
catch {
|
|
254
|
+
return false;
|
|
255
|
+
}
|
|
256
|
+
}
|
|
257
|
+
//# sourceMappingURL=merge.js.map
|
package/dist/core/pipeline.d.ts
CHANGED
|
@@ -141,4 +141,17 @@ export interface StatusResult {
|
|
|
141
141
|
export declare function runStatus(root: string, changeName: string, options?: ReadOptions): Promise<StatusResult>;
|
|
142
142
|
/** Archive gate for a change (design §8, §9: `attest archive <change>`). */
|
|
143
143
|
export declare function runArchive(root: string, changeName: string, options?: RunOptions): Promise<Issue[]>;
|
|
144
|
+
/**
|
|
145
|
+
* The gate, and — only if it passes — the merge it approved
|
|
146
|
+
* (`attest archive <change> --apply`).
|
|
147
|
+
*
|
|
148
|
+
* The gate runs first and unconditionally, in this same call. A merge acting on
|
|
149
|
+
* an earlier run's verdict would let an unfinished change be filed as done, and
|
|
150
|
+
* "done" having a hard definition is the whole claim of the tool; so a red gate
|
|
151
|
+
* writes nothing, and there is no flag that skips it.
|
|
152
|
+
*/
|
|
153
|
+
export declare function runArchiveApply(root: string, changeName: string, options?: RunOptions): Promise<{
|
|
154
|
+
issues: Issue[];
|
|
155
|
+
written: string[];
|
|
156
|
+
}>;
|
|
144
157
|
//# sourceMappingURL=pipeline.d.ts.map
|