@am_shork/attest 0.4.3 → 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 +418 -5
- package/README.md +16 -8
- 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/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/gate.d.ts
CHANGED
|
@@ -9,6 +9,15 @@ export interface GateInputs {
|
|
|
9
9
|
run: RunResult;
|
|
10
10
|
/** Ids this change ADDs — the scenarios that carry a first-red obligation. */
|
|
11
11
|
addedIds?: readonly string[];
|
|
12
|
+
/**
|
|
13
|
+
* Of those, the ids the registry **on disk** does not have yet.
|
|
14
|
+
*
|
|
15
|
+
* Computed against the base registry rather than the applied one, because
|
|
16
|
+
* that is the registry the child run imports: the gate applies the delta in
|
|
17
|
+
* memory, and the suite is a separate process reading `*.reqs.ts` off the
|
|
18
|
+
* filesystem. The two disagreeing is the whole content of `added-id-unmerged`.
|
|
19
|
+
*/
|
|
20
|
+
unmergedAddedIds?: readonly string[];
|
|
12
21
|
/** First observed outcome per scenario, already merged with this run. */
|
|
13
22
|
firstRun?: RedRecord;
|
|
14
23
|
}
|
|
@@ -21,6 +30,32 @@ export interface GateInputs {
|
|
|
21
30
|
* this framework is that agreement should be structural, not clerical.
|
|
22
31
|
*/
|
|
23
32
|
export declare function declaredNotRunIssues(plan: AttestPlan, run: RunResult): Issue[];
|
|
33
|
+
/**
|
|
34
|
+
* A spec file that failed to load, while the change adds ids the registry on
|
|
35
|
+
* disk does not have yet (design §8).
|
|
36
|
+
*
|
|
37
|
+
* The gate applies the delta in memory and hands the result to itself; the
|
|
38
|
+
* suite is a child process that imports `*.reqs.ts` from the filesystem. So
|
|
39
|
+
* implementation code doing exactly what the workflow requires —
|
|
40
|
+
* `reqs['FOG-4'].params.modestRisk` for an id the change *adds* — throws at
|
|
41
|
+
* import, every spec file transitively importing that module fails to load, and
|
|
42
|
+
* what the gate could see was `tests-red` plus a `declared-not-run` per
|
|
43
|
+
* scenario, whose message sends the reader to look for a `skip` or an `.only`
|
|
44
|
+
* that is not there. The change could not be made green by the documented
|
|
45
|
+
* workflow, and the verdict pointed away from the reason.
|
|
46
|
+
*
|
|
47
|
+
* This names the reason instead. It is a diagnosis, not a fix: the run still
|
|
48
|
+
* fails, and the way forward is still to merge the added requirement into the
|
|
49
|
+
* registry before running the gate. What it buys is that the reader is told
|
|
50
|
+
* that, rather than sent to audit their own spec files for a skip.
|
|
51
|
+
*
|
|
52
|
+
* The conjunction is the whole test, and it is deliberately a heuristic: a
|
|
53
|
+
* module can fail to import for reasons that have nothing to do with a
|
|
54
|
+
* requirement id. Naming an unmerged id when one exists is more useful than
|
|
55
|
+
* silence, so the message states the two facts and the inference between them
|
|
56
|
+
* rather than asserting a cause.
|
|
57
|
+
*/
|
|
58
|
+
export declare function unmergedIdIssues(run: RunResult, unmergedAddedIds: readonly string[]): Issue[];
|
|
24
59
|
/**
|
|
25
60
|
* Never-red: a scenario attesting a requirement this change ADDs, whose first
|
|
26
61
|
* observed run did not fail (design §6, mechanism 2).
|
|
@@ -48,10 +83,12 @@ export declare function neverRedIssues(plan: AttestPlan, addedIds: readonly stri
|
|
|
48
83
|
* the registry+plan here are already the applied result, so one pass
|
|
49
84
|
* validates the end state.
|
|
50
85
|
* 2. Executable: all tests green.
|
|
51
|
-
* 3.
|
|
86
|
+
* 3. A spec file that failed to load while the change adds an id the registry
|
|
87
|
+
* on disk lacks — the reason, named before the absences it causes.
|
|
88
|
+
* 4. Static coverage vs runtime coverage: every declared scenario actually
|
|
52
89
|
* ran (catches skip/only false coverage).
|
|
53
|
-
*
|
|
90
|
+
* 5. Never-red: every scenario of an ADDED requirement failed on its first
|
|
54
91
|
* recorded run (design §6, mechanism 2).
|
|
55
92
|
*/
|
|
56
|
-
export declare function evaluateGate({ registry, plan, run, addedIds, firstRun }: GateInputs): Issue[];
|
|
93
|
+
export declare function evaluateGate({ registry, plan, run, addedIds, unmergedAddedIds, firstRun, }: GateInputs): Issue[];
|
|
57
94
|
//# sourceMappingURL=gate.d.ts.map
|
package/dist/core/gate.js
CHANGED
|
@@ -29,6 +29,43 @@ export function declaredNotRunIssues(plan, run) {
|
|
|
29
29
|
}
|
|
30
30
|
return issues;
|
|
31
31
|
}
|
|
32
|
+
/**
|
|
33
|
+
* A spec file that failed to load, while the change adds ids the registry on
|
|
34
|
+
* disk does not have yet (design §8).
|
|
35
|
+
*
|
|
36
|
+
* The gate applies the delta in memory and hands the result to itself; the
|
|
37
|
+
* suite is a child process that imports `*.reqs.ts` from the filesystem. So
|
|
38
|
+
* implementation code doing exactly what the workflow requires —
|
|
39
|
+
* `reqs['FOG-4'].params.modestRisk` for an id the change *adds* — throws at
|
|
40
|
+
* import, every spec file transitively importing that module fails to load, and
|
|
41
|
+
* what the gate could see was `tests-red` plus a `declared-not-run` per
|
|
42
|
+
* scenario, whose message sends the reader to look for a `skip` or an `.only`
|
|
43
|
+
* that is not there. The change could not be made green by the documented
|
|
44
|
+
* workflow, and the verdict pointed away from the reason.
|
|
45
|
+
*
|
|
46
|
+
* This names the reason instead. It is a diagnosis, not a fix: the run still
|
|
47
|
+
* fails, and the way forward is still to merge the added requirement into the
|
|
48
|
+
* registry before running the gate. What it buys is that the reader is told
|
|
49
|
+
* that, rather than sent to audit their own spec files for a skip.
|
|
50
|
+
*
|
|
51
|
+
* The conjunction is the whole test, and it is deliberately a heuristic: a
|
|
52
|
+
* module can fail to import for reasons that have nothing to do with a
|
|
53
|
+
* requirement id. Naming an unmerged id when one exists is more useful than
|
|
54
|
+
* silence, so the message states the two facts and the inference between them
|
|
55
|
+
* rather than asserting a cause.
|
|
56
|
+
*/
|
|
57
|
+
export function unmergedIdIssues(run, unmergedAddedIds) {
|
|
58
|
+
if (unmergedAddedIds.length === 0)
|
|
59
|
+
return [];
|
|
60
|
+
return run.unloadedFiles.map((file) => ({
|
|
61
|
+
level: 'ERROR',
|
|
62
|
+
code: 'added-id-unmerged',
|
|
63
|
+
file,
|
|
64
|
+
message: `${file} failed to load, and this change adds ${unmergedAddedIds.join(', ')}, which the registry on disk does not have yet. ` +
|
|
65
|
+
`The suite runs against the registry files, not the applied registry the gate computed, so code reading a requirement this change adds throws at import. ` +
|
|
66
|
+
`Merge the added requirement into the registry and run the gate again: applying a delta whose ADDED entry already exists with identical content is a no-op, so the change still documents the intent.`,
|
|
67
|
+
}));
|
|
68
|
+
}
|
|
32
69
|
/**
|
|
33
70
|
* Never-red: a scenario attesting a requirement this change ADDs, whose first
|
|
34
71
|
* observed run did not fail (design §6, mechanism 2).
|
|
@@ -77,12 +114,14 @@ export function neverRedIssues(plan, addedIds, firstRun) {
|
|
|
77
114
|
* the registry+plan here are already the applied result, so one pass
|
|
78
115
|
* validates the end state.
|
|
79
116
|
* 2. Executable: all tests green.
|
|
80
|
-
* 3.
|
|
117
|
+
* 3. A spec file that failed to load while the change adds an id the registry
|
|
118
|
+
* on disk lacks — the reason, named before the absences it causes.
|
|
119
|
+
* 4. Static coverage vs runtime coverage: every declared scenario actually
|
|
81
120
|
* ran (catches skip/only false coverage).
|
|
82
|
-
*
|
|
121
|
+
* 5. Never-red: every scenario of an ADDED requirement failed on its first
|
|
83
122
|
* recorded run (design §6, mechanism 2).
|
|
84
123
|
*/
|
|
85
|
-
export function evaluateGate({ registry, plan, run, addedIds, firstRun }) {
|
|
124
|
+
export function evaluateGate({ registry, plan, run, addedIds, unmergedAddedIds, firstRun, }) {
|
|
86
125
|
const blocking = [];
|
|
87
126
|
// 1) Structure (end-state re-validation).
|
|
88
127
|
blocking.push(...validateStructure(registry, plan).filter((i) => i.level === 'ERROR'));
|
|
@@ -94,9 +133,20 @@ export function evaluateGate({ registry, plan, run, addedIds, firstRun }) {
|
|
|
94
133
|
message: 'Some tests are failing; the change cannot be archived.',
|
|
95
134
|
});
|
|
96
135
|
}
|
|
97
|
-
// 3)
|
|
98
|
-
|
|
99
|
-
|
|
136
|
+
// 3) A file that failed to load, named before the absences it produces.
|
|
137
|
+
const unmerged = unmergedIdIssues(run, unmergedAddedIds ?? []);
|
|
138
|
+
blocking.push(...unmerged);
|
|
139
|
+
// 4) Declared-not-run: static coverage claimed, runtime never executed it.
|
|
140
|
+
//
|
|
141
|
+
// Scenarios in a file diagnosed just above are left out. Not because they
|
|
142
|
+
// ran — they could not have — but because "declared but never executed
|
|
143
|
+
// (skipped, or excluded by an .only?)" is one fact restated as a guess about
|
|
144
|
+
// a cause the line above has already established. Only when that line was
|
|
145
|
+
// emitted: with no diagnosis to replace it, a wrong message still beats
|
|
146
|
+
// silence, which is why `verify` keeps reporting them.
|
|
147
|
+
const diagnosed = new Set(unmerged.map((i) => i.file));
|
|
148
|
+
blocking.push(...declaredNotRunIssues(plan, run).filter((i) => !diagnosed.has(i.file)));
|
|
149
|
+
// 5) Never-red: the added scenarios have to have discriminated once.
|
|
100
150
|
if (addedIds && addedIds.length > 0) {
|
|
101
151
|
blocking.push(...neverRedIssues(plan, addedIds, firstRun ?? {}));
|
|
102
152
|
}
|
package/dist/core/locate.d.ts
CHANGED
|
@@ -1,6 +1,24 @@
|
|
|
1
1
|
import type { Loader } from './loader.js';
|
|
2
2
|
import type { AttestPlan, Issue, Registry } from './types.js';
|
|
3
3
|
export declare const isReqsFile: (name: string) => boolean;
|
|
4
|
+
/**
|
|
5
|
+
* A spec belonging to a change that has not been agreed yet (design §7).
|
|
6
|
+
*
|
|
7
|
+
* The marker is in the **name** rather than the directory because the file
|
|
8
|
+
* already sits where it will live once the change is merged: a proposal's spec
|
|
9
|
+
* is written next to the code it attests, so its relative imports resolve
|
|
10
|
+
* identically before and after the merge, and merging renames it in place
|
|
11
|
+
* instead of moving it up a tree and rewriting every specifier.
|
|
12
|
+
*/
|
|
13
|
+
export declare const isProposedSpecFile: (name: string) => boolean;
|
|
14
|
+
/**
|
|
15
|
+
* A spec belonging to the merged suite.
|
|
16
|
+
*
|
|
17
|
+
* Proposed specs are excluded here rather than at each call site, so the base
|
|
18
|
+
* scan, the base run and every reporting command are wrong together or not at
|
|
19
|
+
* all: a red scenario for behaviour nobody has implemented must not reach any
|
|
20
|
+
* of them until its gate passes.
|
|
21
|
+
*/
|
|
4
22
|
export declare const isSpecFile: (name: string) => boolean;
|
|
5
23
|
/**
|
|
6
24
|
* Recursively find files under root whose basename matches `match`.
|
|
@@ -11,16 +29,21 @@ export declare const isSpecFile: (name: string) => boolean;
|
|
|
11
29
|
* order never depends on which `readdir` happened to resolve first.
|
|
12
30
|
*/
|
|
13
31
|
export declare function findFiles(root: string, match: (name: string) => boolean): Promise<string[]>;
|
|
14
|
-
/** The
|
|
32
|
+
/** The file sets every command needs, collected in one pass. */
|
|
15
33
|
export interface ProjectScan {
|
|
16
34
|
reqsFiles: string[];
|
|
17
35
|
specFiles: string[];
|
|
36
|
+
/** Specs of changes still under review; never part of the base suite. */
|
|
37
|
+
proposedSpecFiles: string[];
|
|
18
38
|
}
|
|
19
39
|
/**
|
|
20
40
|
* Find the registry and spec files under root in a **single** traversal.
|
|
21
41
|
*
|
|
22
42
|
* Calling findFiles once per pattern meant `check` walked the tree twice and
|
|
23
|
-
* `archive` four times, over a tree that cannot change in between.
|
|
43
|
+
* `archive` four times, over a tree that cannot change in between. Proposed
|
|
44
|
+
* specs are collected in the same pass for that reason and kept in their own
|
|
45
|
+
* list: they are found everywhere the merged ones are, and no caller may reach
|
|
46
|
+
* them by accident.
|
|
24
47
|
*/
|
|
25
48
|
export declare function scanProject(root: string): Promise<ProjectScan>;
|
|
26
49
|
/** One file's worth of registry, or the single issue that stopped it. */
|
|
@@ -92,8 +115,6 @@ export declare function loadRegistry(root: string, reader: RegistryReader, files
|
|
|
92
115
|
export declare function parseSpecs(files: string[], displayRoot: string): Promise<AttestPlan>;
|
|
93
116
|
/** Parse every `*.spec.ts` under root into one merged plan (file paths shown relative to root). */
|
|
94
117
|
export declare function parseAllSpecFiles(root: string): Promise<AttestPlan>;
|
|
95
|
-
/** Parse the spec files belonging to a single proposed change (design §8). */
|
|
96
|
-
export declare function parseChangeSpecs(root: string, changeName: string): Promise<AttestPlan>;
|
|
97
118
|
/** List the names of proposed changes under `root/changes`. */
|
|
98
119
|
export declare function listChangeNames(root: string): Promise<string[]>;
|
|
99
120
|
//# sourceMappingURL=locate.d.ts.map
|
package/dist/core/locate.js
CHANGED
|
@@ -20,7 +20,25 @@ const SKIP_DIRS = new Set([
|
|
|
20
20
|
'archive',
|
|
21
21
|
]);
|
|
22
22
|
export const isReqsFile = (name) => name.endsWith('.reqs.ts');
|
|
23
|
-
|
|
23
|
+
/**
|
|
24
|
+
* A spec belonging to a change that has not been agreed yet (design §7).
|
|
25
|
+
*
|
|
26
|
+
* The marker is in the **name** rather than the directory because the file
|
|
27
|
+
* already sits where it will live once the change is merged: a proposal's spec
|
|
28
|
+
* is written next to the code it attests, so its relative imports resolve
|
|
29
|
+
* identically before and after the merge, and merging renames it in place
|
|
30
|
+
* instead of moving it up a tree and rewriting every specifier.
|
|
31
|
+
*/
|
|
32
|
+
export const isProposedSpecFile = (name) => name.endsWith('.proposed.spec.ts');
|
|
33
|
+
/**
|
|
34
|
+
* A spec belonging to the merged suite.
|
|
35
|
+
*
|
|
36
|
+
* Proposed specs are excluded here rather than at each call site, so the base
|
|
37
|
+
* scan, the base run and every reporting command are wrong together or not at
|
|
38
|
+
* all: a red scenario for behaviour nobody has implemented must not reach any
|
|
39
|
+
* of them until its gate passes.
|
|
40
|
+
*/
|
|
41
|
+
export const isSpecFile = (name) => name.endsWith('.spec.ts') && !isProposedSpecFile(name);
|
|
24
42
|
/**
|
|
25
43
|
* Recursively find files under root whose basename matches `match`.
|
|
26
44
|
*
|
|
@@ -53,19 +71,29 @@ export async function findFiles(root, match) {
|
|
|
53
71
|
* Find the registry and spec files under root in a **single** traversal.
|
|
54
72
|
*
|
|
55
73
|
* Calling findFiles once per pattern meant `check` walked the tree twice and
|
|
56
|
-
* `archive` four times, over a tree that cannot change in between.
|
|
74
|
+
* `archive` four times, over a tree that cannot change in between. Proposed
|
|
75
|
+
* specs are collected in the same pass for that reason and kept in their own
|
|
76
|
+
* list: they are found everywhere the merged ones are, and no caller may reach
|
|
77
|
+
* them by accident.
|
|
57
78
|
*/
|
|
58
79
|
export async function scanProject(root) {
|
|
59
|
-
const files = await findFiles(root, (n) => isReqsFile(n) || isSpecFile(n));
|
|
80
|
+
const files = await findFiles(root, (n) => isReqsFile(n) || isSpecFile(n) || isProposedSpecFile(n));
|
|
60
81
|
const reqsFiles = [];
|
|
61
82
|
const specFiles = [];
|
|
83
|
+
const proposedSpecFiles = [];
|
|
62
84
|
for (const f of files) {
|
|
63
|
-
|
|
85
|
+
const name = basename(f);
|
|
86
|
+
// Sorted by `isSpecFile` rather than by `isProposedSpecFile`, so what makes
|
|
87
|
+
// a spec part of the merged suite is decided in exactly one place: the two
|
|
88
|
+
// predicates would otherwise both have to be right about the same file.
|
|
89
|
+
if (isReqsFile(name))
|
|
64
90
|
reqsFiles.push(f);
|
|
65
|
-
else
|
|
91
|
+
else if (isSpecFile(name))
|
|
66
92
|
specFiles.push(f);
|
|
93
|
+
else
|
|
94
|
+
proposedSpecFiles.push(f);
|
|
67
95
|
}
|
|
68
|
-
return { reqsFiles, specFiles };
|
|
96
|
+
return { reqsFiles, specFiles, proposedSpecFiles };
|
|
69
97
|
}
|
|
70
98
|
/**
|
|
71
99
|
* Read registries by **executing** the module through the Vite loader.
|
|
@@ -251,11 +279,6 @@ export async function parseSpecs(files, displayRoot) {
|
|
|
251
279
|
export async function parseAllSpecFiles(root) {
|
|
252
280
|
return parseSpecs(await findFiles(root, isSpecFile), root);
|
|
253
281
|
}
|
|
254
|
-
/** Parse the spec files belonging to a single proposed change (design §8). */
|
|
255
|
-
export async function parseChangeSpecs(root, changeName) {
|
|
256
|
-
const dir = join(root, 'changes', changeName);
|
|
257
|
-
return parseSpecs(await findFiles(dir, isSpecFile), root);
|
|
258
|
-
}
|
|
259
282
|
/** List the names of proposed changes under `root/changes`. */
|
|
260
283
|
export async function listChangeNames(root) {
|
|
261
284
|
try {
|
package/dist/core/pipeline.d.ts
CHANGED
|
@@ -120,15 +120,6 @@ export declare function runInit(root: string, names: readonly string[]): Promise
|
|
|
120
120
|
files: string[];
|
|
121
121
|
issues: Issue[];
|
|
122
122
|
}>;
|
|
123
|
-
/**
|
|
124
|
-
* The globs that keep *other* proposals out of a change's gate run.
|
|
125
|
-
*
|
|
126
|
-
* Sibling names come from `readdir`, not from the guard above, so they are
|
|
127
|
-
* escaped: a directory called `feat(auth)` pasted in raw is a *pattern*, it
|
|
128
|
-
* matches nothing, and that sibling's specs silently join the run — quietly
|
|
129
|
-
* widening the scope of the one check that decides "done".
|
|
130
|
-
*/
|
|
131
|
-
export declare function changeExcludeGlobs(others: string[]): string[];
|
|
132
123
|
export interface StatusResult {
|
|
133
124
|
change: string;
|
|
134
125
|
/** One row per added id, in id order. */
|
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
|