@am_shork/attest 0.5.0 → 0.7.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CHANGELOG.md +1063 -143
- package/README.md +1 -1
- package/dist/cli/index.js +13 -11
- package/dist/cli/json.js +6 -1
- package/dist/cli/report.d.ts +18 -0
- package/dist/cli/report.js +41 -0
- package/dist/core/compiler.d.ts +40 -0
- package/dist/core/compiler.js +64 -0
- package/dist/core/docs.d.ts +1 -1
- package/dist/core/docs.js +6 -0
- package/dist/core/gate.d.ts +48 -2
- package/dist/core/gate.js +73 -14
- package/dist/core/loader.js +13 -0
- package/dist/core/locate.d.ts +62 -0
- package/dist/core/locate.js +94 -11
- package/dist/core/merge.d.ts +54 -0
- package/dist/core/merge.js +257 -0
- package/dist/core/pipeline.d.ts +13 -0
- package/dist/core/pipeline.js +319 -40
- package/dist/core/render.js +129 -14
- package/dist/core/req-suite.d.ts +5 -0
- package/dist/core/req-suite.js +27 -0
- package/dist/core/runner.js +26 -8
- package/dist/core/skill.js +52 -12
- package/dist/core/splice.d.ts +52 -0
- package/dist/core/splice.js +189 -0
- package/dist/core/static-registry.d.ts +30 -0
- package/dist/core/static-registry.js +36 -0
- package/dist/core/status.js +21 -3
- package/dist/core/terminal.d.ts +23 -0
- package/dist/core/terminal.js +25 -9
- package/dist/core/validator.d.ts +5 -1
- package/dist/core/validator.js +23 -2
- package/dist/runtime.d.ts +20 -0
- package/dist/runtime.js +43 -15
- package/package.json +1 -1
package/dist/core/pipeline.js
CHANGED
|
@@ -1,29 +1,68 @@
|
|
|
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
|
-
import {
|
|
4
|
-
import { evalReader, loadRegistry, listChangeNames, parseSpecs, scanProject, staticReader, } from './locate.js';
|
|
3
|
+
import { evalReader, findChangeDirSpecs, loadRegistry, listChangeNames, parseSpecs, scanProject, staticReader, } from './locate.js';
|
|
5
4
|
import { validateStructure, detectPotentialDrift, uncoveredIssues } from './validator.js';
|
|
6
5
|
import { byCodeUnit } from './order.js';
|
|
7
|
-
import { runAndCollect, BASE_EXCLUDE } from './runner.js';
|
|
8
6
|
import { applyDelta, addedIds, claimedIds } from './apply.js';
|
|
9
7
|
import { readDeltaSource } from './static-registry.js';
|
|
10
8
|
import { statusRows, statusCounts } from './status.js';
|
|
11
|
-
import { evaluateGate,
|
|
9
|
+
import { evaluateGate, notRunIssues } from './gate.js';
|
|
12
10
|
import { renderMarkdown, staleIssue } from './render.js';
|
|
13
11
|
import { mergeRedRecord, readRedRecord, redRecordPath, serialiseRedRecord, } from './red-record.js';
|
|
14
12
|
import { DEFAULT_TARGET, resolveTargets } from './targets.js';
|
|
15
13
|
import { writeAtomic } from './write.js';
|
|
14
|
+
import { applyMerge, mergedSpecPath } from './merge.js';
|
|
15
|
+
import { compilerIssue } from './compiler.js';
|
|
16
16
|
import { mkdir, readFile } from 'node:fs/promises';
|
|
17
17
|
import { basename, dirname, join } from 'node:path';
|
|
18
18
|
import { relativePath } from './paths.js';
|
|
19
19
|
import { hasError } from './types.js';
|
|
20
|
+
// The runner half of the engine, reached only when a command actually needs it.
|
|
21
|
+
//
|
|
22
|
+
// These two are deliberately not top-level imports. `cli/index.ts` imports this
|
|
23
|
+
// module whole, so a static import here resolves and evaluates `vite`,
|
|
24
|
+
// `vitest/node` and `vitest/config` before `commander` has parsed an argument —
|
|
25
|
+
// measured at ~900 ms, paid by `check`, `cover`, `render`, `status` without
|
|
26
|
+
// `--eval` and `init`, none of which can start a run. §11's cost model is a
|
|
27
|
+
// short-lived process where cold start is the whole bill, so a fixed toll on
|
|
28
|
+
// every invocation outweighs anything scaling with requirement count.
|
|
29
|
+
//
|
|
30
|
+
// This is *not* the core/adapter package split §11 defers, and does not
|
|
31
|
+
// substitute for it: `pipeline.ts` still holds both halves, so the reachability
|
|
32
|
+
// `tests/import-boundary.spec.ts` cannot assert is still unassertable. What
|
|
33
|
+
// moves is when the cost is paid. The specifiers stay relative and the gate
|
|
34
|
+
// reads `import(...)` from the AST alongside static imports, so the boundary is
|
|
35
|
+
// checked exactly as before — that is the one property this change could have
|
|
36
|
+
// silently dropped, and the reason that test looks for dynamic imports at all.
|
|
37
|
+
/** {@link import('./loader.js').createLoader}, resolved on first use. */
|
|
38
|
+
async function createLoader() {
|
|
39
|
+
const { createLoader: create } = await import('./loader.js');
|
|
40
|
+
return create();
|
|
41
|
+
}
|
|
42
|
+
/**
|
|
43
|
+
* {@link import('./runner.js').runAndCollect}, resolved on first use.
|
|
44
|
+
*
|
|
45
|
+
* `BASE_EXCLUDE` gets no such wrapper: it has one caller, which is inside the
|
|
46
|
+
* function that starts the run it belongs to, so it is destructured there
|
|
47
|
+
* alongside nothing.
|
|
48
|
+
*/
|
|
49
|
+
async function runAndCollect(options = {}) {
|
|
50
|
+
const { runAndCollect: run } = await import('./runner.js');
|
|
51
|
+
return run(options);
|
|
52
|
+
}
|
|
20
53
|
/**
|
|
21
54
|
* Read every registry under root with the reader `options` asks for, and close
|
|
22
55
|
* whatever that reader needed. Static reading starts no Vite server at all.
|
|
56
|
+
*
|
|
57
|
+
* `borrowed` is a loader the caller is already holding open for this command.
|
|
58
|
+
* See `withLoader` for why a command with more than one thing to evaluate owns
|
|
59
|
+
* one rather than letting each read start its own.
|
|
23
60
|
*/
|
|
24
|
-
async function readRegistry(root, options, files) {
|
|
61
|
+
async function readRegistry(root, options, files, borrowed) {
|
|
25
62
|
if (!options.evaluate)
|
|
26
63
|
return loadRegistry(root, staticReader(), files);
|
|
64
|
+
if (borrowed)
|
|
65
|
+
return loadRegistry(root, evalReader(borrowed), files);
|
|
27
66
|
const loader = await createLoader();
|
|
28
67
|
try {
|
|
29
68
|
return await loadRegistry(root, evalReader(loader), files);
|
|
@@ -32,17 +71,123 @@ async function readRegistry(root, options, files) {
|
|
|
32
71
|
await loader.close();
|
|
33
72
|
}
|
|
34
73
|
}
|
|
74
|
+
/**
|
|
75
|
+
* Run `fn` with one loader for the whole command, or with none when nothing is
|
|
76
|
+
* to be evaluated.
|
|
77
|
+
*
|
|
78
|
+
* A loader is a Vite dev server: starting one is the expensive thing on this
|
|
79
|
+
* path, and `check --eval` used to start **one per change** — `readDelta`
|
|
80
|
+
* created and closed its own on every iteration of the loop over
|
|
81
|
+
* `listChangeNames`, on top of the one `readRegistry` had already opened and
|
|
82
|
+
* closed for the same command. So the cost was 1 + N servers for a command that
|
|
83
|
+
* needs one. Ownership sits here rather than in either reader because neither
|
|
84
|
+
* of them knows how many times it is about to be called.
|
|
85
|
+
*
|
|
86
|
+
* Static commands pass `undefined` all the way down and start nothing at all,
|
|
87
|
+
* which is the property `check` without `--eval` is documented on (design §5.1)
|
|
88
|
+
* and the reason this returns an optional rather than always creating one.
|
|
89
|
+
*/
|
|
90
|
+
async function withLoader(options, fn) {
|
|
91
|
+
if (!options.evaluate)
|
|
92
|
+
return fn(undefined);
|
|
93
|
+
const loader = await createLoader();
|
|
94
|
+
try {
|
|
95
|
+
return await fn(loader);
|
|
96
|
+
}
|
|
97
|
+
finally {
|
|
98
|
+
await loader.close();
|
|
99
|
+
}
|
|
100
|
+
}
|
|
35
101
|
/** Static structural check (design §9: `attest check`). */
|
|
36
102
|
export async function runCheck(root, options = {}) {
|
|
103
|
+
const unusable = compilerIssue();
|
|
104
|
+
if (unusable)
|
|
105
|
+
return [unusable];
|
|
37
106
|
const scan = await scanProject(root);
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
|
|
107
|
+
// One loader for the command, not one per thing evaluated: this is the only
|
|
108
|
+
// command that reads both the registry and every change's delta, so it is the
|
|
109
|
+
// only one where that distinction is worth anything.
|
|
110
|
+
return withLoader(options, async (loader) => {
|
|
111
|
+
const { registry, issues, unreadableFiles } = await readRegistry(root, options, scan.reqsFiles, loader);
|
|
112
|
+
const plan = await parseSpecs(scan.specFiles, root);
|
|
113
|
+
return [
|
|
114
|
+
...issues,
|
|
115
|
+
// `check` keeps reporting on a registry that only half-loaded,
|
|
116
|
+
// deliberately: its contract is breadth, and the findings from the files
|
|
117
|
+
// that *did* load are all still true. What it must not do is advise work
|
|
118
|
+
// that the load failure makes wrong — see `orphan-test` in
|
|
119
|
+
// `validateStructure`.
|
|
120
|
+
...validateStructure(registry, plan, unreadableFiles.length > 0),
|
|
121
|
+
...detectPotentialDrift(registry, plan, plan.paramRefs),
|
|
122
|
+
...(await unclaimedProposedSpecIssues(root, scan, options, loader)),
|
|
123
|
+
...(await changeDirSpecIssues(root)),
|
|
124
|
+
...proposedNameTakenIssues(root, scan),
|
|
125
|
+
];
|
|
126
|
+
});
|
|
127
|
+
}
|
|
128
|
+
/**
|
|
129
|
+
* A proposed spec whose merged name is already taken (design §7).
|
|
130
|
+
*
|
|
131
|
+
* Merging a proposed spec is a rename in place, so the name it will take is
|
|
132
|
+
* decided the moment the file is created — and nothing stopped that name from
|
|
133
|
+
* being one a merged spec already holds. `isSpecFile` and `isProposedSpecFile`
|
|
134
|
+
* are deliberately disjoint, so `session.spec.ts` and `session.proposed.spec.ts`
|
|
135
|
+
* sit side by side without either predicate objecting; the collision only
|
|
136
|
+
* surfaces at the rename, where it would **destroy** the merged file.
|
|
137
|
+
*
|
|
138
|
+
* It is not a corner. A proposed spec is written beside the code it attests, so
|
|
139
|
+
* a change to a module that already has tests — the ordinary kind — reaches for
|
|
140
|
+
* exactly the colliding name.
|
|
141
|
+
*
|
|
142
|
+
* Reported here rather than only refused by `--apply` because the fix is a
|
|
143
|
+
* rename at authoring time and the cost of learning late is a merge stopped
|
|
144
|
+
* halfway. `--apply` checks it again regardless: this is the one failure mode of
|
|
145
|
+
* that command that would destroy work rather than halt, and a check living in a
|
|
146
|
+
* different command is not a guard.
|
|
147
|
+
*/
|
|
148
|
+
function proposedNameTakenIssues(root, scan) {
|
|
149
|
+
const taken = new Set(scan.specFiles);
|
|
150
|
+
return scan.proposedSpecFiles
|
|
151
|
+
.filter((p) => taken.has(mergedSpecPath(p)))
|
|
152
|
+
.map((p) => ({
|
|
153
|
+
level: 'ERROR',
|
|
154
|
+
code: 'proposed-spec-name-taken',
|
|
155
|
+
file: relativePath(root, p),
|
|
156
|
+
message: `${relativePath(root, p)} merges to ${relativePath(root, mergedSpecPath(p))}, which already exists — the rename would overwrite it. ` +
|
|
157
|
+
`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.`,
|
|
158
|
+
}));
|
|
159
|
+
}
|
|
160
|
+
/**
|
|
161
|
+
* A spec still sitting under `changes/` (design §7).
|
|
162
|
+
*
|
|
163
|
+
* The companion to `unclaimedProposedSpecIssues`, and the reason both exist is
|
|
164
|
+
* the same: a spec that runs nowhere. That one covers a file at the right path
|
|
165
|
+
* no delta reaches; this one covers a file no *scan* reaches, which is the
|
|
166
|
+
* blind spot moving specs to their merged location opened underneath it.
|
|
167
|
+
*
|
|
168
|
+
* `check` rather than the gate, for the reason ATX-47 gives about its sibling
|
|
169
|
+
* and one more of its own: the gate does speak here, but it says
|
|
170
|
+
* `uncovered-requirement` — the added ids genuinely have no scenario the plan
|
|
171
|
+
* can see — and sends an author who has written those scenarios off to write
|
|
172
|
+
* them again or defer a finished requirement. `check` is both the command a
|
|
173
|
+
* pipeline runs first and, until this, the one that said nothing at all.
|
|
174
|
+
*
|
|
175
|
+
* No `reqId`: the finding is about a file and a path, and no requirement is
|
|
176
|
+
* implicated by it — the ids the file covers are exactly what cannot be read
|
|
177
|
+
* without walking it.
|
|
178
|
+
*/
|
|
179
|
+
async function changeDirSpecIssues(root) {
|
|
180
|
+
const files = await findChangeDirSpecs(root);
|
|
181
|
+
return files.map((abs) => {
|
|
182
|
+
const file = relativePath(root, abs);
|
|
183
|
+
return {
|
|
184
|
+
level: 'ERROR',
|
|
185
|
+
code: 'spec-in-change-dir',
|
|
186
|
+
file,
|
|
187
|
+
message: `${file} is a spec under changes/, which no command walks: it runs in neither the base suite nor any change's gate. ` +
|
|
188
|
+
`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.`,
|
|
189
|
+
};
|
|
190
|
+
});
|
|
46
191
|
}
|
|
47
192
|
/**
|
|
48
193
|
* A `*.proposed.spec.ts` no change's delta claims (design §7).
|
|
@@ -60,13 +205,15 @@ export async function runCheck(root, options = {}) {
|
|
|
60
205
|
* — a change delta is intent, and the commands that only report read it from
|
|
61
206
|
* source (design §5.1).
|
|
62
207
|
*/
|
|
63
|
-
async function unclaimedProposedSpecIssues(root, scan, options) {
|
|
208
|
+
async function unclaimedProposedSpecIssues(root, scan, options, loader) {
|
|
64
209
|
if (scan.proposedSpecFiles.length === 0)
|
|
65
210
|
return [];
|
|
66
211
|
const claimed = new Set();
|
|
67
212
|
const issues = [];
|
|
213
|
+
// The loop `loader` exists for: under `--eval` this used to start and stop a
|
|
214
|
+
// Vite dev server per change.
|
|
68
215
|
for (const name of await listChangeNames(root)) {
|
|
69
|
-
const read = await readDelta(root, name, options);
|
|
216
|
+
const read = await readDelta(root, name, options, loader);
|
|
70
217
|
if ('issue' in read)
|
|
71
218
|
issues.push(read.issue);
|
|
72
219
|
else
|
|
@@ -96,6 +243,22 @@ async function unclaimedProposedSpecIssues(root, scan, options) {
|
|
|
96
243
|
}
|
|
97
244
|
return issues;
|
|
98
245
|
}
|
|
246
|
+
/**
|
|
247
|
+
* A verdict reached without running anything — the shape a refusal takes.
|
|
248
|
+
*
|
|
249
|
+
* `passed` is false rather than true: nothing ran, and a result claiming the
|
|
250
|
+
* suite passed because it never started is the silent-green this tool exists to
|
|
251
|
+
* refuse. The counts are zero for the same reason `Running 0 spec files` would
|
|
252
|
+
* be honest — they report what was looked at, and nothing was.
|
|
253
|
+
*/
|
|
254
|
+
function emptyVerify(issues) {
|
|
255
|
+
return {
|
|
256
|
+
issues,
|
|
257
|
+
passed: false,
|
|
258
|
+
ok: false,
|
|
259
|
+
counts: { requirements: 0, scenarios: 0, specFiles: 0, attesting: 0 },
|
|
260
|
+
};
|
|
261
|
+
}
|
|
99
262
|
/**
|
|
100
263
|
* The spec files a run should execute: the ones that declare a `requirement()`.
|
|
101
264
|
*
|
|
@@ -110,21 +273,28 @@ function attestingFiles(plan) {
|
|
|
110
273
|
}
|
|
111
274
|
/** Executable verification (design §9: `attest verify`). */
|
|
112
275
|
export async function runVerify(root, options = {}) {
|
|
276
|
+
const unusable = compilerIssue();
|
|
277
|
+
if (unusable)
|
|
278
|
+
return emptyVerify([unusable]);
|
|
113
279
|
const scan = await scanProject(root);
|
|
114
280
|
const loader = await createLoader();
|
|
115
281
|
let registry;
|
|
116
282
|
let plan;
|
|
283
|
+
let unreadableFiles;
|
|
117
284
|
const issues = [];
|
|
118
285
|
try {
|
|
119
286
|
const loaded = await loadRegistry(root, evalReader(loader), scan.reqsFiles);
|
|
120
287
|
registry = loaded.registry;
|
|
121
288
|
issues.push(...loaded.issues);
|
|
289
|
+
unreadableFiles = loaded.unreadableFiles;
|
|
122
290
|
plan = await parseSpecs(scan.specFiles, root);
|
|
123
291
|
}
|
|
124
292
|
finally {
|
|
125
293
|
await loader.close();
|
|
126
294
|
}
|
|
127
|
-
|
|
295
|
+
// Same as `check`: everything the loaded half supports is still reported, and
|
|
296
|
+
// only the advice that a load failure would make wrong is withdrawn.
|
|
297
|
+
issues.push(...validateStructure(registry, plan, unreadableFiles.length > 0));
|
|
128
298
|
issues.push(...detectPotentialDrift(registry, plan, plan.paramRefs));
|
|
129
299
|
const attesting = attestingFiles(plan);
|
|
130
300
|
const counts = {
|
|
@@ -167,7 +337,13 @@ export async function runVerify(root, options = {}) {
|
|
|
167
337
|
if (!run.passed) {
|
|
168
338
|
issues.push({ level: 'ERROR', code: 'tests-red', message: 'Some tests are failing.' });
|
|
169
339
|
}
|
|
170
|
-
|
|
340
|
+
// The load failures first, then the absences they caused — by the same
|
|
341
|
+
// function the gate calls, which is the point. `verify` used to ask only for
|
|
342
|
+
// the absences, so an ordinary broken import surfaced as a `declared-not-run`
|
|
343
|
+
// per scenario blaming a skip, with nothing anywhere naming the file that did
|
|
344
|
+
// not load. No more specific diagnosis to pass: `added-id-unmerged` is a
|
|
345
|
+
// statement about a change's delta, and `verify` has no change.
|
|
346
|
+
issues.push(...notRunIssues(plan, run));
|
|
171
347
|
return { issues, passed: run.passed, ok: run.passed && !hasError(issues), counts };
|
|
172
348
|
}
|
|
173
349
|
/**
|
|
@@ -182,6 +358,9 @@ export async function runVerify(root, options = {}) {
|
|
|
182
358
|
* shape it can take.
|
|
183
359
|
*/
|
|
184
360
|
export async function runCover(root, options = {}) {
|
|
361
|
+
const unusable = compilerIssue();
|
|
362
|
+
if (unusable)
|
|
363
|
+
return { rows: [], issues: [unusable] };
|
|
185
364
|
const scan = await scanProject(root);
|
|
186
365
|
const { registry, issues: loadIssues } = await readRegistry(root, options, scan.reqsFiles);
|
|
187
366
|
if (hasError(loadIssues))
|
|
@@ -191,8 +370,12 @@ export async function runCover(root, options = {}) {
|
|
|
191
370
|
for (const s of plan.scenarios) {
|
|
192
371
|
counts.set(s.reqId, (counts.get(s.reqId) ?? 0) + 1);
|
|
193
372
|
}
|
|
373
|
+
// `byCodeUnit`, not a bare `.sort()`. The two give an identical result on ids
|
|
374
|
+
// that match the grammar, and that is exactly why this is worth spelling:
|
|
375
|
+
// `order.ts` exists so ordering has one spelling across the engine, which is
|
|
376
|
+
// a property that survives an edit — an identical result is not.
|
|
194
377
|
const rows = Object.keys(registry)
|
|
195
|
-
.sort()
|
|
378
|
+
.sort(byCodeUnit)
|
|
196
379
|
.map((reqId) => ({
|
|
197
380
|
reqId,
|
|
198
381
|
covered: (counts.get(reqId) ?? 0) > 0,
|
|
@@ -209,6 +392,9 @@ export async function runCover(root, options = {}) {
|
|
|
209
392
|
* every time a line moves in a test file.
|
|
210
393
|
*/
|
|
211
394
|
export async function runRender(root, options = {}) {
|
|
395
|
+
const unusable = compilerIssue();
|
|
396
|
+
if (unusable)
|
|
397
|
+
return { markdown: '', issues: [unusable] };
|
|
212
398
|
const { registry, issues } = await readRegistry(root, options);
|
|
213
399
|
// A registry that failed to load yields a document that silently omits
|
|
214
400
|
// requirements. An incomplete spec doc is worse than none, so refuse.
|
|
@@ -330,10 +516,14 @@ function changeNotFoundIssue(root, deltaPath, changeName, err) {
|
|
|
330
516
|
* under review. `archive` evaluates unconditionally because it runs the suite
|
|
331
517
|
* anyway, so declining to evaluate one more module would buy it nothing.
|
|
332
518
|
*/
|
|
333
|
-
async function readDelta(root, changeName, options) {
|
|
519
|
+
async function readDelta(root, changeName, options, borrowed) {
|
|
334
520
|
const deltaPath = changeDeltaPath(root, changeName);
|
|
335
521
|
if (options.evaluate) {
|
|
336
|
-
|
|
522
|
+
// Borrow the caller's loader when it has one, own one otherwise. The
|
|
523
|
+
// difference matters only to the caller that reads several deltas — see
|
|
524
|
+
// `withLoader` — and a lone reader should not have to build scaffolding to
|
|
525
|
+
// ask for one delta.
|
|
526
|
+
const loader = borrowed ?? (await createLoader());
|
|
337
527
|
try {
|
|
338
528
|
const mod = await loader.load(deltaPath);
|
|
339
529
|
if (!mod.default || typeof mod.default !== 'object') {
|
|
@@ -347,7 +537,10 @@ async function readDelta(root, changeName, options) {
|
|
|
347
537
|
return { issue: changeNotFoundIssue(root, deltaPath, changeName, err) };
|
|
348
538
|
}
|
|
349
539
|
finally {
|
|
350
|
-
|
|
540
|
+
// Only what this call created. Closing a borrowed loader would take it
|
|
541
|
+
// out from under the caller's next iteration.
|
|
542
|
+
if (!borrowed)
|
|
543
|
+
await loader.close();
|
|
351
544
|
}
|
|
352
545
|
}
|
|
353
546
|
let source;
|
|
@@ -370,6 +563,24 @@ async function readDelta(root, changeName, options) {
|
|
|
370
563
|
},
|
|
371
564
|
};
|
|
372
565
|
}
|
|
566
|
+
/**
|
|
567
|
+
* One entry per claimed proposed spec, carrying the ids it declares a scenario
|
|
568
|
+
* for — what `--apply` needs to know which registry to repoint its import at.
|
|
569
|
+
*
|
|
570
|
+
* Built from the plan rather than by re-parsing, so the files `--apply` touches
|
|
571
|
+
* are exactly the files the gate ran.
|
|
572
|
+
*/
|
|
573
|
+
function claimedSpecsOf(root, plan) {
|
|
574
|
+
const byFile = new Map();
|
|
575
|
+
for (const sc of plan.scenarios) {
|
|
576
|
+
const set = byFile.get(sc.file) ?? new Set();
|
|
577
|
+
set.add(sc.reqId);
|
|
578
|
+
byFile.set(sc.file, set);
|
|
579
|
+
}
|
|
580
|
+
return [...byFile.entries()]
|
|
581
|
+
.sort(([a], [b]) => byCodeUnit(a, b))
|
|
582
|
+
.map(([file, ids]) => ({ file: join(root, file), reqIds: [...ids].sort(byCodeUnit) }));
|
|
583
|
+
}
|
|
373
584
|
/** Split a plan over the proposed specs into the part one delta claims. */
|
|
374
585
|
function claimedByDelta(proposed, delta) {
|
|
375
586
|
const claimed = new Set(claimedIds(delta));
|
|
@@ -398,14 +609,25 @@ function claimedByDelta(proposed, delta) {
|
|
|
398
609
|
async function changeMergedPlan(root, delta, scan) {
|
|
399
610
|
const basePlan = await parseSpecs(scan.specFiles, root);
|
|
400
611
|
const proposed = await parseSpecs(scan.proposedSpecFiles, root);
|
|
401
|
-
const
|
|
612
|
+
const claimed = claimedByDelta(proposed, delta);
|
|
402
613
|
return {
|
|
403
|
-
|
|
404
|
-
|
|
614
|
+
merged: {
|
|
615
|
+
scenarios: [...basePlan.scenarios, ...claimed.scenarios],
|
|
616
|
+
paramRefs: [...basePlan.paramRefs, ...claimed.paramRefs],
|
|
617
|
+
},
|
|
618
|
+
// Returned rather than recomputed by the caller that needs it. `--apply`
|
|
619
|
+
// renames exactly the proposed specs this change claims, and deriving that
|
|
620
|
+
// set a second time — a second `parseSpecs` and a second `claimedByDelta`
|
|
621
|
+
// over the same files — is agreement by transcription: the two answers
|
|
622
|
+
// match because the same two functions ran twice, which is the thing this
|
|
623
|
+
// codebase extracts `declaredNotRunIssues` and `uncoveredIssues` to stop
|
|
624
|
+
// relying on. One parse, one split, one answer.
|
|
625
|
+
claimed,
|
|
405
626
|
};
|
|
406
627
|
}
|
|
407
628
|
/** Per-change progress (design §9: `attest status <change>`). */
|
|
408
629
|
export async function runStatus(root, changeName, options = {}) {
|
|
630
|
+
const unusable = compilerIssue();
|
|
409
631
|
const nothing = (issues) => ({
|
|
410
632
|
change: changeName,
|
|
411
633
|
rows: [],
|
|
@@ -416,27 +638,69 @@ export async function runStatus(root, changeName, options = {}) {
|
|
|
416
638
|
// gate checks it: under `--eval` the name reaches a module that is executed.
|
|
417
639
|
if (!isSafeChangeName(changeName))
|
|
418
640
|
return nothing([invalidChangeNameIssue(changeName)]);
|
|
641
|
+
if (unusable)
|
|
642
|
+
return nothing([unusable]);
|
|
419
643
|
const read = await readDelta(root, changeName, options);
|
|
420
644
|
if ('issue' in read)
|
|
421
645
|
return nothing([read.issue]);
|
|
422
646
|
const scan = await scanProject(root);
|
|
423
|
-
const plan = await changeMergedPlan(root, read.delta, scan);
|
|
647
|
+
const { merged: plan } = await changeMergedPlan(root, read.delta, scan);
|
|
424
648
|
const firstRun = await readRedRecord(root, changeName);
|
|
425
649
|
const rows = statusRows(addedIds(read.delta), plan, firstRun);
|
|
426
650
|
return { change: changeName, rows, counts: statusCounts(rows), issues: [] };
|
|
427
651
|
}
|
|
428
652
|
/** Archive gate for a change (design §8, §9: `attest archive <change>`). */
|
|
429
653
|
export async function runArchive(root, changeName, options = {}) {
|
|
654
|
+
return (await archiveRun(root, changeName, options)).issues;
|
|
655
|
+
}
|
|
656
|
+
/**
|
|
657
|
+
* The gate, and — only if it passes — the merge it approved
|
|
658
|
+
* (`attest archive <change> --apply`).
|
|
659
|
+
*
|
|
660
|
+
* The gate runs first and unconditionally, in this same call. A merge acting on
|
|
661
|
+
* an earlier run's verdict would let an unfinished change be filed as done, and
|
|
662
|
+
* "done" having a hard definition is the whole claim of the tool; so a red gate
|
|
663
|
+
* writes nothing, and there is no flag that skips it.
|
|
664
|
+
*/
|
|
665
|
+
export async function runArchiveApply(root, changeName, options = {}) {
|
|
666
|
+
const { issues, merge } = await archiveRun(root, changeName, options);
|
|
667
|
+
// One predicate for the verdict, everywhere (ATX-60): the gate's WARNINGs
|
|
668
|
+
// survive `--apply` by design, so `some(level === 'ERROR')` spelled here by
|
|
669
|
+
// hand was a second definition of failure sitting next to the one that counts.
|
|
670
|
+
if (hasError(issues) || !merge)
|
|
671
|
+
return { issues, written: [] };
|
|
672
|
+
const result = await applyMerge({ root, changeName, ...merge });
|
|
673
|
+
// The gate's non-blocking output is kept: a WARNING the gate raised is still
|
|
674
|
+
// true after the merge, and dropping it here would make `--apply` quieter than
|
|
675
|
+
// the same command without it.
|
|
676
|
+
return { issues: [...issues, ...result.issues], written: result.written };
|
|
677
|
+
}
|
|
678
|
+
/**
|
|
679
|
+
* The gate, plus what finishing the merge would need.
|
|
680
|
+
*
|
|
681
|
+
* One function rather than a gate and a separate `--apply` path, because the
|
|
682
|
+
* merge must act on **this** run's verdict. A second traversal could reach a
|
|
683
|
+
* different answer than the one just printed — and a command able to file a
|
|
684
|
+
* change as done against a stale verdict removes the hard definition of "done"
|
|
685
|
+
* that is this tool's whole claim.
|
|
686
|
+
*
|
|
687
|
+
* `merge` is absent exactly when there is nothing to act on: a rejected name, an
|
|
688
|
+
* unreadable delta, or a registry that would not load.
|
|
689
|
+
*/
|
|
690
|
+
async function archiveRun(root, changeName, options = {}) {
|
|
430
691
|
// Checked before any loader starts: nothing should be resolved, let alone
|
|
431
692
|
// evaluated, on behalf of a name we have already rejected.
|
|
432
693
|
if (!isSafeChangeName(changeName))
|
|
433
|
-
return [invalidChangeNameIssue(changeName)];
|
|
694
|
+
return { issues: [invalidChangeNameIssue(changeName)] };
|
|
695
|
+
const unusable = compilerIssue();
|
|
696
|
+
if (unusable)
|
|
697
|
+
return { issues: [unusable] };
|
|
434
698
|
const scan = await scanProject(root);
|
|
435
699
|
const loader = await createLoader();
|
|
436
700
|
try {
|
|
437
|
-
const { registry: base, issues } = await loadRegistry(root, evalReader(loader), scan.reqsFiles);
|
|
438
|
-
if (issues
|
|
439
|
-
return issues;
|
|
701
|
+
const { registry: base, issues, prefixOwners } = await loadRegistry(root, evalReader(loader), scan.reqsFiles);
|
|
702
|
+
if (hasError(issues))
|
|
703
|
+
return { issues };
|
|
440
704
|
const deltaPath = changeDeltaPath(root, changeName);
|
|
441
705
|
let delta;
|
|
442
706
|
try {
|
|
@@ -453,21 +717,22 @@ export async function runArchive(root, changeName, options = {}) {
|
|
|
453
717
|
delta = mod.default;
|
|
454
718
|
}
|
|
455
719
|
catch (err) {
|
|
456
|
-
return [changeNotFoundIssue(root, deltaPath, changeName, err)];
|
|
720
|
+
return { issues: [changeNotFoundIssue(root, deltaPath, changeName, err)] };
|
|
457
721
|
}
|
|
458
722
|
const applied = applyDelta(base, delta);
|
|
459
723
|
if (applied.issues.length > 0)
|
|
460
|
-
return applied.issues;
|
|
724
|
+
return { issues: applied.issues };
|
|
461
725
|
// Static plan = merged base suite + this change's specs (design §8), by the
|
|
462
726
|
// same function `status` reports against — a progress report computed over a
|
|
463
727
|
// different spec set than the gate uses would be a report about nothing.
|
|
464
|
-
const plan = await changeMergedPlan(root, delta, scan);
|
|
728
|
+
const { merged: plan, claimed } = await changeMergedPlan(root, delta, scan);
|
|
465
729
|
// Other proposals need no exclude glob of their own: the include list below
|
|
466
730
|
// is the plan's own files, and the plan holds only the proposed specs this
|
|
467
731
|
// delta claims. That is what replaced `**/changes/<sibling>/**` — with the
|
|
468
732
|
// specs no longer living under `changes/`, a directory glob could not have
|
|
469
733
|
// told two proposals apart, and one kept alongside the claim check would be
|
|
470
734
|
// a second scoping rule able to disagree with it.
|
|
735
|
+
const { BASE_EXCLUDE } = await import('./runner.js');
|
|
471
736
|
const exclude = [...BASE_EXCLUDE, '**/archive/**'];
|
|
472
737
|
// Same run scope as `verify`: only the files that declare a requirement().
|
|
473
738
|
// The gate must not go red because a repo's incumbent suite happens to sit
|
|
@@ -498,14 +763,28 @@ export async function runArchive(root, changeName, options = {}) {
|
|
|
498
763
|
// Against `base`, not `applied`: the child run imported the registry from
|
|
499
764
|
// disk, so what matters is what that file has, not what the gate computed.
|
|
500
765
|
const unmergedAddedIds = added.filter((id) => !Object.hasOwn(base, id));
|
|
501
|
-
return
|
|
502
|
-
|
|
503
|
-
|
|
504
|
-
|
|
505
|
-
|
|
506
|
-
|
|
507
|
-
|
|
508
|
-
|
|
766
|
+
return {
|
|
767
|
+
issues: evaluateGate({
|
|
768
|
+
registry: applied.registry,
|
|
769
|
+
plan,
|
|
770
|
+
run,
|
|
771
|
+
addedIds: added,
|
|
772
|
+
unmergedAddedIds,
|
|
773
|
+
firstRun,
|
|
774
|
+
}),
|
|
775
|
+
merge: {
|
|
776
|
+
delta,
|
|
777
|
+
base,
|
|
778
|
+
applied: applied.registry,
|
|
779
|
+
prefixOwners,
|
|
780
|
+
// The plan's proposed files, not every proposed file on disk: `--apply`
|
|
781
|
+
// renames what *this* change claims, and a sibling proposal's spec is
|
|
782
|
+
// not this change's to move. `claimed` is the half of the plan the gate
|
|
783
|
+
// above ran, handed back by the function that built it.
|
|
784
|
+
claimedSpecs: claimedSpecsOf(root, claimed),
|
|
785
|
+
mergedSpecs: scan.specFiles,
|
|
786
|
+
},
|
|
787
|
+
};
|
|
509
788
|
}
|
|
510
789
|
finally {
|
|
511
790
|
await loader.close();
|