@am_shork/attest 0.4.3 → 0.6.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -20,7 +20,25 @@ const SKIP_DIRS = new Set([
20
20
  'archive',
21
21
  ]);
22
22
  export const isReqsFile = (name) => name.endsWith('.reqs.ts');
23
- export const isSpecFile = (name) => name.endsWith('.spec.ts');
23
+ /**
24
+ * A spec belonging to a change that has not been agreed yet (design §7).
25
+ *
26
+ * The marker is in the **name** rather than the directory because the file
27
+ * already sits where it will live once the change is merged: a proposal's spec
28
+ * is written next to the code it attests, so its relative imports resolve
29
+ * identically before and after the merge, and merging renames it in place
30
+ * instead of moving it up a tree and rewriting every specifier.
31
+ */
32
+ export const isProposedSpecFile = (name) => name.endsWith('.proposed.spec.ts');
33
+ /**
34
+ * A spec belonging to the merged suite.
35
+ *
36
+ * Proposed specs are excluded here rather than at each call site, so the base
37
+ * scan, the base run and every reporting command are wrong together or not at
38
+ * all: a red scenario for behaviour nobody has implemented must not reach any
39
+ * of them until its gate passes.
40
+ */
41
+ export const isSpecFile = (name) => name.endsWith('.spec.ts') && !isProposedSpecFile(name);
24
42
  /**
25
43
  * Recursively find files under root whose basename matches `match`.
26
44
  *
@@ -53,19 +71,29 @@ export async function findFiles(root, match) {
53
71
  * Find the registry and spec files under root in a **single** traversal.
54
72
  *
55
73
  * Calling findFiles once per pattern meant `check` walked the tree twice and
56
- * `archive` four times, over a tree that cannot change in between.
74
+ * `archive` four times, over a tree that cannot change in between. Proposed
75
+ * specs are collected in the same pass for that reason and kept in their own
76
+ * list: they are found everywhere the merged ones are, and no caller may reach
77
+ * them by accident.
57
78
  */
58
79
  export async function scanProject(root) {
59
- const files = await findFiles(root, (n) => isReqsFile(n) || isSpecFile(n));
80
+ const files = await findFiles(root, (n) => isReqsFile(n) || isSpecFile(n) || isProposedSpecFile(n));
60
81
  const reqsFiles = [];
61
82
  const specFiles = [];
83
+ const proposedSpecFiles = [];
62
84
  for (const f of files) {
63
- if (isReqsFile(basename(f)))
85
+ const name = basename(f);
86
+ // Sorted by `isSpecFile` rather than by `isProposedSpecFile`, so what makes
87
+ // a spec part of the merged suite is decided in exactly one place: the two
88
+ // predicates would otherwise both have to be right about the same file.
89
+ if (isReqsFile(name))
64
90
  reqsFiles.push(f);
65
- else
91
+ else if (isSpecFile(name))
66
92
  specFiles.push(f);
93
+ else
94
+ proposedSpecFiles.push(f);
67
95
  }
68
- return { reqsFiles, specFiles };
96
+ return { reqsFiles, specFiles, proposedSpecFiles };
69
97
  }
70
98
  /**
71
99
  * Read registries by **executing** the module through the Vite loader.
@@ -168,6 +196,12 @@ export function staticReader() {
168
196
  * prefix a duplicate-prefix ERROR.
169
197
  *
170
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.
171
205
  */
