@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.
- package/CHANGELOG.md +541 -32
- package/README.md +16 -8
- package/dist/cli/index.js +21 -3
- package/dist/core/apply.d.ts +15 -0
- package/dist/core/apply.js +43 -1
- 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 +7 -0
- package/dist/core/gate.d.ts +40 -3
- package/dist/core/gate.js +56 -6
- package/dist/core/locate.d.ts +62 -4
- package/dist/core/locate.js +91 -16
- package/dist/core/merge.d.ts +54 -0
- package/dist/core/merge.js +244 -0
- package/dist/core/pipeline.d.ts +13 -9
- package/dist/core/pipeline.js +289 -46
- package/dist/core/registry.d.ts +63 -4
- package/dist/core/registry.js +30 -4
- package/dist/core/runner.js +41 -4
- package/dist/core/schema.js +9 -1
- package/dist/core/skill.js +104 -13
- 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 +42 -2
- package/dist/core/types.d.ts +10 -0
- package/package.json +1 -1
package/dist/core/pipeline.js
CHANGED
|
@@ -1,11 +1,11 @@
|
|
|
1
1
|
// High-level operations composing the core layers (design §9). These are
|
|
2
2
|
// tool-agnostic; the CLI is a thin shell that calls them and renders the result.
|
|
3
3
|
import { createLoader } from './loader.js';
|
|
4
|
-
import { evalReader,
|
|
4
|
+
import { evalReader, findChangeDirSpecs, loadRegistry, listChangeNames, parseSpecs, scanProject, staticReader, } from './locate.js';
|
|
5
5
|
import { validateStructure, detectPotentialDrift, uncoveredIssues } from './validator.js';
|
|
6
6
|
import { byCodeUnit } from './order.js';
|
|
7
7
|
import { runAndCollect, BASE_EXCLUDE } from './runner.js';
|
|
8
|
-
import { applyDelta, addedIds } from './apply.js';
|
|
8
|
+
import { applyDelta, addedIds, claimedIds } from './apply.js';
|
|
9
9
|
import { readDeltaSource } from './static-registry.js';
|
|
10
10
|
import { statusRows, statusCounts } from './status.js';
|
|
11
11
|
import { evaluateGate, declaredNotRunIssues } from './gate.js';
|
|
@@ -13,6 +13,8 @@ import { renderMarkdown, staleIssue } from './render.js';
|
|
|
13
13
|
import { mergeRedRecord, readRedRecord, redRecordPath, serialiseRedRecord, } from './red-record.js';
|
|
14
14
|
import { DEFAULT_TARGET, resolveTargets } from './targets.js';
|
|
15
15
|
import { writeAtomic } from './write.js';
|
|
16
|
+
import { applyMerge, mergedSpecPath } from './merge.js';
|
|
17
|
+
import { compilerIssue } from './compiler.js';
|
|
16
18
|
import { mkdir, readFile } from 'node:fs/promises';
|
|
17
19
|
import { basename, dirname, join } from 'node:path';
|
|
18
20
|
import { relativePath } from './paths.js';
|
|
@@ -34,6 +36,9 @@ async function readRegistry(root, options, files) {
|
|
|
34
36
|
}
|
|
35
37
|
/** Static structural check (design §9: `attest check`). */
|
|
36
38
|
export async function runCheck(root, options = {}) {
|
|
39
|
+
const unusable = compilerIssue();
|
|
40
|
+
if (unusable)
|
|
41
|
+
return [unusable];
|
|
37
42
|
const scan = await scanProject(root);
|
|
38
43
|
const { registry, issues } = await readRegistry(root, options, scan.reqsFiles);
|
|
39
44
|
const plan = await parseSpecs(scan.specFiles, root);
|
|
@@ -41,8 +46,143 @@ export async function runCheck(root, options = {}) {
|
|
|
41
46
|
...issues,
|
|
42
47
|
...validateStructure(registry, plan),
|
|
43
48
|
...detectPotentialDrift(registry, plan, plan.paramRefs),
|
|
49
|
+
...(await unclaimedProposedSpecIssues(root, scan, options)),
|
|
50
|
+
...(await changeDirSpecIssues(root)),
|
|
51
|
+
...proposedNameTakenIssues(root, scan),
|
|
44
52
|
];
|
|
45
53
|
}
|
|
54
|
+
/**
|
|
55
|
+
* A proposed spec whose merged name is already taken (design §7).
|
|
56
|
+
*
|
|
57
|
+
* Merging a proposed spec is a rename in place, so the name it will take is
|
|
58
|
+
* decided the moment the file is created — and nothing stopped that name from
|
|
59
|
+
* being one a merged spec already holds. `isSpecFile` and `isProposedSpecFile`
|
|
60
|
+
* are deliberately disjoint, so `session.spec.ts` and `session.proposed.spec.ts`
|
|
61
|
+
* sit side by side without either predicate objecting; the collision only
|
|
62
|
+
* surfaces at the rename, where it would **destroy** the merged file.
|
|
63
|
+
*
|
|
64
|
+
* It is not a corner. A proposed spec is written beside the code it attests, so
|
|
65
|
+
* a change to a module that already has tests — the ordinary kind — reaches for
|
|
66
|
+
* exactly the colliding name.
|
|
67
|
+
*
|
|
68
|
+
* Reported here rather than only refused by `--apply` because the fix is a
|
|
69
|
+
* rename at authoring time and the cost of learning late is a merge stopped
|
|
70
|
+
* halfway. `--apply` checks it again regardless: this is the one failure mode of
|
|
71
|
+
* that command that would destroy work rather than halt, and a check living in a
|
|
72
|
+
* different command is not a guard.
|
|
73
|
+
*/
|
|
74
|
+
function proposedNameTakenIssues(root, scan) {
|
|
75
|
+
const taken = new Set(scan.specFiles);
|
|
76
|
+
return scan.proposedSpecFiles
|
|
77
|
+
.filter((p) => taken.has(mergedSpecPath(p)))
|
|
78
|
+
.map((p) => ({
|
|
79
|
+
level: 'ERROR',
|
|
80
|
+
code: 'proposed-spec-name-taken',
|
|
81
|
+
file: relativePath(root, p),
|
|
82
|
+
message: `${relativePath(root, p)} merges to ${relativePath(root, mergedSpecPath(p))}, which already exists — the rename would overwrite it. ` +
|
|
83
|
+
`Give the proposed spec a name whose merged form is free; several spec files may sit beside one module, but only one may hold each name.`,
|
|
84
|
+
}));
|
|
85
|
+
}
|
|
86
|
+
/**
|
|
87
|
+
* A spec still sitting under `changes/` (design §7).
|
|
88
|
+
*
|
|
89
|
+
* The companion to `unclaimedProposedSpecIssues`, and the reason both exist is
|
|
90
|
+
* the same: a spec that runs nowhere. That one covers a file at the right path
|
|
91
|
+
* no delta reaches; this one covers a file no *scan* reaches, which is the
|
|
92
|
+
* blind spot moving specs to their merged location opened underneath it.
|
|
93
|
+
*
|
|
94
|
+
* `check` rather than the gate, for the reason ATX-47 gives about its sibling
|
|
95
|
+
* and one more of its own: the gate does speak here, but it says
|
|
96
|
+
* `uncovered-requirement` — the added ids genuinely have no scenario the plan
|
|
97
|
+
* can see — and sends an author who has written those scenarios off to write
|
|
98
|
+
* them again or defer a finished requirement. `check` is both the command a
|
|
99
|
+
* pipeline runs first and, until this, the one that said nothing at all.
|
|
100
|
+
*
|
|
101
|
+
* No `reqId`: the finding is about a file and a path, and no requirement is
|
|
102
|
+
* implicated by it — the ids the file covers are exactly what cannot be read
|
|
103
|
+
* without walking it.
|
|
104
|
+
*/
|
|
105
|
+
async function changeDirSpecIssues(root) {
|
|
106
|
+
const files = await findChangeDirSpecs(root);
|
|
107
|
+
return files.map((abs) => {
|
|
108
|
+
const file = relativePath(root, abs);
|
|
109
|
+
return {
|
|
110
|
+
level: 'ERROR',
|
|
111
|
+
code: 'spec-in-change-dir',
|
|
112
|
+
file,
|
|
113
|
+
message: `${file} is a spec under changes/, which no command walks: it runs in neither the base suite nor any change's gate. ` +
|
|
114
|
+
`Move it beside the code it attests and name it *.proposed.spec.ts — that is where a change's specs live, and it is what lets merging one be a rename in place.`,
|
|
115
|
+
};
|
|
116
|
+
});
|
|
117
|
+
}
|
|
118
|
+
/**
|
|
119
|
+
* A `*.proposed.spec.ts` no change's delta claims (design §7).
|
|
120
|
+
*
|
|
121
|
+
* A proposed spec is kept out of every normal run by its name and pulled into a
|
|
122
|
+
* gate run by its requirement ids, so one whose ids no delta mentions is a file
|
|
123
|
+
* that never executes anywhere — the silent drop this marker would otherwise
|
|
124
|
+
* cost. The base suite cannot catch it (the file is excluded there by design)
|
|
125
|
+
* and neither can the gate (it is scoped to what its own delta claims), which
|
|
126
|
+
* leaves `check` as the only command positioned to see it at all.
|
|
127
|
+
*
|
|
128
|
+
* A delta that cannot be read is reported as itself rather than turned into
|
|
129
|
+
* accusations against its specs: not knowing what a change claims is a different
|
|
130
|
+
* finding from knowing it claims nothing, and `check` already owes the first one
|
|
131
|
+
* — a change delta is intent, and the commands that only report read it from
|
|
132
|
+
* source (design §5.1).
|
|
133
|
+
*/
|
|
134
|
+
async function unclaimedProposedSpecIssues(root, scan, options) {
|
|
135
|
+
if (scan.proposedSpecFiles.length === 0)
|
|
136
|
+
return [];
|
|
137
|
+
const claimed = new Set();
|
|
138
|
+
const issues = [];
|
|
139
|
+
for (const name of await listChangeNames(root)) {
|
|
140
|
+
const read = await readDelta(root, name, options);
|
|
141
|
+
if ('issue' in read)
|
|
142
|
+
issues.push(read.issue);
|
|
143
|
+
else
|
|
144
|
+
for (const id of claimedIds(read.delta))
|
|
145
|
+
claimed.add(id);
|
|
146
|
+
}
|
|
147
|
+
const proposed = await parseSpecs(scan.proposedSpecFiles, root);
|
|
148
|
+
const claimedFiles = new Set(proposed.scenarios.filter((s) => claimed.has(s.reqId)).map((s) => s.file));
|
|
149
|
+
// Reported per file, not per scenario: the file is the unit a run includes,
|
|
150
|
+
// so it is the unit that did or did not execute, and one line per scenario
|
|
151
|
+
// would say the same thing several times about one unread file. Driven off
|
|
152
|
+
// the scanned files rather than off the parsed scenarios, so a proposed spec
|
|
153
|
+
// that declares no scenario at all is reported too — that one is claimed by
|
|
154
|
+
// nothing for a second reason, and reading the plan alone cannot see it.
|
|
155
|
+
for (const abs of scan.proposedSpecFiles) {
|
|
156
|
+
const file = relativePath(root, abs);
|
|
157
|
+
if (claimedFiles.has(file))
|
|
158
|
+
continue;
|
|
159
|
+
issues.push({
|
|
160
|
+
level: 'ERROR',
|
|
161
|
+
code: 'proposed-spec-unclaimed',
|
|
162
|
+
file,
|
|
163
|
+
message: `${file} is a proposed spec, but no change under changes/ declares a requirement it covers, ` +
|
|
164
|
+
`so no gate run will ever include it. Add its requirement ids to that change's ` +
|
|
165
|
+
`${CHANGE_DELTA_FILE}, or rename the file to *.spec.ts if the behaviour has already merged.`,
|
|
166
|
+
});
|
|
167
|
+
}
|
|
168
|
+
return issues;
|
|
169
|
+
}
|
|
170
|
+
/**
|
|
171
|
+
* A verdict reached without running anything — the shape a refusal takes.
|
|
172
|
+
*
|
|
173
|
+
* `passed` is false rather than true: nothing ran, and a result claiming the
|
|
174
|
+
* suite passed because it never started is the silent-green this tool exists to
|
|
175
|
+
* refuse. The counts are zero for the same reason `Running 0 spec files` would
|
|
176
|
+
* be honest — they report what was looked at, and nothing was.
|
|
177
|
+
*/
|
|
178
|
+
function emptyVerify(issues) {
|
|
179
|
+
return {
|
|
180
|
+
issues,
|
|
181
|
+
passed: false,
|
|
182
|
+
ok: false,
|
|
183
|
+
counts: { requirements: 0, scenarios: 0, specFiles: 0, attesting: 0 },
|
|
184
|
+
};
|
|
185
|
+
}
|
|
46
186
|
/**
|
|
47
187
|
* The spec files a run should execute: the ones that declare a `requirement()`.
|
|
48
188
|
*
|
|
@@ -57,6 +197,9 @@ function attestingFiles(plan) {
|
|
|
57
197
|
}
|
|
58
198
|
/** Executable verification (design §9: `attest verify`). */
|
|
59
199
|
export async function runVerify(root, options = {}) {
|
|
200
|
+
const unusable = compilerIssue();
|
|
201
|
+
if (unusable)
|
|
202
|
+
return emptyVerify([unusable]);
|
|
60
203
|
const scan = await scanProject(root);
|
|
61
204
|
const loader = await createLoader();
|
|
62
205
|
let registry;
|
|
@@ -103,6 +246,7 @@ export async function runVerify(root, options = {}) {
|
|
|
103
246
|
passed: true,
|
|
104
247
|
runtimeCoverage: new Map(),
|
|
105
248
|
outcomes: new Map(),
|
|
249
|
+
unloadedFiles: [],
|
|
106
250
|
}
|
|
107
251
|
: await runAndCollect({
|
|
108
252
|
root,
|
|
@@ -128,6 +272,9 @@ export async function runVerify(root, options = {}) {
|
|
|
128
272
|
* shape it can take.
|
|
129
273
|
*/
|
|
130
274
|
export async function runCover(root, options = {}) {
|
|
275
|
+
const unusable = compilerIssue();
|
|
276
|
+
if (unusable)
|
|
277
|
+
return { rows: [], issues: [unusable] };
|
|
131
278
|
const scan = await scanProject(root);
|
|
132
279
|
const { registry, issues: loadIssues } = await readRegistry(root, options, scan.reqsFiles);
|
|
133
280
|
if (hasError(loadIssues))
|
|
@@ -155,6 +302,9 @@ export async function runCover(root, options = {}) {
|
|
|
155
302
|
* every time a line moves in a test file.
|
|
156
303
|
*/
|
|
157
304
|
export async function runRender(root, options = {}) {
|
|
305
|
+
const unusable = compilerIssue();
|
|
306
|
+
if (unusable)
|
|
307
|
+
return { markdown: '', issues: [unusable] };
|
|
158
308
|
const { registry, issues } = await readRegistry(root, options);
|
|
159
309
|
// A registry that failed to load yields a document that silently omits
|
|
160
310
|
// requirements. An incomplete spec doc is worse than none, so refuse.
|
|
@@ -215,10 +365,9 @@ export async function runInit(root, names) {
|
|
|
215
365
|
* MR title.
|
|
216
366
|
*
|
|
217
367
|
* The test is path safety, not a character whitelist: the name never reaches a
|
|
218
|
-
* glob (
|
|
219
|
-
*
|
|
220
|
-
*
|
|
221
|
-
* directory keeps the guard exactly as wide as the danger.
|
|
368
|
+
* glob, so `feat(auth)` or a name with a space is a perfectly good directory
|
|
369
|
+
* and there is no reason for the gate to refuse it. Rejecting only what can
|
|
370
|
+
* escape the directory keeps the guard exactly as wide as the danger.
|
|
222
371
|
*/
|
|
223
372
|
function isSafeChangeName(name) {
|
|
224
373
|
return (name !== '' &&
|
|
@@ -229,29 +378,21 @@ function isSafeChangeName(name) {
|
|
|
229
378
|
!name.includes('\0'));
|
|
230
379
|
}
|
|
231
380
|
/**
|
|
232
|
-
*
|
|
381
|
+
* Escape a path so a run's `include` matches that file and nothing else.
|
|
233
382
|
*
|
|
234
|
-
*
|
|
235
|
-
*
|
|
236
|
-
*
|
|
237
|
-
*
|
|
383
|
+
* The backslash comes first, and it is the one that matters most: it is glob's
|
|
384
|
+
* own escape character, so a path containing one is not merely unescaped, it
|
|
385
|
+
* silently rewrites the pattern around it — `a\b` reads as an escaped `b` and
|
|
386
|
+
* matches `ab`, never the file itself. These paths come from the tree, not from
|
|
387
|
+
* the change-name guard above, so they are escaped rather than refused: a file
|
|
388
|
+
* that exists is not ours to reject.
|
|
389
|
+
*
|
|
390
|
+
* This used to escape *sibling change names* too, for the exclude globs that
|
|
391
|
+
* kept other proposals out of a gate run. Those globs are gone with the
|
|
392
|
+
* directory that made them possible; the include list is now the plan's own
|
|
393
|
+
* files, which is a set rather than a pattern.
|
|
238
394
|
*/
|
|
239
|
-
export function changeExcludeGlobs(others) {
|
|
240
|
-
return others.map((n) => `**/changes/${escapeGlob(n)}/**`);
|
|
241
|
-
}
|
|
242
395
|
function escapeGlob(name) {
|
|
243
|
-
// The backslash comes first, and it is the one that matters most: it is
|
|
244
|
-
// glob's own escape character, so a name containing one is not merely
|
|
245
|
-
// unescaped, it silently rewrites the pattern around it. On POSIX a
|
|
246
|
-
// backslash is a legal filename character, so `changes/a\b` produced the
|
|
247
|
-
// exclude glob `**/changes/a\b/**`, which globbing reads as an escaped `b` —
|
|
248
|
-
// matching `ab`, never the directory itself. That sibling's specs then
|
|
249
|
-
// joined the gate run, quietly widening the scope of the one check that
|
|
250
|
-
// decides whether a change is done.
|
|
251
|
-
//
|
|
252
|
-
// Escaped rather than rejected, because these names are not the change name
|
|
253
|
-
// the guard above screens: they come from `readdir`, and a directory that
|
|
254
|
-
// exists is not ours to refuse.
|
|
255
396
|
return name.replace(/[\\*?[\]{}()!+@|^$]/g, '\\$&');
|
|
256
397
|
}
|
|
257
398
|
/** The delta a change is declared in (design §7). */
|
|
@@ -325,14 +466,53 @@ async function readDelta(root, changeName, options) {
|
|
|
325
466
|
},
|
|
326
467
|
};
|
|
327
468
|
}
|
|
469
|
+
/**
|
|
470
|
+
* One entry per claimed proposed spec, carrying the ids it declares a scenario
|
|
471
|
+
* for — what `--apply` needs to know which registry to repoint its import at.
|
|
472
|
+
*
|
|
473
|
+
* Built from the plan rather than by re-parsing, so the files `--apply` touches
|
|
474
|
+
* are exactly the files the gate ran.
|
|
475
|
+
*/
|
|
476
|
+
function claimedSpecsOf(root, plan) {
|
|
477
|
+
const byFile = new Map();
|
|
478
|
+
for (const sc of plan.scenarios) {
|
|
479
|
+
const set = byFile.get(sc.file) ?? new Set();
|
|
480
|
+
set.add(sc.reqId);
|
|
481
|
+
byFile.set(sc.file, set);
|
|
482
|
+
}
|
|
483
|
+
return [...byFile.entries()]
|
|
484
|
+
.sort(([a], [b]) => byCodeUnit(a, b))
|
|
485
|
+
.map(([file, ids]) => ({ file: join(root, file), reqIds: [...ids].sort(byCodeUnit) }));
|
|
486
|
+
}
|
|
487
|
+
/** Split a plan over the proposed specs into the part one delta claims. */
|
|
488
|
+
function claimedByDelta(proposed, delta) {
|
|
489
|
+
const claimed = new Set(claimedIds(delta));
|
|
490
|
+
// A file is claimed whole or not at all. Scenarios in one file can name
|
|
491
|
+
// several requirements, and running half a file is not something Vitest can
|
|
492
|
+
// be asked for — the run scope is a set of files — so a per-scenario split
|
|
493
|
+
// would make the static plan describe a run that cannot happen.
|
|
494
|
+
const files = new Set(proposed.scenarios.filter((s) => claimed.has(s.reqId)).map((s) => s.file));
|
|
495
|
+
const scenarios = proposed.scenarios.filter((s) => files.has(s.file));
|
|
496
|
+
// A `ParamRef` records a requirement and a scenario name, not a file, so it
|
|
497
|
+
// is carried by the scenario it was seen in rather than filtered on its own.
|
|
498
|
+
// Dropping the pair down to `reqId` would let a param read in a *merged*
|
|
499
|
+
// scenario silence the drift heuristic for a proposed one, and vice versa.
|
|
500
|
+
const key = (reqId, scenario) => JSON.stringify([reqId, scenario]);
|
|
501
|
+
const kept = new Set(scenarios.map((s) => key(s.reqId, s.name)));
|
|
502
|
+
return {
|
|
503
|
+
scenarios,
|
|
504
|
+
paramRefs: proposed.paramRefs.filter((p) => kept.has(key(p.reqId, p.scenario))),
|
|
505
|
+
};
|
|
506
|
+
}
|
|
328
507
|
/**
|
|
329
508
|
* The static plan a change is gated and reported against: the base suite plus
|
|
330
|
-
* the change's
|
|
331
|
-
*
|
|
509
|
+
* the specs this change's delta claims (design §8). Takes the scan rather than
|
|
510
|
+
* walking the tree again, so a caller that already scanned does not repeat it.
|
|
332
511
|
*/
|
|
333
|
-
async function changeMergedPlan(root,
|
|
334
|
-
const basePlan = await parseSpecs(specFiles, root);
|
|
335
|
-
const
|
|
512
|
+
async function changeMergedPlan(root, delta, scan) {
|
|
513
|
+
const basePlan = await parseSpecs(scan.specFiles, root);
|
|
514
|
+
const proposed = await parseSpecs(scan.proposedSpecFiles, root);
|
|
515
|
+
const changePlan = claimedByDelta(proposed, delta);
|
|
336
516
|
return {
|
|
337
517
|
scenarios: [...basePlan.scenarios, ...changePlan.scenarios],
|
|
338
518
|
paramRefs: [...basePlan.paramRefs, ...changePlan.paramRefs],
|
|
@@ -340,6 +520,7 @@ async function changeMergedPlan(root, changeName, specFiles) {
|
|
|
340
520
|
}
|
|
341
521
|
/** Per-change progress (design §9: `attest status <change>`). */
|
|
342
522
|
export async function runStatus(root, changeName, options = {}) {
|
|
523
|
+
const unusable = compilerIssue();
|
|
343
524
|
const nothing = (issues) => ({
|
|
344
525
|
change: changeName,
|
|
345
526
|
rows: [],
|
|
@@ -350,27 +531,66 @@ export async function runStatus(root, changeName, options = {}) {
|
|
|
350
531
|
// gate checks it: under `--eval` the name reaches a module that is executed.
|
|
351
532
|
if (!isSafeChangeName(changeName))
|
|
352
533
|
return nothing([invalidChangeNameIssue(changeName)]);
|
|
534
|
+
if (unusable)
|
|
535
|
+
return nothing([unusable]);
|
|
353
536
|
const read = await readDelta(root, changeName, options);
|
|
354
537
|
if ('issue' in read)
|
|
355
538
|
return nothing([read.issue]);
|
|
356
539
|
const scan = await scanProject(root);
|
|
357
|
-
const plan = await changeMergedPlan(root,
|
|
540
|
+
const plan = await changeMergedPlan(root, read.delta, scan);
|
|
358
541
|
const firstRun = await readRedRecord(root, changeName);
|
|
359
542
|
const rows = statusRows(addedIds(read.delta), plan, firstRun);
|
|
360
543
|
return { change: changeName, rows, counts: statusCounts(rows), issues: [] };
|
|
361
544
|
}
|
|
362
545
|
/** Archive gate for a change (design §8, §9: `attest archive <change>`). */
|
|
363
546
|
export async function runArchive(root, changeName, options = {}) {
|
|
547
|
+
return (await archiveRun(root, changeName, options)).issues;
|
|
548
|
+
}
|
|
549
|
+
/**
|
|
550
|
+
* The gate, and — only if it passes — the merge it approved
|
|
551
|
+
* (`attest archive <change> --apply`).
|
|
552
|
+
*
|
|
553
|
+
* The gate runs first and unconditionally, in this same call. A merge acting on
|
|
554
|
+
* an earlier run's verdict would let an unfinished change be filed as done, and
|
|
555
|
+
* "done" having a hard definition is the whole claim of the tool; so a red gate
|
|
556
|
+
* writes nothing, and there is no flag that skips it.
|
|
557
|
+
*/
|
|
558
|
+
export async function runArchiveApply(root, changeName, options = {}) {
|
|
559
|
+
const { issues, merge } = await archiveRun(root, changeName, options);
|
|
560
|
+
if (issues.some((i) => i.level === 'ERROR') || !merge)
|
|
561
|
+
return { issues, written: [] };
|
|
562
|
+
const result = await applyMerge({ root, changeName, ...merge });
|
|
563
|
+
// The gate's non-blocking output is kept: a WARNING the gate raised is still
|
|
564
|
+
// true after the merge, and dropping it here would make `--apply` quieter than
|
|
565
|
+
// the same command without it.
|
|
566
|
+
return { issues: [...issues, ...result.issues], written: result.written };
|
|
567
|
+
}
|
|
568
|
+
/**
|
|
569
|
+
* The gate, plus what finishing the merge would need.
|
|
570
|
+
*
|
|
571
|
+
* One function rather than a gate and a separate `--apply` path, because the
|
|
572
|
+
* merge must act on **this** run's verdict. A second traversal could reach a
|
|
573
|
+
* different answer than the one just printed — and a command able to file a
|
|
574
|
+
* change as done against a stale verdict removes the hard definition of "done"
|
|
575
|
+
* that is this tool's whole claim.
|
|
576
|
+
*
|
|
577
|
+
* `merge` is absent exactly when there is nothing to act on: a rejected name, an
|
|
578
|
+
* unreadable delta, or a registry that would not load.
|
|
579
|
+
*/
|
|
580
|
+
async function archiveRun(root, changeName, options = {}) {
|
|
364
581
|
// Checked before any loader starts: nothing should be resolved, let alone
|
|
365
582
|
// evaluated, on behalf of a name we have already rejected.
|
|
366
583
|
if (!isSafeChangeName(changeName))
|
|
367
|
-
return [invalidChangeNameIssue(changeName)];
|
|
584
|
+
return { issues: [invalidChangeNameIssue(changeName)] };
|
|
585
|
+
const unusable = compilerIssue();
|
|
586
|
+
if (unusable)
|
|
587
|
+
return { issues: [unusable] };
|
|
368
588
|
const scan = await scanProject(root);
|
|
369
589
|
const loader = await createLoader();
|
|
370
590
|
try {
|
|
371
|
-
const { registry: base, issues } = await loadRegistry(root, evalReader(loader), scan.reqsFiles);
|
|
591
|
+
const { registry: base, issues, prefixOwners } = await loadRegistry(root, evalReader(loader), scan.reqsFiles);
|
|
372
592
|
if (issues.some((i) => i.level === 'ERROR'))
|
|
373
|
-
return issues;
|
|
593
|
+
return { issues };
|
|
374
594
|
const deltaPath = changeDeltaPath(root, changeName);
|
|
375
595
|
let delta;
|
|
376
596
|
try {
|
|
@@ -387,22 +607,22 @@ export async function runArchive(root, changeName, options = {}) {
|
|
|
387
607
|
delta = mod.default;
|
|
388
608
|
}
|
|
389
609
|
catch (err) {
|
|
390
|
-
return [changeNotFoundIssue(root, deltaPath, changeName, err)];
|
|
610
|
+
return { issues: [changeNotFoundIssue(root, deltaPath, changeName, err)] };
|
|
391
611
|
}
|
|
392
612
|
const applied = applyDelta(base, delta);
|
|
393
613
|
if (applied.issues.length > 0)
|
|
394
|
-
return applied.issues;
|
|
614
|
+
return { issues: applied.issues };
|
|
395
615
|
// Static plan = merged base suite + this change's specs (design §8), by the
|
|
396
616
|
// same function `status` reports against — a progress report computed over a
|
|
397
617
|
// different spec set than the gate uses would be a report about nothing.
|
|
398
|
-
const plan = await changeMergedPlan(root,
|
|
399
|
-
//
|
|
400
|
-
|
|
401
|
-
|
|
402
|
-
|
|
403
|
-
|
|
404
|
-
|
|
405
|
-
];
|
|
618
|
+
const plan = await changeMergedPlan(root, delta, scan);
|
|
619
|
+
// Other proposals need no exclude glob of their own: the include list below
|
|
620
|
+
// is the plan's own files, and the plan holds only the proposed specs this
|
|
621
|
+
// delta claims. That is what replaced `**/changes/<sibling>/**` — with the
|
|
622
|
+
// specs no longer living under `changes/`, a directory glob could not have
|
|
623
|
+
// told two proposals apart, and one kept alongside the claim check would be
|
|
624
|
+
// a second scoping rule able to disagree with it.
|
|
625
|
+
const exclude = [...BASE_EXCLUDE, '**/archive/**'];
|
|
406
626
|
// Same run scope as `verify`: only the files that declare a requirement().
|
|
407
627
|
// The gate must not go red because a repo's incumbent suite happens to sit
|
|
408
628
|
// under the same root as the change being archived.
|
|
@@ -429,7 +649,30 @@ export async function runArchive(root, changeName, options = {}) {
|
|
|
429
649
|
await writeAtomic(redRecordPath(root, changeName), serialiseRedRecord(changeName, merged.record));
|
|
430
650
|
}
|
|
431
651
|
}
|
|
432
|
-
|
|
652
|
+
// Against `base`, not `applied`: the child run imported the registry from
|
|
653
|
+
// disk, so what matters is what that file has, not what the gate computed.
|
|
654
|
+
const unmergedAddedIds = added.filter((id) => !Object.hasOwn(base, id));
|
|
655
|
+
return {
|
|
656
|
+
issues: evaluateGate({
|
|
657
|
+
registry: applied.registry,
|
|
658
|
+
plan,
|
|
659
|
+
run,
|
|
660
|
+
addedIds: added,
|
|
661
|
+
unmergedAddedIds,
|
|
662
|
+
firstRun,
|
|
663
|
+
}),
|
|
664
|
+
merge: {
|
|
665
|
+
delta,
|
|
666
|
+
base,
|
|
667
|
+
applied: applied.registry,
|
|
668
|
+
prefixOwners,
|
|
669
|
+
// The plan's proposed files, not every proposed file on disk: `--apply`
|
|
670
|
+
// renames what *this* change claims, and a sibling proposal's spec is
|
|
671
|
+
// not this change's to move.
|
|
672
|
+
claimedSpecs: claimedSpecsOf(root, claimedByDelta(await parseSpecs(scan.proposedSpecFiles, root), delta)),
|
|
673
|
+
mergedSpecs: scan.specFiles,
|
|
674
|
+
},
|
|
675
|
+
};
|
|
433
676
|
}
|
|
434
677
|
finally {
|
|
435
678
|
await loader.close();
|
package/dist/core/registry.d.ts
CHANGED
|
@@ -75,10 +75,69 @@ export interface RegistryDelta {
|
|
|
75
75
|
}[];
|
|
76
76
|
}
|
|
77
77
|
/**
|
|
78
|
-
*
|
|
79
|
-
*
|
|
80
|
-
*
|
|
78
|
+
* The requirements a delta ADDs, keyed by id and typed at the params written —
|
|
79
|
+
* the same shape {@link defineRequirements} returns, over the half of a change
|
|
80
|
+
* that introduces requirements.
|
|
81
|
+
*
|
|
82
|
+
* MODIFIED ids are deliberately absent. A modified requirement's end state is
|
|
83
|
+
* the base entry with the patch applied, and the base is not in this file; a
|
|
84
|
+
* view that showed the patch alone would answer `params.x` with the proposed
|
|
85
|
+
* value and `params.y` with `undefined` for a key the requirement has. That is
|
|
86
|
+
* a shape that reads as the merged requirement and is not one, which is worse
|
|
87
|
+
* than not offering it.
|
|
88
|
+
*/
|
|
89
|
+
type ProposedRequirements<T extends RegistryDelta> = T extends {
|
|
90
|
+
added: infer A;
|
|
91
|
+
} ? {
|
|
92
|
+
[K in keyof A]: Omit<Requirement, 'params'> & {
|
|
93
|
+
params: DefinedParams<A[K]>;
|
|
94
|
+
};
|
|
95
|
+
} : unknown;
|
|
96
|
+
/**
|
|
97
|
+
* What `delta()` returns: the delta itself, also readable as the registry of
|
|
98
|
+
* what it proposes.
|
|
99
|
+
*
|
|
100
|
+
* The union is safe by grammar rather than by convention — a requirement id
|
|
101
|
+
* matches `/^[A-Z]+-\d+$/` (`RequirementIdSchema`), so no id can ever be spelled
|
|
102
|
+
* `added`, `modified`, `removed` or `renamed`. That is what lets one value carry
|
|
103
|
+
* both surfaces without either shadowing the other.
|
|
104
|
+
*
|
|
105
|
+
* There is no index signature, unlike {@link DefinedRegistry}. That one keeps
|
|
106
|
+
* its because `reqs[id]` with an `id: string` is a real pattern in a shared test
|
|
107
|
+
* helper over a whole registry; the ids a single change adds are few and known
|
|
108
|
+
* at the call site, so the stricter shape costs nothing and rejects a mistyped
|
|
109
|
+
* id outright instead of leaving it to `check`.
|
|
110
|
+
*/
|
|
111
|
+
export type DefinedDelta<T extends RegistryDelta> = T & ProposedRequirements<T>;
|
|
112
|
+
/**
|
|
113
|
+
* Declare a registry delta for a change (design §7). Ordered idempotent
|
|
114
|
+
* application (RENAMED -> REMOVED -> MODIFIED -> ADDED) lives in applyDelta.
|
|
115
|
+
*
|
|
116
|
+
* The returned value is the delta *and* the registry of the requirements it
|
|
117
|
+
* adds, so a stage-1 scenario reads a proposed param with the expression a
|
|
118
|
+
* merged one uses — `reqs['AUTH-7'].params.totpWindowSec`, not
|
|
119
|
+
* `d.added!['AUTH-7']!.params!.totpWindowSec as number`. Stage 1 is where the
|
|
120
|
+
* scenario must be written and must go red, which made it the one stage where
|
|
121
|
+
* the workflow pushed the author off `params`, the single source the whole tool
|
|
122
|
+
* exists to reward — and then billed them the rewrite at merge for having
|
|
123
|
+
* complied. Now merging a spec changes its import and nothing else.
|
|
124
|
+
*
|
|
125
|
+
* Still no validation, deliberately. A delta carrying an id the registry would
|
|
126
|
+
* refuse is an `add-invalid` / `rename-target-invalid` ERROR from the gate
|
|
127
|
+
* (ATX-41), which is where a reviewer sees it; throwing here would move that
|
|
128
|
+
* verdict into whichever command happened to evaluate the file first.
|
|
129
|
+
*/
|
|
130
|
+
export declare function delta<const T extends RegistryDelta>(d: T): DefinedDelta<T>;
|
|
131
|
+
/**
|
|
132
|
+
* A delta, also keyed by the ids it ADDs. The one place that shape is built.
|
|
133
|
+
*
|
|
134
|
+
* Both readers go through this — `delta()` on the evaluated path, and
|
|
135
|
+
* `readDeltaSource` on the static one — because the differential suite asserts
|
|
136
|
+
* the two agree about what a delta *is*, and a second copy of this three-line
|
|
137
|
+
* spread is exactly the kind of agreement that holds by transcription until it
|
|
138
|
+
* does not. A new object rather than a mutated argument, so the literal a
|
|
139
|
+
* caller wrote is still the literal they hold.
|
|
81
140
|
*/
|
|
82
|
-
export declare function
|
|
141
|
+
export declare function withProposedRequirements<T extends RegistryDelta>(d: T): T;
|
|
83
142
|
export {};
|
|
84
143
|
//# sourceMappingURL=registry.d.ts.map
|
package/dist/core/registry.js
CHANGED
|
@@ -34,11 +34,37 @@ export function defineRequirements(input) {
|
|
|
34
34
|
return result.data;
|
|
35
35
|
}
|
|
36
36
|
/**
|
|
37
|
-
* Declare a registry delta for a change
|
|
38
|
-
*
|
|
39
|
-
*
|
|
37
|
+
* Declare a registry delta for a change (design §7). Ordered idempotent
|
|
38
|
+
* application (RENAMED -> REMOVED -> MODIFIED -> ADDED) lives in applyDelta.
|
|
39
|
+
*
|
|
40
|
+
* The returned value is the delta *and* the registry of the requirements it
|
|
41
|
+
* adds, so a stage-1 scenario reads a proposed param with the expression a
|
|
42
|
+
* merged one uses — `reqs['AUTH-7'].params.totpWindowSec`, not
|
|
43
|
+
* `d.added!['AUTH-7']!.params!.totpWindowSec as number`. Stage 1 is where the
|
|
44
|
+
* scenario must be written and must go red, which made it the one stage where
|
|
45
|
+
* the workflow pushed the author off `params`, the single source the whole tool
|
|
46
|
+
* exists to reward — and then billed them the rewrite at merge for having
|
|
47
|
+
* complied. Now merging a spec changes its import and nothing else.
|
|
48
|
+
*
|
|
49
|
+
* Still no validation, deliberately. A delta carrying an id the registry would
|
|
50
|
+
* refuse is an `add-invalid` / `rename-target-invalid` ERROR from the gate
|
|
51
|
+
* (ATX-41), which is where a reviewer sees it; throwing here would move that
|
|
52
|
+
* verdict into whichever command happened to evaluate the file first.
|
|
40
53
|
*/
|
|
41
54
|
export function delta(d) {
|
|
42
|
-
return d;
|
|
55
|
+
return withProposedRequirements(d);
|
|
56
|
+
}
|
|
57
|
+
/**
|
|
58
|
+
* A delta, also keyed by the ids it ADDs. The one place that shape is built.
|
|
59
|
+
*
|
|
60
|
+
* Both readers go through this — `delta()` on the evaluated path, and
|
|
61
|
+
* `readDeltaSource` on the static one — because the differential suite asserts
|
|
62
|
+
* the two agree about what a delta *is*, and a second copy of this three-line
|
|
63
|
+
* spread is exactly the kind of agreement that holds by transcription until it
|
|
64
|
+
* does not. A new object rather than a mutated argument, so the literal a
|
|
65
|
+
* caller wrote is still the literal they hold.
|
|
66
|
+
*/
|
|
67
|
+
export function withProposedRequirements(d) {
|
|
68
|
+
return { ...d, ...(d.added ?? {}) };
|
|
43
69
|
}
|
|
44
70
|
//# sourceMappingURL=registry.js.map
|