@am_shork/attest 0.7.4 → 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 +1449 -157
- package/README.md +3 -2
- package/dist/cli/index.js +11 -8
- package/dist/cli/json.d.ts +26 -1
- package/dist/cli/json.js +28 -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/gate.js +3 -3
- package/dist/core/locate.d.ts +19 -4
- package/dist/core/locate.js +90 -40
- package/dist/core/merge.js +218 -72
- package/dist/core/pipeline.d.ts +17 -1
- package/dist/core/pipeline.js +149 -35
- package/dist/core/red-record.d.ts +14 -5
- package/dist/core/red-record.js +82 -24
- package/dist/core/registry-issues.d.ts +30 -0
- package/dist/core/registry-issues.js +26 -0
- package/dist/core/registry.d.ts +12 -5
- package/dist/core/registry.js +10 -8
- package/dist/core/runner.js +21 -9
- package/dist/core/schema.d.ts +32 -27
- package/dist/core/schema.js +33 -5
- package/dist/core/skill.js +101 -27
- package/dist/core/splice.d.ts +62 -3
- package/dist/core/splice.js +297 -27
- package/dist/core/static-registry.d.ts +42 -0
- package/dist/core/static-registry.js +136 -21
- package/dist/core/status.js +2 -2
- package/dist/core/terminal.js +5 -2
- package/dist/core/types.d.ts +39 -10
- package/dist/core/validator.d.ts +1 -0
- package/dist/core/validator.js +22 -0
- package/package.json +2 -2
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
|
//
|
package/dist/core/pipeline.d.ts
CHANGED
|
@@ -81,6 +81,20 @@ export interface RenderResult {
|
|
|
81
81
|
markdown: string;
|
|
82
82
|
/** Registry-loading issues — rendering a half-loaded registry would lie. */
|
|
83
83
|
issues: Issue[];
|
|
84
|
+
/** Whether the document reached `out.file`. False whenever `out` is absent. */
|
|
85
|
+
wrote: boolean;
|
|
86
|
+
}
|
|
87
|
+
/**
|
|
88
|
+
* A destination `render` was asked to write, as the two spellings it needs.
|
|
89
|
+
*
|
|
90
|
+
* `file` is resolved against the caller's working directory and is what the
|
|
91
|
+
* filesystem is given; `display` is the string the user typed, and is what a
|
|
92
|
+
* diagnostic has to echo so the path in the report is the path they can act on.
|
|
93
|
+
* They travel together because a destination is not usable without both.
|
|
94
|
+
*/
|
|
95
|
+
export interface RenderOut {
|
|
96
|
+
file: string;
|
|
97
|
+
display: string;
|
|
84
98
|
}
|
|
85
99
|
/**
|
|
86
100
|
* Markdown projection of the intent layer (design §9: `attest render`).
|
|
@@ -90,7 +104,9 @@ export interface RenderResult {
|
|
|
90
104
|
* `*.reqs.ts` files, so a committed rendering can be gated without going stale
|
|
91
105
|
* every time a line moves in a test file.
|
|
92
106
|
*/
|
|
93
|
-
export declare function runRender(root: string, options?: ReadOptions
|
|
107
|
+
export declare function runRender(root: string, options?: ReadOptions & {
|
|
108
|
+
out?: RenderOut | undefined;
|
|
109
|
+
}): Promise<RenderResult>;
|
|
94
110
|
/**
|
|
95
111
|
* Freshness gate for a committed rendering (`attest render --check`).
|
|
96
112
|
*
|
package/dist/core/pipeline.js
CHANGED
|
@@ -14,7 +14,7 @@ import { writeAtomic } from './write.js';
|
|
|
14
14
|
import { applyMerge, mergedSpecPath } from './merge.js';
|
|
15
15
|
import { compilerIssue } from './compiler.js';
|
|
16
16
|
import { mkdir, readFile, realpath } from 'node:fs/promises';
|
|
17
|
-
import { basename, dirname, join } from 'node:path';
|
|
17
|
+
import { basename, dirname, join, resolve } from 'node:path';
|
|
18
18
|
import { isInside, relativePath } from './paths.js';
|
|
19
19
|
import { hasError } from './types.js';
|
|
20
20
|
// The runner half of the engine, reached only when a command actually needs it.
|
|
@@ -316,21 +316,13 @@ export async function runVerify(root, options = {}) {
|
|
|
316
316
|
specFiles: scan.specFiles.length,
|
|
317
317
|
attesting: attesting.length,
|
|
318
318
|
};
|
|
319
|
-
//
|
|
320
|
-
//
|
|
321
|
-
//
|
|
322
|
-
//
|
|
323
|
-
//
|
|
324
|
-
//
|
|
325
|
-
// double-report a run that is
|
|
326
|
-
if (counts.requirements === 0) {
|
|
327
|
-
issues.push({
|
|
328
|
-
level: 'ERROR',
|
|
329
|
-
code: 'empty-spec',
|
|
330
|
-
message: `No requirements found under this root, so this run attested nothing. ` +
|
|
331
|
-
`Point attest at the directory holding your *.reqs.ts files, or add one.`,
|
|
332
|
-
});
|
|
333
|
-
}
|
|
319
|
+
// `empty-spec` belongs to `validateStructure` above, not here: the registry
|
|
320
|
+
// alone decides it, so it is owed by every command performing §5.3 rather than
|
|
321
|
+
// by the one that executes. The guard a reader is most likely to want here is
|
|
322
|
+
// "zero scenarios ran", and it is deliberately not that — with at least one
|
|
323
|
+
// requirement, an absent scenario is already `uncovered-requirement` and a
|
|
324
|
+
// declared scenario that never executed is already `declared-not-run`, both
|
|
325
|
+
// ERRORs, so the wider rule would only double-report a run that is red anyway.
|
|
334
326
|
// Nothing declares intent, so there is nothing for Attest to run. Skipping
|
|
335
327
|
// the child run keeps an incumbent suite untouched; the verdict comes from
|
|
336
328
|
// empty-spec or uncovered-requirement, both already ERRORs above.
|
|
@@ -405,15 +397,38 @@ export async function runCover(root, options = {}) {
|
|
|
405
397
|
* every time a line moves in a test file.
|
|
406
398
|
*/
|
|
407
399
|
export async function runRender(root, options = {}) {
|
|
400
|
+
// The compiler guard stays the first statement, as it is in every other entry
|
|
401
|
+
// point (ATX-56): a TypeScript with no AST API is a refusal about the
|
|
402
|
+
// toolchain, and it outranks anything this run could say about the caller's
|
|
403
|
+
// paths or registry.
|
|
408
404
|
const unusable = compilerIssue();
|
|
409
405
|
if (unusable)
|
|
410
|
-
return { markdown: '', issues: [unusable] };
|
|
406
|
+
return { markdown: '', issues: [unusable], wrote: false };
|
|
407
|
+
// Then the destination, before the registry is read, so a refused path is the
|
|
408
|
+
// only thing reported: a run that cannot write anywhere has no reason to also
|
|
409
|
+
// tell the user about their registry, and the report a pipeline reads should
|
|
410
|
+
// name the one thing that has to change.
|
|
411
|
+
const dest = options.out ? await resolveOutFile(root, options.out) : undefined;
|
|
412
|
+
if (dest && !dest.ok)
|
|
413
|
+
return { markdown: '', issues: [dest.issue], wrote: false };
|
|
411
414
|
const { registry, issues } = await readRegistry(root, options);
|
|
412
415
|
// A registry that failed to load yields a document that silently omits
|
|
413
|
-
// requirements. An incomplete spec doc is worse than none, so refuse
|
|
416
|
+
// requirements. An incomplete spec doc is worse than none, so refuse — and
|
|
417
|
+
// since the write is below this line, refusing is also what keeps a broken
|
|
418
|
+
// registry from overwriting a good committed document.
|
|
414
419
|
if (hasError(issues))
|
|
415
|
-
return { markdown: '', issues };
|
|
416
|
-
|
|
420
|
+
return { markdown: '', issues, wrote: false };
|
|
421
|
+
const markdown = renderMarkdown(registry);
|
|
422
|
+
// The write lives here rather than in the CLI, and that is the whole of what
|
|
423
|
+
// ATX-73 is structurally about: while it sat in the shell it was the one
|
|
424
|
+
// `writeAtomic` call outside this layer, and it reached the filesystem
|
|
425
|
+
// without the resolution `runInit` had performed since ATX-66. Resolving and
|
|
426
|
+
// writing in one function is what stops the two from coming apart again —
|
|
427
|
+
// a later caller cannot forget a step it never had to take.
|
|
428
|
+
if (!dest)
|
|
429
|
+
return { markdown, issues, wrote: false };
|
|
430
|
+
await writeAtomic(dest.dest, markdown);
|
|
431
|
+
return { markdown, issues, wrote: true };
|
|
417
432
|
}
|
|
418
433
|
/**
|
|
419
434
|
* Freshness gate for a committed rendering (`attest render --check`).
|
|
@@ -423,10 +438,29 @@ export async function runRender(root, options = {}) {
|
|
|
423
438
|
* alongside a gate that fails when the file no longer matches its source.
|
|
424
439
|
*/
|
|
425
440
|
export async function runRenderCheck(root, outFile, target, options = {}) {
|
|
441
|
+
// Called here despite the delegation further down, because the resolution
|
|
442
|
+
// below it runs first: the guard has to be the first statement of an entry
|
|
443
|
+
// point (ATX-56), and delegation cannot supply that once anything precedes
|
|
444
|
+
// it. Same precedence in both, for the reason `runRender` states.
|
|
445
|
+
const unusable = compilerIssue();
|
|
446
|
+
if (unusable)
|
|
447
|
+
return [unusable];
|
|
448
|
+
// Resolved before it is read, not only before it is written. A link along the
|
|
449
|
+
// path makes this compare a fresh document against a file outside the project
|
|
450
|
+
// — and both verdicts that can produce are ones the gate reports normally, so
|
|
451
|
+
// the wrong answer is indistinguishable from a right one.
|
|
452
|
+
//
|
|
453
|
+
// `target` already carries the spelling a diagnostic must echo, so the pair
|
|
454
|
+
// `resolveOutFile` wants is assembled here rather than asked of the caller:
|
|
455
|
+
// taking a `RenderOut` beside a `RenderTarget` would make the shell pass the
|
|
456
|
+
// same string twice, which is a clump waiting to disagree with itself.
|
|
457
|
+
const dest = await resolveOutFile(root, { file: outFile, display: target.display });
|
|
458
|
+
if (!dest.ok)
|
|
459
|
+
return [dest.issue];
|
|
426
460
|
const { markdown, issues } = await runRender(root, options);
|
|
427
461
|
if (hasError(issues))
|
|
428
462
|
return issues;
|
|
429
|
-
const current = await readFile(
|
|
463
|
+
const current = await readFile(dest.dest, 'utf8').catch(() => undefined);
|
|
430
464
|
const stale = staleIssue(target, current, markdown);
|
|
431
465
|
return stale ? [...issues, stale] : issues;
|
|
432
466
|
}
|
|
@@ -467,12 +501,7 @@ export async function runInit(root, names) {
|
|
|
467
501
|
writes.push({ target, dest: resolved.dest });
|
|
468
502
|
}
|
|
469
503
|
else {
|
|
470
|
-
refusals.push(
|
|
471
|
-
level: 'ERROR',
|
|
472
|
-
code: 'unsafe-target-path',
|
|
473
|
-
file: target.file,
|
|
474
|
-
message: `Refusing to write ${target.file}: it resolves to ${resolved.escape}, outside the project. Remove the link at that path and re-run.`,
|
|
475
|
-
});
|
|
504
|
+
refusals.push(unsafeTargetPathIssue(target.file, resolved.escape));
|
|
476
505
|
}
|
|
477
506
|
}
|
|
478
507
|
if (refusals.length > 0)
|
|
@@ -485,6 +514,61 @@ export async function runInit(root, names) {
|
|
|
485
514
|
}
|
|
486
515
|
return { files, issues };
|
|
487
516
|
}
|
|
517
|
+
/**
|
|
518
|
+
* Where a `render` destination really lands, or the refusal that a link
|
|
519
|
+
* redirected it (design §9: ownership of the path is checked, and who named the
|
|
520
|
+
* path decides what the refusal is about).
|
|
521
|
+
*
|
|
522
|
+
* `init` names its own destinations, so every one of them is checked; `--out`
|
|
523
|
+
* is the caller's path, and that is the whole of the difference between the two
|
|
524
|
+
* callers. A path spelled outside the project is the user asking for a file
|
|
525
|
+
* outside the project — `--out ../site/SPEC.md` is a destination, not an
|
|
526
|
+
* escape, and refusing it would be refusing the flag. What is not visible in
|
|
527
|
+
* the argument is a *link* along a path that reads as staying inside, which is
|
|
528
|
+
* the one a checkout can plant: the victim's `--out` is the string their README
|
|
529
|
+
* or CI already documents, and the content redirected is the document their
|
|
530
|
+
* repository wrote.
|
|
531
|
+
*
|
|
532
|
+
* So the check applies exactly when the destination is lexically inside the
|
|
533
|
+
* root, and it is `resolveDest`'s, unchanged. The leaf needs no separate
|
|
534
|
+
* defence and never did — `writeAtomic` lands by `rename`, which replaces a
|
|
535
|
+
* symlink rather than following it — but it is resolved with the rest anyway,
|
|
536
|
+
* because a link there is still a file the user made deliberately and this
|
|
537
|
+
* refuses rather than destroys it.
|
|
538
|
+
*
|
|
539
|
+
* Both entry points that name a destination come through here — the one that
|
|
540
|
+
* writes it and the one that reads it back to date it — which is why this is
|
|
541
|
+
* private to this module rather than something a shell is trusted to call.
|
|
542
|
+
*/
|
|
543
|
+
async function resolveOutFile(root, out) {
|
|
544
|
+
const dest = resolve(out.file);
|
|
545
|
+
const realRoot = await realpath(root).catch(() => root);
|
|
546
|
+
// Asked against both spellings of the root, because they differ whenever the
|
|
547
|
+
// project itself sits under a link: `dest` is built from the caller's cwd, so
|
|
548
|
+
// it is lexical, and testing only the resolved root would skip the check for
|
|
549
|
+
// every such checkout — failing open on exactly the layout that has links in
|
|
550
|
+
// it. `''` is the root itself, which is a directory and not a destination.
|
|
551
|
+
const rel = isInside(root, dest)
|
|
552
|
+
? relativePath(root, dest)
|
|
553
|
+
: isInside(realRoot, dest)
|
|
554
|
+
? relativePath(realRoot, dest)
|
|
555
|
+
: undefined;
|
|
556
|
+
if (rel === undefined || rel === '')
|
|
557
|
+
return { ok: true, dest };
|
|
558
|
+
const resolved = await resolveDest(realRoot, rel);
|
|
559
|
+
return resolved.ok
|
|
560
|
+
? { ok: true, dest: resolved.dest }
|
|
561
|
+
: { ok: false, issue: unsafeTargetPathIssue(out.display, resolved.escape) };
|
|
562
|
+
}
|
|
563
|
+
/** The one refusal both destination checks report (design §9). */
|
|
564
|
+
function unsafeTargetPathIssue(display, escape) {
|
|
565
|
+
return {
|
|
566
|
+
level: 'ERROR',
|
|
567
|
+
code: 'unsafe-target-path',
|
|
568
|
+
file: display,
|
|
569
|
+
message: `Refusing to write ${display}: it resolves to ${escape}, outside the project. Remove the link at that path and re-run.`,
|
|
570
|
+
};
|
|
571
|
+
}
|
|
488
572
|
/**
|
|
489
573
|
* Where a target's file really goes, or the path outside the project that
|
|
490
574
|
* asking put it at (design §9: ownership of the path is checked, not assumed).
|
|
@@ -721,14 +805,44 @@ export async function runStatus(root, changeName, options = {}) {
|
|
|
721
805
|
return nothing([invalidChangeNameIssue(changeName)]);
|
|
722
806
|
if (unusable)
|
|
723
807
|
return nothing([unusable]);
|
|
724
|
-
|
|
725
|
-
|
|
726
|
-
|
|
727
|
-
|
|
728
|
-
|
|
729
|
-
|
|
730
|
-
|
|
731
|
-
|
|
808
|
+
// One loader for the whole command, or none at all. Both reads below take the
|
|
809
|
+
// reader `options` asks for, so the escape hatch moves them together: the
|
|
810
|
+
// default path evaluates nothing, which is what puts this command on ATX-16's
|
|
811
|
+
// roster, and `--eval` is one named decision rather than one per file.
|
|
812
|
+
return withLoader(options, async (loader) => {
|
|
813
|
+
const read = await readDelta(root, changeName, options, loader);
|
|
814
|
+
if ('issue' in read)
|
|
815
|
+
return nothing([read.issue]);
|
|
816
|
+
const scan = await scanProject(root);
|
|
817
|
+
const { registry: base, issues: registryIssues } = await readRegistry(root, options, scan.reqsFiles, loader);
|
|
818
|
+
// `hasError`, the gate's own predicate over the same list, because the
|
|
819
|
+
// registry is what the delta is applied *to*: without it there is no end
|
|
820
|
+
// state to describe, and a report computed against half a registry would
|
|
821
|
+
// answer about a change nobody proposed.
|
|
822
|
+
if (hasError(registryIssues))
|
|
823
|
+
return nothing(registryIssues);
|
|
824
|
+
// The gate's own call, made here for its diagnosis rather than its result:
|
|
825
|
+
// the rows below stay decided by the delta, the specs and the record alone
|
|
826
|
+
// (ATX-32), and what this adds is the refusal. A delta that cannot be
|
|
827
|
+
// applied describes an end state that does not exist, so there is no
|
|
828
|
+
// progress toward it to report — an `unproven` against it would be a
|
|
829
|
+
// true-looking sentence about a change that cannot exist.
|
|
830
|
+
const { issues: deltaIssues } = applyDelta(base, read.delta);
|
|
831
|
+
if (deltaIssues.length > 0)
|
|
832
|
+
return nothing(deltaIssues);
|
|
833
|
+
const { merged: plan, issues: unreadableSpecs } = await changeMergedPlan(root, read.delta, scan);
|
|
834
|
+
// The same refusal the gate makes, from the same list and on the same
|
|
835
|
+
// argument (ATX-65): every state this command reports is computed *from* the
|
|
836
|
+
// plan, so a spec the parser could not read does not shrink the report — it
|
|
837
|
+
// changes what the remaining rows say. A requirement whose only scenario is
|
|
838
|
+
// in that file reads as having none, which is the opposite of the truth and
|
|
839
|
+
// is advice to redo work already on disk.
|
|
840
|
+
if (unreadableSpecs.length > 0)
|
|
841
|
+
return nothing(unreadableSpecs);
|
|
842
|
+
const firstRun = await readRedRecord(root, changeName);
|
|
843
|
+
const rows = statusRows(addedIds(read.delta), plan, firstRun);
|
|
844
|
+
return { change: changeName, rows, counts: statusCounts(rows), issues: [] };
|
|
845
|
+
});
|
|
732
846
|
}
|
|
733
847
|
/** Archive gate for a change (design §8, §9: `attest archive <change>`). */
|
|
734
848
|
export async function runArchive(root, changeName, options = {}) {
|