172
206
  export async function loadRegistry(root, reader, files) {
173
207
  const paths = files ?? (await scanProject(root)).reqsFiles;
@@ -182,6 +216,9 @@ export async function loadRegistry(root, reader, files) {
182
216
  // themselves can stay distinct forever while the space they share has two
183
217
  // owners. First seen in sorted file order, so which file is called the owner
184
218
  // does not depend on I/O timing.
219
+ // Absolute paths, so a caller that has to *write* to the owning file does not
220
+ // have to turn a display path back into one; the messages below relativise at
221
+ // the point they are built, which is where the reader's path belongs anyway.
185
222
  const prefixOwner = new Map();
186
223
  for (const entry of loaded) {
187
224
  const display = relativePath(root, entry.file);
@@ -195,13 +232,12 @@ export async function loadRegistry(root, reader, files) {
195
232
  // prefix would otherwise report the same sentence forty times.
196
233
  const reported = new Set();
197
234
  for (const [id, req] of Object.entries(outcome.registry)) {
198
- const dash = id.indexOf('-');
199
- const prefix = dash === -1 ? id : id.slice(0, dash);
235
+ const prefix = idPrefix(id);
200
236
  const owner = prefixOwner.get(prefix);
201
237
  if (owner === undefined) {
202
- prefixOwner.set(prefix, display);
238
+ prefixOwner.set(prefix, entry.file);
203
239
  }
204
- else if (owner !== display && !reported.has(prefix)) {
240
+ else if (owner !== entry.file && !reported.has(prefix)) {
205
241
  reported.add(prefix);
206
242
  // No `reqId`: this is about two files, not about any one of the
207
243
  // requirements that happen to reveal it.
@@ -209,7 +245,7 @@ export async function loadRegistry(root, reader, files) {
209
245
  level: 'ERROR',
210
246
  code: 'duplicate-prefix',
211
247
  file: display,
212
- message: `Id prefix "${prefix}-" is already declared by "${owner}". Give each registry file a prefix of its own.`,
248
+ message: `Id prefix "${prefix}-" is already declared by "${relativePath(root, owner)}". Give each registry file a prefix of its own.`,
213
249
  });
214
250
  }
215
251
  if (Object.hasOwn(registry, id)) {
@@ -226,7 +262,20 @@ export async function loadRegistry(root, reader, files) {
226
262
  }
227
263
  }
228
264
  }
229
- return { registry, issues };
265
+ return { registry, issues, prefixOwners: Object.fromEntries(prefixOwner) };
266
+ }
267
+ /**
268
+ * The part of an id that names the space it lives in — `AUTH` of `AUTH-3`.
269
+ *
270
+ * The prefix is the only unit above the requirement (design §11) and two things
271
+ * now depend on agreeing about it: `duplicate-prefix`, and which file `--apply`
272
+ * writes an ADDED requirement into. An id with no dash is its own prefix, which
273
+ * cannot arise from `RequirementIdSchema` and is handled anyway because this
274
+ * also runs over ids a delta proposed.
275
+ */
276
+ export function idPrefix(id) {
277
+ const dash = id.indexOf('-');
278
+ return dash === -1 ? id : id.slice(0, dash);
230
279
  }
231
280
  /**
232
281
  * Parse the given spec files into one merged plan (paths shown relative to
@@ -251,10 +300,36 @@ export async function parseSpecs(files, displayRoot) {
251
300
  export async function parseAllSpecFiles(root) {
252
301
  return parseSpecs(await findFiles(root, isSpecFile), root);
253
302
  }
254
- /** Parse the spec files belonging to a single proposed change (design §8). */
255
- export async function parseChangeSpecs(root, changeName) {
256
- const dir = join(root, 'changes', changeName);
257
- return parseSpecs(await findFiles(dir, isSpecFile), root);
303
+ /**
304
+ * Spec-shaped files sitting under `root/changes` the location a change's
305
+ * specs used to live at, and which nothing walks any more (design §7).
306
+ *
307
+ * Two correct decisions compose into a blind spot. `changes` is in `SKIP_DIRS`,
308
+ * so `scanProject` cannot reach these; and the explicit include `attest archive`
309
+ * once applied to `changes/<name>/specs/` went away with `changeExcludeGlobs`
310
+ * when specs moved to their merged location. A spec left here therefore executes
311
+ * in no suite and no gate, and the only command that says anything is the gate,
312
+ * blaming coverage for a file it cannot see.
313
+ *
314
+ * Walks through `findFiles` rather than a second walker: `SKIP_DIRS` is consulted
315
+ * for *sub*directories only, so starting the walk at `changes` itself both
316
+ * reaches these files and keeps skipping `node_modules` beneath them.
317
+ *
318
+ * Both spellings are wrong here and both are returned. A `*.spec.ts` is the
319
+ * pre-move layout; a `*.proposed.spec.ts` is the right marker at the wrong path,
320
+ * which is the half-done migration and no more visible than the other.
321
+ */
322
+ export async function findChangeDirSpecs(root) {
323
+ try {
324
+ return await findFiles(join(root, 'changes'), (n) => isSpecFile(n) || isProposedSpecFile(n));
325
+ }
326
+ catch {
327
+ // No `changes/` at all — the ordinary case for a project with nothing in
328
+ // flight, and not a finding. Swallowed the same way `listChangeNames`
329
+ // swallows it, so the two cannot disagree about what a missing directory
330
+ // means.
331
+ return [];
332
+ }
258
333
  }
259
334
  /** List the names of proposed changes under `root/changes`. */
260
335
  export async function listChangeNames(root) {
@@ -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,244 @@
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) {
52
+ const issues = [];
53
+ const { delta, root, changeName } = input;
54
+ // 0) The destination already exists. Checked here, before anything is
55
+ // written, rather than at the move it is about: every refusal in this function
56
+ // leaves the tree untouched, and one that fired after two steps had landed
57
+ // would make "refused whole" a claim with an exception in it — which is the
58
+ // kind of claim nobody can rely on.
59
+ if (await exists(archivePath(input))) {
60
+ issues.push({
61
+ level: 'ERROR',
62
+ code: 'apply-unsupported-delta',
63
+ file: relativePath(root, archivePath(input)),
64
+ message: `${relativePath(root, archivePath(input))} already exists, so this change cannot be archived there. ` +
65
+ `Move or remove that directory, then run this command again.`,
66
+ });
67
+ }
68
+ // 1) Operations this does not perform. The gate applies all four in memory to
69
+ // reach its verdict; only writing them back is limited to ADDED, because
70
+ // REMOVED cannot say which comments belonged to the entry it deletes and
71
+ // MODIFIED is a formatting-preserving edit inside an existing literal. Both
72
+ // are the "destructive on a file the user cannot regenerate" shape.
73
+ const unsupported = [
74
+ delta.renamed?.length ? 'renamed' : '',
75
+ delta.removed?.length ? 'removed' : '',
76
+ Object.keys(delta.modified ?? {}).length ? 'modified' : '',
77
+ ].filter(Boolean);
78
+ if (unsupported.length > 0) {
79
+ issues.push({
80
+ level: 'ERROR',
81
+ code: 'apply-unsupported-delta',
82
+ file: relativePath(root, join(root, 'changes', changeName)),
83
+ message: `--apply writes back ADDED requirements only, and this change's delta also carries ${unsupported.join(', ')}. ` +
84
+ `The gate above still checked all of it — merge the remaining operations into the registry by hand, then run this command again to confirm.`,
85
+ });
86
+ }
87
+ // 2) An added id whose prefix no registry file claims. Which file it belongs
88
+ // in — or whether a file should be created for it — is not something the gate
89
+ // verified, and guessing would file a requirement somewhere nobody chose.
90
+ for (const id of unmergedAdded(input)) {
91
+ if (input.prefixOwners[idPrefix(id)] === undefined) {
92
+ issues.push({
93
+ level: 'ERROR',
94
+ code: 'apply-no-prefix-owner',
95
+ reqId: id,
96
+ message: `No registry file declares the "${idPrefix(id)}-" prefix, so --apply cannot tell where "${id}" belongs. ` +
97
+ `Create the registry file for that prefix, or give the requirement a prefix an existing file already owns, then run this command again.`,
98
+ });
99
+ }
100
+ }
101
+ // 3) A rename that would land on a file that already exists. This is the one
102
+ // failure mode of this command that would destroy work rather than stop, so
103
+ // it is checked here as well as reported statically by `check`.
104
+ const taken = new Set(input.mergedSpecs);
105
+ for (const { file: spec } of input.claimedSpecs) {
106
+ const dest = mergedSpecPath(spec);
107
+ if (taken.has(dest)) {
108
+ issues.push({
109
+ level: 'ERROR',
110
+ code: 'proposed-spec-name-taken',
111
+ file: relativePath(root, spec),
112
+ message: `Merging ${relativePath(root, spec)} would rename it onto ${relativePath(root, dest)}, which already exists. ` +
113
+ `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.`,
114
+ });
115
+ }
116
+ }
117
+ // 4) A spec whose ids land in more than one registry file. Its delta import
118
+ // has to be repointed at *a* registry, and there is no single right answer —
119
+ // one import cannot serve two files, so this is a change that has to be split
120
+ // or merged by hand rather than one this can guess at.
121
+ for (const { file: spec, reqIds } of input.claimedSpecs) {
122
+ if (registryTargets(input, reqIds).length > 1) {
123
+ issues.push({
124
+ level: 'ERROR',
125
+ code: 'apply-unsupported-delta',
126
+ file: relativePath(root, spec),
127
+ message: `${relativePath(root, spec)} attests requirements that this change files into more than one registry file, ` +
128
+ `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.`,
129
+ });
130
+ }
131
+ }
132
+ // By code, then by file. Concatenating the two into one key would let a long
133
+ // code and a short one collide across the boundary, so the order of a report
134
+ // could depend on where that seam happened to fall.
135
+ return issues.sort((a, b) => byCodeUnit(a.code, b.code) || byCodeUnit(a.file ?? '', b.file ?? ''));
136
+ }
137
+ /** The distinct registry files the given ids belong in, by prefix ownership. */
138
+ function registryTargets(input, reqIds) {
139
+ const owners = reqIds
140
+ .map((id) => input.prefixOwners[idPrefix(id)])
141
+ .filter((f) => f !== undefined);
142
+ return [...new Set(owners)].sort(byCodeUnit);
143
+ }
144
+ /** The ids this change adds that the registry on disk does not have yet. */
145
+ function unmergedAdded(input) {
146
+ return addedIds(input.delta).filter((id) => !Object.hasOwn(input.base, id));
147
+ }
148
+ /**
149
+ * Perform the merge, or refuse it whole.
150
+ *
151
+ * The caller must have run the gate in the same invocation and found it green;
152
+ * a merge that files a change as done without re-checking is the one thing this
153
+ * tool must not ship, because "done" having a hard definition is the whole
154
+ * claim.
155
+ */
156
+ export async function applyMerge(input) {
157
+ const refused = await refusals(input);
158
+ if (refused.length > 0)
159
+ return { issues: refused, written: [] };
160
+ const { root } = input;
161
+ const written = [];
162
+ // --- 1) Splice, first. See the note at the top of this file.
163
+ const byFile = new Map();
164
+ for (const id of unmergedAdded(input)) {
165
+ const file = input.prefixOwners[idPrefix(id)];
166
+ const group = byFile.get(file) ?? {};
167
+ group[id] = input.applied[id];
168
+ byFile.set(file, group);
169
+ }
170
+ for (const file of [...byFile.keys()].sort(byCodeUnit)) {
171
+ const source = await readFile(file, 'utf8');
172
+ const spliced = spliceRequirements(file, source, byFile.get(file));
173
+ if (spliced === undefined) {
174
+ // Unreachable through the command — the gate read this file as a literal
175
+ // moments ago — so it is reported as the internal inconsistency it is
176
+ // rather than as a diagnosis about the user's registry.
177
+ return {
178
+ issues: [
179
+ {
180
+ level: 'ERROR',
181
+ code: 'internal-error',
182
+ file: relativePath(root, file),
183
+ message: `${relativePath(root, file)} read as a registry for the gate but not for the merge.`,
184
+ },
185
+ ],
186
+ written,
187
+ };
188
+ }
189
+ if (spliced !== source) {
190
+ await writeAtomic(file, spliced);
191
+ written.push(relativePath(root, file));
192
+ }
193
+ }
194
+ // --- 2) Repoint each claimed spec's delta import, then rename it in place.
195
+ //
196
+ // Repoint *before* rename, and that order is resumable rather than tidy. A
197
+ // crash between them leaves a proposed spec — still claimed, so still in this
198
+ // list next run — that already reads the registry, which step 1 has just
199
+ // written the id into; so the re-run's gate is green and the remaining rename
200
+ // happens. The other order leaves a merged spec that no longer appears in
201
+ // `claimedSpecs` at all, with a broken import nothing would come back for.
202
+ const deltaPath = join(root, 'changes', input.changeName, 'requirements.delta.ts');
203
+ const claimed = [...input.claimedSpecs].sort((a, b) => byCodeUnit(a.file, b.file));
204
+ for (const { file: spec, reqIds } of claimed) {
205
+ const target = registryTargets(input, reqIds)[0];
206
+ if (target) {
207
+ const source = await readFile(spec, 'utf8');
208
+ const repointed = repointImport(spec, source, deltaPath, target);
209
+ if (repointed !== source)
210
+ await writeAtomic(spec, repointed);
211
+ }
212
+ const dest = mergedSpecPath(spec);
213
+ await rename(spec, dest);
214
+ written.push(relativePath(root, dest));
215
+ }
216
+ // --- 3) Move the change folder, last.
217
+ const from = join(root, 'changes', input.changeName);
218
+ const to = archivePath(input);
219
+ if (await exists(from)) {
220
+ await mkdir(dirname(to), { recursive: true });
221
+ await rename(from, to);
222
+ written.push(relativePath(root, to));
223
+ }
224
+ return { issues: [], written };
225
+ }
226
+ /**
227
+ * Where a change is archived to. One function because two places ask — the
228
+ * refusal that checks it is free, and the move that performs it — and a date
229
+ * computed twice could straddle midnight and disagree with itself.
230
+ */
231
+ function archivePath(input) {
232
+ const stamp = new Date().toISOString().slice(0, 10);
233
+ return join(input.root, 'archive', `${stamp}-${input.changeName}`);
234
+ }
235
+ async function exists(path) {
236
+ try {
237
+ await stat(path);
238
+ return true;
239
+ }
240
+ catch {
241
+ return false;
242
+ }
243
+ }
244
+ //# sourceMappingURL=merge.js.map
@@ -120,15 +120,6 @@ export declare function runInit(root: string, names: readonly string[]): Promise
120
120
  files: string[];
121
121
  issues: Issue[];
122
122
  }>;
123
- /**
124
- * The globs that keep *other* proposals out of a change's gate run.
125
- *
126
- * Sibling names come from `readdir`, not from the guard above, so they are
127
- * escaped: a directory called `feat(auth)` pasted in raw is a *pattern*, it
128
- * matches nothing, and that sibling's specs silently join the run — quietly
129
- * widening the scope of the one check that decides "done".
130
- */
131
- export declare function changeExcludeGlobs(others: string[]): string[];
132
123
  export interface StatusResult {
133
124
  change: string;
134
125
  /** One row per added id, in id order. */
@@ -150,4 +141,17 @@ export interface StatusResult {
150
141
  export declare function runStatus(root: string, changeName: string, options?: ReadOptions): Promise<StatusResult>;
151
142
  /** Archive gate for a change (design §8, §9: `attest archive <change>`). */
152
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
+ }>;
153
157
  //# sourceMappingURL=pipeline.d.ts.map