@am_shork/attest 0.4.2 → 0.5.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 +615 -55
- package/README.md +32 -11
- package/dist/core/apply.d.ts +15 -0
- package/dist/core/apply.js +43 -1
- package/dist/core/docs.d.ts +1 -1
- package/dist/core/docs.js +2 -0
- package/dist/core/gate.d.ts +40 -3
- package/dist/core/gate.js +56 -6
- package/dist/core/locate.d.ts +25 -4
- package/dist/core/locate.js +34 -11
- package/dist/core/parser.js +0 -0
- package/dist/core/pipeline.d.ts +0 -9
- package/dist/core/pipeline.js +117 -41
- 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 +62 -7
- package/dist/core/static-registry.js +6 -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, loadRegistry,
|
|
4
|
+
import { evalReader, 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';
|
|
@@ -41,8 +41,61 @@ export async function runCheck(root, options = {}) {
|
|
|
41
41
|
...issues,
|
|
42
42
|
...validateStructure(registry, plan),
|
|
43
43
|
...detectPotentialDrift(registry, plan, plan.paramRefs),
|
|
44
|
+
...(await unclaimedProposedSpecIssues(root, scan, options)),
|
|
44
45
|
];
|
|
45
46
|
}
|
|
47
|
+
/**
|
|
48
|
+
* A `*.proposed.spec.ts` no change's delta claims (design §7).
|
|
49
|
+
*
|
|
50
|
+
* A proposed spec is kept out of every normal run by its name and pulled into a
|
|
51
|
+
* gate run by its requirement ids, so one whose ids no delta mentions is a file
|
|
52
|
+
* that never executes anywhere — the silent drop this marker would otherwise
|
|
53
|
+
* cost. The base suite cannot catch it (the file is excluded there by design)
|
|
54
|
+
* and neither can the gate (it is scoped to what its own delta claims), which
|
|
55
|
+
* leaves `check` as the only command positioned to see it at all.
|
|
56
|
+
*
|
|
57
|
+
* A delta that cannot be read is reported as itself rather than turned into
|
|
58
|
+
* accusations against its specs: not knowing what a change claims is a different
|
|
59
|
+
* finding from knowing it claims nothing, and `check` already owes the first one
|
|
60
|
+
* — a change delta is intent, and the commands that only report read it from
|
|
61
|
+
* source (design §5.1).
|
|
62
|
+
*/
|
|
63
|
+
async function unclaimedProposedSpecIssues(root, scan, options) {
|
|
64
|
+
if (scan.proposedSpecFiles.length === 0)
|
|
65
|
+
return [];
|
|
66
|
+
const claimed = new Set();
|
|
67
|
+
const issues = [];
|
|
68
|
+
for (const name of await listChangeNames(root)) {
|
|
69
|
+
const read = await readDelta(root, name, options);
|
|
70
|
+
if ('issue' in read)
|
|
71
|
+
issues.push(read.issue);
|
|
72
|
+
else
|
|
73
|
+
for (const id of claimedIds(read.delta))
|
|
74
|
+
claimed.add(id);
|
|
75
|
+
}
|
|
76
|
+
const proposed = await parseSpecs(scan.proposedSpecFiles, root);
|
|
77
|
+
const claimedFiles = new Set(proposed.scenarios.filter((s) => claimed.has(s.reqId)).map((s) => s.file));
|
|
78
|
+
// Reported per file, not per scenario: the file is the unit a run includes,
|
|
79
|
+
// so it is the unit that did or did not execute, and one line per scenario
|
|
80
|
+
// would say the same thing several times about one unread file. Driven off
|
|
81
|
+
// the scanned files rather than off the parsed scenarios, so a proposed spec
|
|
82
|
+
// that declares no scenario at all is reported too — that one is claimed by
|
|
83
|
+
// nothing for a second reason, and reading the plan alone cannot see it.
|
|
84
|
+
for (const abs of scan.proposedSpecFiles) {
|
|
85
|
+
const file = relativePath(root, abs);
|
|
86
|
+
if (claimedFiles.has(file))
|
|
87
|
+
continue;
|
|
88
|
+
issues.push({
|
|
89
|
+
level: 'ERROR',
|
|
90
|
+
code: 'proposed-spec-unclaimed',
|
|
91
|
+
file,
|
|
92
|
+
message: `${file} is a proposed spec, but no change under changes/ declares a requirement it covers, ` +
|
|
93
|
+
`so no gate run will ever include it. Add its requirement ids to that change's ` +
|
|
94
|
+
`${CHANGE_DELTA_FILE}, or rename the file to *.spec.ts if the behaviour has already merged.`,
|
|
95
|
+
});
|
|
96
|
+
}
|
|
97
|
+
return issues;
|
|
98
|
+
}
|
|
46
99
|
/**
|
|
47
100
|
* The spec files a run should execute: the ones that declare a `requirement()`.
|
|
48
101
|
*
|
|
@@ -103,6 +156,7 @@ export async function runVerify(root, options = {}) {
|
|
|
103
156
|
passed: true,
|
|
104
157
|
runtimeCoverage: new Map(),
|
|
105
158
|
outcomes: new Map(),
|
|
159
|
+
unloadedFiles: [],
|
|
106
160
|
}
|
|
107
161
|
: await runAndCollect({
|
|
108
162
|
root,
|
|
@@ -215,10 +269,9 @@ export async function runInit(root, names) {
|
|
|
215
269
|
* MR title.
|
|
216
270
|
*
|
|
217
271
|
* 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.
|
|
272
|
+
* glob, so `feat(auth)` or a name with a space is a perfectly good directory
|
|
273
|
+
* and there is no reason for the gate to refuse it. Rejecting only what can
|
|
274
|
+
* escape the directory keeps the guard exactly as wide as the danger.
|
|
222
275
|
*/
|
|
223
276
|
function isSafeChangeName(name) {
|
|
224
277
|
return (name !== '' &&
|
|
@@ -229,29 +282,21 @@ function isSafeChangeName(name) {
|
|
|
229
282
|
!name.includes('\0'));
|
|
230
283
|
}
|
|
231
284
|
/**
|
|
232
|
-
*
|
|
285
|
+
* Escape a path so a run's `include` matches that file and nothing else.
|
|
233
286
|
*
|
|
234
|
-
*
|
|
235
|
-
*
|
|
236
|
-
*
|
|
237
|
-
*
|
|
287
|
+
* The backslash comes first, and it is the one that matters most: it is glob's
|
|
288
|
+
* own escape character, so a path containing one is not merely unescaped, it
|
|
289
|
+
* silently rewrites the pattern around it — `a\b` reads as an escaped `b` and
|
|
290
|
+
* matches `ab`, never the file itself. These paths come from the tree, not from
|
|
291
|
+
* the change-name guard above, so they are escaped rather than refused: a file
|
|
292
|
+
* that exists is not ours to reject.
|
|
293
|
+
*
|
|
294
|
+
* This used to escape *sibling change names* too, for the exclude globs that
|
|
295
|
+
* kept other proposals out of a gate run. Those globs are gone with the
|
|
296
|
+
* directory that made them possible; the include list is now the plan's own
|
|
297
|
+
* files, which is a set rather than a pattern.
|
|
238
298
|
*/
|
|
239
|
-
export function changeExcludeGlobs(others) {
|
|
240
|
-
return others.map((n) => `**/changes/${escapeGlob(n)}/**`);
|
|
241
|
-
}
|
|
242
299
|
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
300
|
return name.replace(/[\\*?[\]{}()!+@|^$]/g, '\\$&');
|
|
256
301
|
}
|
|
257
302
|
/** The delta a change is declared in (design §7). */
|
|
@@ -325,14 +370,35 @@ async function readDelta(root, changeName, options) {
|
|
|
325
370
|
},
|
|
326
371
|
};
|
|
327
372
|
}
|
|
373
|
+
/** Split a plan over the proposed specs into the part one delta claims. */
|
|
374
|
+
function claimedByDelta(proposed, delta) {
|
|
375
|
+
const claimed = new Set(claimedIds(delta));
|
|
376
|
+
// A file is claimed whole or not at all. Scenarios in one file can name
|
|
377
|
+
// several requirements, and running half a file is not something Vitest can
|
|
378
|
+
// be asked for — the run scope is a set of files — so a per-scenario split
|
|
379
|
+
// would make the static plan describe a run that cannot happen.
|
|
380
|
+
const files = new Set(proposed.scenarios.filter((s) => claimed.has(s.reqId)).map((s) => s.file));
|
|
381
|
+
const scenarios = proposed.scenarios.filter((s) => files.has(s.file));
|
|
382
|
+
// A `ParamRef` records a requirement and a scenario name, not a file, so it
|
|
383
|
+
// is carried by the scenario it was seen in rather than filtered on its own.
|
|
384
|
+
// Dropping the pair down to `reqId` would let a param read in a *merged*
|
|
385
|
+
// scenario silence the drift heuristic for a proposed one, and vice versa.
|
|
386
|
+
const key = (reqId, scenario) => JSON.stringify([reqId, scenario]);
|
|
387
|
+
const kept = new Set(scenarios.map((s) => key(s.reqId, s.name)));
|
|
388
|
+
return {
|
|
389
|
+
scenarios,
|
|
390
|
+
paramRefs: proposed.paramRefs.filter((p) => kept.has(key(p.reqId, p.scenario))),
|
|
391
|
+
};
|
|
392
|
+
}
|
|
328
393
|
/**
|
|
329
394
|
* The static plan a change is gated and reported against: the base suite plus
|
|
330
|
-
* the change's
|
|
331
|
-
*
|
|
395
|
+
* the specs this change's delta claims (design §8). Takes the scan rather than
|
|
396
|
+
* walking the tree again, so a caller that already scanned does not repeat it.
|
|
332
397
|
*/
|
|
333
|
-
async function changeMergedPlan(root,
|
|
334
|
-
const basePlan = await parseSpecs(specFiles, root);
|
|
335
|
-
const
|
|
398
|
+
async function changeMergedPlan(root, delta, scan) {
|
|
399
|
+
const basePlan = await parseSpecs(scan.specFiles, root);
|
|
400
|
+
const proposed = await parseSpecs(scan.proposedSpecFiles, root);
|
|
401
|
+
const changePlan = claimedByDelta(proposed, delta);
|
|
336
402
|
return {
|
|
337
403
|
scenarios: [...basePlan.scenarios, ...changePlan.scenarios],
|
|
338
404
|
paramRefs: [...basePlan.paramRefs, ...changePlan.paramRefs],
|
|
@@ -354,7 +420,7 @@ export async function runStatus(root, changeName, options = {}) {
|
|
|
354
420
|
if ('issue' in read)
|
|
355
421
|
return nothing([read.issue]);
|
|
356
422
|
const scan = await scanProject(root);
|
|
357
|
-
const plan = await changeMergedPlan(root,
|
|
423
|
+
const plan = await changeMergedPlan(root, read.delta, scan);
|
|
358
424
|
const firstRun = await readRedRecord(root, changeName);
|
|
359
425
|
const rows = statusRows(addedIds(read.delta), plan, firstRun);
|
|
360
426
|
return { change: changeName, rows, counts: statusCounts(rows), issues: [] };
|
|
@@ -395,14 +461,14 @@ export async function runArchive(root, changeName, options = {}) {
|
|
|
395
461
|
// Static plan = merged base suite + this change's specs (design §8), by the
|
|
396
462
|
// same function `status` reports against — a progress report computed over a
|
|
397
463
|
// 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
|
-
];
|
|
464
|
+
const plan = await changeMergedPlan(root, delta, scan);
|
|
465
|
+
// Other proposals need no exclude glob of their own: the include list below
|
|
466
|
+
// is the plan's own files, and the plan holds only the proposed specs this
|
|
467
|
+
// delta claims. That is what replaced `**/changes/<sibling>/**` — with the
|
|
468
|
+
// specs no longer living under `changes/`, a directory glob could not have
|
|
469
|
+
// told two proposals apart, and one kept alongside the claim check would be
|
|
470
|
+
// a second scoping rule able to disagree with it.
|
|
471
|
+
const exclude = [...BASE_EXCLUDE, '**/archive/**'];
|
|
406
472
|
// Same run scope as `verify`: only the files that declare a requirement().
|
|
407
473
|
// The gate must not go red because a repo's incumbent suite happens to sit
|
|
408
474
|
// under the same root as the change being archived.
|
|
@@ -429,7 +495,17 @@ export async function runArchive(root, changeName, options = {}) {
|
|
|
429
495
|
await writeAtomic(redRecordPath(root, changeName), serialiseRedRecord(changeName, merged.record));
|
|
430
496
|
}
|
|
431
497
|
}
|
|
432
|
-
|
|
498
|
+
// Against `base`, not `applied`: the child run imported the registry from
|
|
499
|
+
// disk, so what matters is what that file has, not what the gate computed.
|
|
500
|
+
const unmergedAddedIds = added.filter((id) => !Object.hasOwn(base, id));
|
|
501
|
+
return evaluateGate({
|
|
502
|
+
registry: applied.registry,
|
|
503
|
+
plan,
|
|
504
|
+
run,
|
|
505
|
+
addedIds: added,
|
|
506
|
+
unmergedAddedIds,
|
|
507
|
+
firstRun,
|
|
508
|
+
});
|
|
433
509
|
}
|
|
434
510
|
finally {
|
|
435
511
|
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
|
package/dist/core/runner.js
CHANGED
|
@@ -4,6 +4,8 @@
|
|
|
4
4
|
// never crosses the process boundary; the serialized task tree does.
|
|
5
5
|
import { startVitest } from 'vitest/node';
|
|
6
6
|
import { configDefaults } from 'vitest/config';
|
|
7
|
+
import { relativePath } from './paths.js';
|
|
8
|
+
import { byCodeUnit } from './order.js';
|
|
7
9
|
/** requirement() names each describe block `[reqId]`; recover the id from that. */
|
|
8
10
|
const REQ_SUITE = /^\[(.+)\]$/;
|
|
9
11
|
/**
|
|
@@ -17,8 +19,21 @@ const REQ_SUITE = /^\[(.+)\]$/;
|
|
|
17
19
|
* command goes through — the boundary the gate exists to hold.
|
|
18
20
|
*/
|
|
19
21
|
export const BASE_EXCLUDE = configDefaults.exclude;
|
|
20
|
-
/**
|
|
21
|
-
|
|
22
|
+
/**
|
|
23
|
+
* Proposed / archived changes are excluded from a normal run (design §7, §8).
|
|
24
|
+
*
|
|
25
|
+
* The `.proposed.spec.ts` glob is the one carrying the weight now: a proposal's
|
|
26
|
+
* specs sit at their merged location, next to the code they attest, so nothing
|
|
27
|
+
* about *where* they are keeps them out of a run. They are red by construction
|
|
28
|
+
* until their change is implemented, and a default run that swept them up would
|
|
29
|
+
* report a project as broken for the whole life of every change in flight.
|
|
30
|
+
*/
|
|
31
|
+
const DEFAULT_EXCLUDE = [
|
|
32
|
+
...BASE_EXCLUDE,
|
|
33
|
+
'**/*.proposed.spec.ts',
|
|
34
|
+
'**/changes/**',
|
|
35
|
+
'**/archive/**',
|
|
36
|
+
];
|
|
22
37
|
/**
|
|
23
38
|
* Build the child-run options. Attest owns the run *scope* — include/exclude/
|
|
24
39
|
* root/watch always come from here, so the caller can neither widen the spec
|
|
@@ -88,20 +103,42 @@ export async function runAndCollect(options = {}) {
|
|
|
88
103
|
// that notices if the task tree ever stops having a `type`, a `name` or a
|
|
89
104
|
// `result.state`. An `as unknown as` here bought nothing and blinded it to
|
|
90
105
|
// exactly the change that would silently empty the runtime coverage.
|
|
91
|
-
|
|
106
|
+
const unloadedFiles = [];
|
|
107
|
+
for (const file of vitest.state.getFiles()) {
|
|
92
108
|
walk(file);
|
|
109
|
+
// Relative and POSIX for the reason everything derived from the root is
|
|
110
|
+
// (`paths.ts`): this becomes an `Issue.file`, which a `--json` consumer
|
|
111
|
+
// diffs across two CI runs that may not share an operating system.
|
|
112
|
+
if (failedToLoad(file)) {
|
|
113
|
+
unloadedFiles.push(relativePath(options.root ?? process.cwd(), file.filepath));
|
|
114
|
+
}
|
|
115
|
+
}
|
|
116
|
+
unloadedFiles.sort(byCodeUnit);
|
|
93
117
|
// No optional call and no `?? 0` default: `getCountOfFailedTests` is a
|
|
94
118
|
// required method on Vitest's state, so the guard was dead at the type
|
|
95
119
|
// level — and had it ever become live it defaulted the wrong way, reading a
|
|
96
120
|
// missing API as zero failures and turning a red suite green. This is the
|
|
97
121
|
// one boolean in the engine that must never fail open.
|
|
98
122
|
const passed = vitest.state.getCountOfFailedTests() === 0;
|
|
99
|
-
return { passed, runtimeCoverage, outcomes };
|
|
123
|
+
return { passed, runtimeCoverage, outcomes, unloadedFiles };
|
|
100
124
|
}
|
|
101
125
|
finally {
|
|
102
126
|
await vitest.close();
|
|
103
127
|
}
|
|
104
128
|
}
|
|
129
|
+
/**
|
|
130
|
+
* Did this file fail before any of its tests could exist?
|
|
131
|
+
*
|
|
132
|
+
* Errors recorded on the *file* rather than on a test are collection errors —
|
|
133
|
+
* the module threw while being imported. Measured rather than assumed: a spec
|
|
134
|
+
* whose assertion fails carries `errors: 0` at the file level and one failing
|
|
135
|
+
* test task, while a spec whose import throws carries `errors: 1` and no tasks
|
|
136
|
+
* at all. Both leave `result.state` at `fail`, which is why the state alone
|
|
137
|
+
* cannot tell them apart.
|
|
138
|
+
*/
|
|
139
|
+
function failedToLoad(file) {
|
|
140
|
+
return (file.result?.errors?.length ?? 0) > 0;
|
|
141
|
+
}
|
|
105
142
|
/** The outcome of a task that ran, or `undefined` when it did not run at all. */
|
|
106
143
|
function executedOutcome(task) {
|
|
107
144
|
const state = task.result?.state;
|
package/dist/core/schema.js
CHANGED
|
@@ -14,10 +14,18 @@ export const RequirementSchema = z.object({
|
|
|
14
14
|
// blacklists, id sets) are the most drift-prone constants, so keeping them out
|
|
15
15
|
// of params left the highest-risk values unguarded; an array still has exactly
|
|
16
16
|
// one owner (the spec) read by exactly one place (the scenario).
|
|
17
|
+
// The union carries its own message because a union's default one is the
|
|
18
|
+
// word "Invalid input", which names neither what was given nor what is
|
|
19
|
+
// accepted. A nested object is the value that reaches it — a table of
|
|
20
|
+
// kind -> weight is the natural thing to try — and the constraint that
|
|
21
|
+
// refuses it is stated nowhere the author is looking, so the message is
|
|
22
|
+
// where they find it.
|
|
17
23
|
params: z
|
|
18
24
|
.record(z.string(), (() => {
|
|
19
25
|
const scalar = z.union([z.number(), z.string(), z.boolean()]);
|
|
20
|
-
return z.union([scalar, z.array(scalar)]
|
|
26
|
+
return z.union([scalar, z.array(scalar)], {
|
|
27
|
+
errorMap: () => ({ message: 'expected a string, number, boolean, or an array of those' }),
|
|
28
|
+
});
|
|
21
29
|
})())
|
|
22
30
|
.default({}),
|
|
23
31
|
outOfScope: z.array(z.string()).default([]),
|
package/dist/core/skill.js
CHANGED
|
@@ -74,12 +74,22 @@ promises is a two-stage workflow, and the stages are separate on purpose.
|
|
|
74
74
|
a limit, a list of names — is written **once**, in the requirement's \`params\`,
|
|
75
75
|
and the scenario reads it from there. The number then cannot drift between the
|
|
76
76
|
spec and the assertion, because there is only one of it.
|
|
77
|
+
- **What earns a param: promises, not tuning.** A value belongs in \`params\` when
|
|
78
|
+
the requirement promises it — a timeout the user is owed, a budget someone would
|
|
79
|
+
file a bug about. A value that only tunes behaviour (a search depth, a cache
|
|
80
|
+
size) stays an ordinary constant: nothing is owed if it changes, and a registry
|
|
81
|
+
that accumulates every knob in the project is one of the ways intent bloats.
|
|
77
82
|
- **What that does not buy.** \`attest check\` executes nothing, so editing a
|
|
78
83
|
param value leaves it at \`✓ No issues\` — nothing became unbound or uncovered.
|
|
79
84
|
The single source makes a value impossible to *diverge*; it does not announce
|
|
80
85
|
that it *moved*. Only \`verify\` goes red, and only when a scenario asserts on
|
|
81
|
-
the value it read from params.
|
|
82
|
-
|
|
86
|
+
the value it read from params.
|
|
87
|
+
- **Reading the param is necessary, not sufficient.** An assertion that
|
|
88
|
+
recomputes its expectation from the same param the code under test just read
|
|
89
|
+
has no independent term: both sides move together, and the scenario stays green
|
|
90
|
+
through any edit. Pin the expectation to something that does not move with the
|
|
91
|
+
param — a fixture, a literal in the test, or a second independently derived
|
|
92
|
+
value.
|
|
83
93
|
|
|
84
94
|
## Rules the engine enforces
|
|
85
95
|
|
|
@@ -128,10 +138,31 @@ Then write the change folder. \`<name>\` is one directory name inside
|
|
|
128
138
|
\`\`\`
|
|
129
139
|
changes/<name>/
|
|
130
140
|
├── proposal.md # why, and what is out of scope. Prose, for humans.
|
|
131
|
-
|
|
132
|
-
└── specs/*.spec.ts # the scenarios that must pass for it to be done
|
|
141
|
+
└── requirements.delta.ts # the intent change
|
|
133
142
|
\`\`\`
|
|
134
143
|
|
|
144
|
+
**The change's scenarios do not live in that folder.** Each goes where it will
|
|
145
|
+
live once the change is merged — beside the code it attests, in the same
|
|
146
|
+
directory as the specs already there — under the name \`*.proposed.spec.ts\`:
|
|
147
|
+
|
|
148
|
+
\`\`\`
|
|
149
|
+
lib/game/specs/
|
|
150
|
+
├── board.spec.ts # already merged
|
|
151
|
+
└── fog.proposed.spec.ts # this change's, and ../fog already resolves
|
|
152
|
+
\`\`\`
|
|
153
|
+
|
|
154
|
+
That name is the whole mechanism. It keeps the file out of \`attest verify\` and
|
|
155
|
+
out of a plain \`vitest run\` while the change is in flight — the scenarios are
|
|
156
|
+
red by construction — and \`attest archive <name>\` pulls it in by the requirement
|
|
157
|
+
ids it covers: a change claims the proposed specs that declare a scenario for an
|
|
158
|
+
id it ADDs, renames to, or MODIFIEs. Nothing lists paths anywhere, so nothing can
|
|
159
|
+
fall out of step. A proposed spec no change claims is a \`proposed-spec-unclaimed\`
|
|
160
|
+
ERROR from \`attest check\`, because it would otherwise run nowhere at all.
|
|
161
|
+
|
|
162
|
+
Writing it at its merged location is what makes merging it a rename. Its relative
|
|
163
|
+
imports resolve now exactly as they will afterwards, so \`../fog\` never becomes
|
|
164
|
+
\`../../../lib/game/fog\` and back again.
|
|
165
|
+
|
|
135
166
|
\`\`\`ts
|
|
136
167
|
// changes/add-2fa/requirements.delta.ts
|
|
137
168
|
import { delta } from '@am_shork/attest/define';
|
|
@@ -156,8 +187,22 @@ The delta applies \`RENAMED → REMOVED → MODIFIED → ADDED\`, idempotently:
|
|
|
156
187
|
ADDED id that already exists with identical content is a no-op, with different
|
|
157
188
|
content it is a conflict.
|
|
158
189
|
|
|
190
|
+
**A delta also reads as the registry of what it adds**, so a scenario written now
|
|
191
|
+
reads a proposed param exactly as it will after the merge:
|
|
192
|
+
|
|
193
|
+
\`\`\`ts
|
|
194
|
+
import reqs from '../../changes/add-2fa/requirements.delta.ts'; // ← only this line changes at merge
|
|
195
|
+
const window = reqs['AUTH-7'].params.totpWindowSec; // typed at 30, no cast
|
|
196
|
+
\`\`\`
|
|
197
|
+
|
|
198
|
+
Never hand-write an accessor for a proposed param, and never copy the literal
|
|
199
|
+
into the assertion: both are a second place the value lives, opened during the
|
|
200
|
+
one stage where the assertion is being authored against a value nobody has
|
|
201
|
+
implemented yet. MODIFIED ids are deliberately not readable this way — the end
|
|
202
|
+
state is the base entry with the patch applied, and the base is not in that file.
|
|
203
|
+
|
|
159
204
|
**The scenarios must be red.** Every added requirement needs at least one
|
|
160
|
-
scenario
|
|
205
|
+
scenario in a \`*.proposed.spec.ts\`, and at this stage they must fail —
|
|
161
206
|
they describe behaviour that does not exist yet. Prove it:
|
|
162
207
|
|
|
163
208
|
\`\`\`
|
|
@@ -227,6 +272,8 @@ once. Branch on \`issues[].code\`, never on \`message\`:
|
|
|
227
272
|
| \`never-red\` | an added requirement's scenario has no recorded failing run |
|
|
228
273
|
| \`uncovered-requirement\` | a requirement in the applied registry has no scenario |
|
|
229
274
|
| \`declared-not-run\` | a scenario was declared but never executed (\`skip\` / \`only\`?) |
|
|
275
|
+
| \`proposed-spec-unclaimed\` | a \`*.proposed.spec.ts\` no change's delta claims |
|
|
276
|
+
| \`added-id-unmerged\` | a spec failed to load, and this change adds an id the registry on disk lacks |
|
|
230
277
|
| \`unbound-param\` | a \`{placeholder}\` has no matching \`params\` key |
|
|
231
278
|
| \`registry-not-static\` | a registry file is not a literal the engine can read |
|
|
232
279
|
| \`add-conflict\` | the delta adds an id that already exists with different content |
|
|
@@ -247,6 +294,12 @@ failure this framework exists to make visible:
|
|
|
247
294
|
is the drift the single source exists to prevent, and the param is typed at
|
|
248
295
|
the value written in the registry, so a stale expectation stops compiling
|
|
249
296
|
rather than silently passing.
|
|
297
|
+
*One thing to know before implementation code reads a param this change
|
|
298
|
+
ADDs:* the suite imports your \`*.reqs.ts\` from disk, while the gate applies
|
|
299
|
+
the delta in memory — so that read throws at import and the gate answers
|
|
300
|
+
\`added-id-unmerged\`. Merge the added requirement into the registry and run
|
|
301
|
+
again; the \`added:\` entry stays, because applying it a second time with
|
|
302
|
+
identical content is a no-op.
|
|
250
303
|
4. **Do not write or edit \`first-run.json\`.** It is the one file here the gate
|
|
251
304
|
trusts without being able to check it, so a hand-written \`"fail"\` clears
|
|
252
305
|
\`never-red\` while proving nothing at all. Run the gate before implementing;
|
|
@@ -256,8 +309,10 @@ failure this framework exists to make visible:
|
|
|
256
309
|
|
|
257
310
|
Then, and not before:
|
|
258
311
|
|
|
259
|
-
1. Merge \`requirements.delta.ts\` into the main registry, and
|
|
260
|
-
|
|
312
|
+
1. Merge \`requirements.delta.ts\` into the main registry, and rename each of the
|
|
313
|
+
change's \`*.proposed.spec.ts\` to \`*.spec.ts\` **in place**. Nothing moves and
|
|
314
|
+
no import changes; if you find yourself editing a specifier, the spec was not
|
|
315
|
+
written at its merged location and stage 1 was the place to fix that.
|
|
261
316
|
2. Move \`changes/<name>/\` to \`archive/<date>-<name>/\`.
|
|
262
317
|
3. If the project commits a rendering, regenerate it: \`attest render --out
|
|
263
318
|
<file>\`. A committed document that no longer matches the registry is a
|
|
@@ -20,7 +20,7 @@
|
|
|
20
20
|
// value is fixed by the source text — a second walker would be a second answer
|
|
21
21
|
// to "is this a literal", and those two answers must not be able to disagree.
|
|
22
22
|
import ts from 'typescript';
|
|
23
|
-
import { RegistryValidationError } from './registry.js';
|
|
23
|
+
import { RegistryValidationError, withProposedRequirements } from './registry.js';
|
|
24
24
|
import { RegistrySchema } from './schema.js';
|
|
25
25
|
/** The authoring function a registry file must default-export the result of. */
|
|
26
26
|
const DEFINE = 'defineRequirements';
|
|
@@ -142,7 +142,11 @@ export function readDeltaSource(file, source) {
|
|
|
142
142
|
// function deliberately does not make (see the note above on why a delta is
|
|
143
143
|
// not schema-validated here), which is the worst thing an assertion can do —
|
|
144
144
|
// look like verification.
|
|
145
|
-
|
|
145
|
+
// Augmented exactly as `delta()` augments the evaluated one, through the same
|
|
146
|
+
// function: a delta reads as the registry of what it adds (design §7), and the
|
|
147
|
+
// two readers must not disagree about that — the differential suite is the
|
|
148
|
+
// thing that would notice, and it should have nothing to notice.
|
|
149
|
+
return { ok: true, delta: withProposedRequirements(value) };
|
|
146
150
|
}
|
|
147
151
|
/**
|
|
148
152
|
* The expression a file default-exports, following one level of local binding.
|
package/dist/core/types.d.ts
CHANGED
|
@@ -40,6 +40,16 @@ export interface RunResult {
|
|
|
40
40
|
* it, which is what §6's mechanism 2 records.
|
|
41
41
|
*/
|
|
42
42
|
outcomes: Map<string, Map<string, Outcome>>;
|
|
43
|
+
/**
|
|
44
|
+
* Spec files that failed to *load*, as project-relative POSIX paths.
|
|
45
|
+
*
|
|
46
|
+
* A file whose module threw at import has no tasks at all, so every other
|
|
47
|
+
* signal it produces is an absence: no coverage, no outcomes, and scenarios
|
|
48
|
+
* the static plan declared but the runtime never saw. Those absences are
|
|
49
|
+
* indistinguishable from a `skip` unless the load failure is carried
|
|
50
|
+
* separately, which is what this is.
|
|
51
|
+
*/
|
|
52
|
+
unloadedFiles: string[];
|
|
43
53
|
}
|
|
44
54
|
/** Severity levels for graded reporting (design §5.3). */
|
|
45
55
|
export type Level = 'ERROR' | 'WARNING' | 'INFO';
|
package/package.json
CHANGED