@am_shork/attest 0.7.4 → 0.9.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CHANGELOG.md +1449 -157
- package/README.md +3 -2
- package/dist/cli/index.js +11 -8
- package/dist/cli/json.d.ts +26 -1
- package/dist/cli/json.js +28 -2
- package/dist/cli/report.js +9 -1
- package/dist/core/apply.d.ts +18 -1
- package/dist/core/apply.js +19 -2
- package/dist/core/gate.js +3 -3
- package/dist/core/locate.d.ts +19 -4
- package/dist/core/locate.js +90 -40
- package/dist/core/merge.js +218 -72
- package/dist/core/pipeline.d.ts +17 -1
- package/dist/core/pipeline.js +149 -35
- package/dist/core/red-record.d.ts +14 -5
- package/dist/core/red-record.js +82 -24
- package/dist/core/registry-issues.d.ts +30 -0
- package/dist/core/registry-issues.js +26 -0
- package/dist/core/registry.d.ts +12 -5
- package/dist/core/registry.js +10 -8
- package/dist/core/runner.js +21 -9
- package/dist/core/schema.d.ts +32 -27
- package/dist/core/schema.js +33 -5
- package/dist/core/skill.js +101 -27
- package/dist/core/splice.d.ts +62 -3
- package/dist/core/splice.js +297 -27
- package/dist/core/static-registry.d.ts +42 -0
- package/dist/core/static-registry.js +136 -21
- package/dist/core/status.js +2 -2
- package/dist/core/terminal.js +5 -2
- package/dist/core/types.d.ts +39 -10
- package/dist/core/validator.d.ts +1 -0
- package/dist/core/validator.js +22 -0
- package/package.json +2 -2
|
@@ -1,17 +1,26 @@
|
|
|
1
|
-
import type { AttestPlan, Outcome, RunResult } from './types.js';
|
|
1
|
+
import type { AttestPlan, Outcome, RunResult, ScenarioRef } from './types.js';
|
|
2
2
|
/** Where the record lives, relative to the project root. */
|
|
3
3
|
export declare function redRecordPath(root: string, changeName: string): string;
|
|
4
4
|
/** The file name, exported so a diagnostic can name it without rebuilding it. */
|
|
5
5
|
export declare const RED_RECORD_FILE = "first-run.json";
|
|
6
6
|
/**
|
|
7
|
-
* reqId -> scenario name -> the outcome of its first observed run.
|
|
7
|
+
* reqId -> spec file -> scenario name -> the outcome of its first observed run.
|
|
8
8
|
*
|
|
9
9
|
* Deliberately not a list of "red scenarios": a scenario whose first run passed
|
|
10
10
|
* is the case mechanism 2 exists to catch, so it has to be recorded as a fact
|
|
11
11
|
* rather than as an absence. An absence then means only one thing — never
|
|
12
12
|
* observed at all — and the gate can report the two separately.
|
|
13
|
+
*
|
|
14
|
+
* **The file level is what identifies a scenario, and it was missing.** A
|
|
15
|
+
* scenario is `(reqId, file, name)` in the static plan and was `(reqId, name)`
|
|
16
|
+
* here, so two spec files declaring the same name under one added requirement
|
|
17
|
+
* shared an entry — and because the record is monotonic toward `fail`, one of
|
|
18
|
+
* them going red satisfied the other's first-red obligation. Nested rather than
|
|
19
|
+
* a composite key string, so no separator has to be chosen that a path or a
|
|
20
|
+
* scenario name could contain, and so the committed file stays something a
|
|
21
|
+
* reviewer reads (design §6).
|
|
13
22
|
*/
|
|
14
|
-
export type RedRecord = Record<string, Record<string, Outcome
|
|
23
|
+
export type RedRecord = Record<string, Record<string, Record<string, Outcome>>>;
|
|
15
24
|
/**
|
|
16
25
|
* Read the record for a change. A missing or unreadable file is an empty
|
|
17
26
|
* record, never an error: the first stage-1 run legitimately finds nothing, and
|
|
@@ -34,7 +43,7 @@ export declare function readRedRecord(root: string, changeName: string): Promise
|
|
|
34
43
|
* what it must not do is answer from an inherited key whoever constructed the
|
|
35
44
|
* object it was handed. `record[reqId]?.[scenario]` did exactly that.
|
|
36
45
|
*/
|
|
37
|
-
export declare function recordedOutcome(record: RedRecord,
|
|
46
|
+
export declare function recordedOutcome(record: RedRecord, ref: ScenarioRef): Outcome | undefined;
|
|
38
47
|
/**
|
|
39
48
|
* Whether this scenario has satisfied the first-red obligation (design §6).
|
|
40
49
|
*
|
|
@@ -44,7 +53,7 @@ export declare function recordedOutcome(record: RedRecord, reqId: string, scenar
|
|
|
44
53
|
* apart — the same argument that extracted `declaredNotRunIssues` and
|
|
45
54
|
* `uncoveredIssues` from the commands that had a copy each.
|
|
46
55
|
*/
|
|
47
|
-
export declare function hasRecordedRed(record: RedRecord,
|
|
56
|
+
export declare function hasRecordedRed(record: RedRecord, ref: ScenarioRef): boolean;
|
|
48
57
|
/**
|
|
49
58
|
* Fold this run's outcomes into the record, for the scenarios that carry an
|
|
50
59
|
* obligation — those covering a requirement this change ADDs.
|
package/dist/core/red-record.js
CHANGED
|
@@ -47,6 +47,32 @@ import { readFile } from 'node:fs/promises';
|
|
|
47
47
|
import { join } from 'node:path';
|
|
48
48
|
import { z } from 'zod';
|
|
49
49
|
import { byCodeUnit } from './order.js';
|
|
50
|
+
import { mergedSpecPath } from './merge.js';
|
|
51
|
+
import { toPosixPath } from './paths.js';
|
|
52
|
+
/**
|
|
53
|
+
* The file half of a scenario's identity, as this record spells it.
|
|
54
|
+
*
|
|
55
|
+
* A scenario is `(reqId, file, name)` — but the one file this record is about
|
|
56
|
+
* is a **proposed** spec, and `--apply` renames it in place as step 2 of the
|
|
57
|
+
* merge. Keyed by the name on disk, a resume after that rename looked up
|
|
58
|
+
* `app2.spec.ts` in a record written under `app2.proposed.spec.ts`, found
|
|
59
|
+
* nothing, and blocked a change whose scenarios had been observed red — the
|
|
60
|
+
* gate refusing its own evidence. Rewriting the record beside the rename was
|
|
61
|
+
* the alternative and is worse: two files that must move together, in a design
|
|
62
|
+
* whose whole resume story is that each step is separately idempotent.
|
|
63
|
+
*
|
|
64
|
+
* So the key is the **merged** spelling from the start, which the rename cannot
|
|
65
|
+
* move. It still separates two spec files, which is the collapse this key
|
|
66
|
+
* exists to prevent; what it deliberately does not separate is one file from
|
|
67
|
+
* its own proposed name, because those are the same file.
|
|
68
|
+
*
|
|
69
|
+
* Through `toPosixPath` because `mergedSpecPath` rebuilds the path with
|
|
70
|
+
* `node:path`, which on Windows hands back a backslash — and this is a
|
|
71
|
+
* comparison key against a plan built on POSIX separators (`paths.ts`).
|
|
72
|
+
*/
|
|
73
|
+
function fileKey(file) {
|
|
74
|
+
return toPosixPath(mergedSpecPath(file));
|
|
75
|
+
}
|
|
50
76
|
/**
|
|
51
77
|
* **The record is a map, so it is built without a prototype.**
|
|
52
78
|
*
|
|
@@ -92,7 +118,7 @@ export const RED_RECORD_FILE = 'first-run.json';
|
|
|
92
118
|
* The gate was never at risk — `hasRecordedRed` compares against `'fail'`, so an
|
|
93
119
|
* unrecognised value blocks exactly like a missing one. This closes the report.
|
|
94
120
|
*/
|
|
95
|
-
const RedRecordSchema = z.record(z.string(), z.record(z.string(), z.enum(['pass', 'fail'])));
|
|
121
|
+
const RedRecordSchema = z.record(z.string(), z.record(z.string(), z.record(z.string(), z.enum(['pass', 'fail']))));
|
|
96
122
|
/**
|
|
97
123
|
* Read the record for a change. A missing or unreadable file is an empty
|
|
98
124
|
* record, never an error: the first stage-1 run legitimately finds nothing, and
|
|
@@ -122,8 +148,15 @@ export async function readRedRecord(root, changeName) {
|
|
|
122
148
|
}
|
|
123
149
|
// `JSON.parse` yields `any`; nothing about the file is known until the schema
|
|
124
150
|
// says so, including whether it is an object at all (`JSON.parse('null')`).
|
|
125
|
-
|
|
126
|
-
|
|
151
|
+
// The version is checked rather than inferred from whether the shape happens
|
|
152
|
+
// to parse. A version-1 record keyed `reqId -> name` would fail the schema
|
|
153
|
+
// anyway and be discarded whole, which is the safe direction — but "discarded
|
|
154
|
+
// because the format moved" and "discarded because the file is corrupt" are
|
|
155
|
+
// different facts, and reading one as the other is how a format change gets
|
|
156
|
+
// made without anyone deciding to make it.
|
|
157
|
+
if (!isRecord(parsed) || parsed['version'] !== 2)
|
|
158
|
+
return emptyMap();
|
|
159
|
+
const result = RedRecordSchema.safeParse(parsed['firstRun']);
|
|
127
160
|
return result.success ? adopt(result.data) : emptyMap();
|
|
128
161
|
}
|
|
129
162
|
/**
|
|
@@ -139,8 +172,16 @@ export async function readRedRecord(root, changeName) {
|
|
|
139
172
|
*/
|
|
140
173
|
function adopt(parsed) {
|
|
141
174
|
const record = emptyMap();
|
|
142
|
-
for (const [id,
|
|
143
|
-
|
|
175
|
+
for (const [id, byFile] of Object.entries(parsed)) {
|
|
176
|
+
const files = emptyMap();
|
|
177
|
+
// Every level, not just the two that existed before: the file level is as
|
|
178
|
+
// much a key taken from a string this module did not choose as the other
|
|
179
|
+
// two, and a container with a prototype is what the whole comment above is
|
|
180
|
+
// about.
|
|
181
|
+
for (const [file, outcomes] of Object.entries(byFile)) {
|
|
182
|
+
files[file] = Object.assign(emptyMap(), outcomes);
|
|
183
|
+
}
|
|
184
|
+
record[id] = files;
|
|
144
185
|
}
|
|
145
186
|
return record;
|
|
146
187
|
}
|
|
@@ -153,13 +194,17 @@ function adopt(parsed) {
|
|
|
153
194
|
* what it must not do is answer from an inherited key whoever constructed the
|
|
154
195
|
* object it was handed. `record[reqId]?.[scenario]` did exactly that.
|
|
155
196
|
*/
|
|
156
|
-
export function recordedOutcome(record,
|
|
157
|
-
if (!Object.hasOwn(record, reqId))
|
|
197
|
+
export function recordedOutcome(record, ref) {
|
|
198
|
+
if (!Object.hasOwn(record, ref.reqId))
|
|
158
199
|
return undefined;
|
|
159
|
-
const
|
|
160
|
-
|
|
200
|
+
const byFile = record[ref.reqId];
|
|
201
|
+
const key = fileKey(ref.file);
|
|
202
|
+
if (byFile === undefined || !Object.hasOwn(byFile, key))
|
|
161
203
|
return undefined;
|
|
162
|
-
|
|
204
|
+
const outcomes = byFile[key];
|
|
205
|
+
if (outcomes === undefined || !Object.hasOwn(outcomes, ref.name))
|
|
206
|
+
return undefined;
|
|
207
|
+
return outcomes[ref.name];
|
|
163
208
|
}
|
|
164
209
|
/**
|
|
165
210
|
* Whether this scenario has satisfied the first-red obligation (design §6).
|
|
@@ -170,8 +215,8 @@ export function recordedOutcome(record, reqId, scenario) {
|
|
|
170
215
|
* apart — the same argument that extracted `declaredNotRunIssues` and
|
|
171
216
|
* `uncoveredIssues` from the commands that had a copy each.
|
|
172
217
|
*/
|
|
173
|
-
export function hasRecordedRed(record,
|
|
174
|
-
return recordedOutcome(record,
|
|
218
|
+
export function hasRecordedRed(record, ref) {
|
|
219
|
+
return recordedOutcome(record, ref) === 'fail';
|
|
175
220
|
}
|
|
176
221
|
/**
|
|
177
222
|
* Fold this run's outcomes into the record, for the scenarios that carry an
|
|
@@ -190,42 +235,55 @@ export function mergeRedRecord(existing, plan, run, addedIds) {
|
|
|
190
235
|
// machines write byte-identical files (the same reason `render` orders ids
|
|
191
236
|
// through one code-unit comparator).
|
|
192
237
|
for (const id of Object.keys(existing).sort(byCodeUnit)) {
|
|
193
|
-
const
|
|
194
|
-
if (
|
|
238
|
+
const byFile = existing[id];
|
|
239
|
+
if (byFile === undefined)
|
|
195
240
|
continue;
|
|
196
|
-
|
|
241
|
+
const files = emptyMap();
|
|
242
|
+
for (const file of Object.keys(byFile).sort(byCodeUnit)) {
|
|
243
|
+
const outcomes = byFile[file];
|
|
244
|
+
if (outcomes === undefined)
|
|
245
|
+
continue;
|
|
246
|
+
files[file] = Object.assign(emptyMap(), outcomes);
|
|
247
|
+
}
|
|
248
|
+
record[id] = files;
|
|
197
249
|
}
|
|
198
250
|
let changed = false;
|
|
199
251
|
for (const s of plan.scenarios) {
|
|
200
252
|
if (!added.has(s.reqId))
|
|
201
253
|
continue;
|
|
202
|
-
const outcome = run.outcomes.get(s.reqId)?.get(s.name);
|
|
254
|
+
const outcome = run.outcomes.get(s.reqId)?.get(s.file)?.get(s.name);
|
|
203
255
|
// No outcome means the scenario did not execute. That is `declared-not-run`,
|
|
204
256
|
// reported by the gate on its own terms; writing nothing here leaves the
|
|
205
257
|
// obligation unobserved rather than inventing a state for it.
|
|
206
258
|
if (!outcome)
|
|
207
259
|
continue;
|
|
208
260
|
const forId = (record[s.reqId] ??= emptyMap());
|
|
261
|
+
const forFile = (forId[fileKey(s.file)] ??= emptyMap());
|
|
209
262
|
// Monotonic toward `fail`: a recorded fail is final, a recorded pass can
|
|
210
263
|
// still be corrected by a real one. See the note at the top of this file.
|
|
211
|
-
if (
|
|
264
|
+
if (forFile[s.name] === 'fail' || forFile[s.name] === outcome)
|
|
212
265
|
continue;
|
|
213
|
-
|
|
266
|
+
forFile[s.name] = outcome;
|
|
214
267
|
changed = true;
|
|
215
268
|
}
|
|
216
|
-
// Sort within each requirement for the same
|
|
269
|
+
// Sort within each requirement, and within each file, for the same
|
|
270
|
+
// byte-stability reason.
|
|
217
271
|
for (const id of Object.keys(record)) {
|
|
218
|
-
const
|
|
219
|
-
for (const
|
|
220
|
-
sorted
|
|
272
|
+
const sortedFiles = emptyMap();
|
|
273
|
+
for (const file of Object.keys(record[id]).sort(byCodeUnit)) {
|
|
274
|
+
const sorted = emptyMap();
|
|
275
|
+
for (const name of Object.keys(record[id][file]).sort(byCodeUnit)) {
|
|
276
|
+
sorted[name] = record[id][file][name];
|
|
277
|
+
}
|
|
278
|
+
sortedFiles[file] = sorted;
|
|
221
279
|
}
|
|
222
|
-
record[id] =
|
|
280
|
+
record[id] = sortedFiles;
|
|
223
281
|
}
|
|
224
282
|
return { record, changed };
|
|
225
283
|
}
|
|
226
284
|
/** Serialise the record. A trailing newline, so the file is a well-formed text file. */
|
|
227
285
|
export function serialiseRedRecord(changeName, record) {
|
|
228
|
-
const file = { version:
|
|
286
|
+
const file = { version: 2, change: changeName, firstRun: record };
|
|
229
287
|
return `${JSON.stringify(file, null, 2)}\n`;
|
|
230
288
|
}
|
|
231
289
|
/** Container check only — what is *in* it is the schema's business, not this. */
|
|
@@ -0,0 +1,30 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* One structural failure from validating a registry.
|
|
3
|
+
*
|
|
4
|
+
* Deliberately not the validator's own issue type: `issues` is reached through
|
|
5
|
+
* the `./define` subpath, so its element type is a public contract, and §10's
|
|
6
|
+
* fourth encapsulation rule keeps a dependency's type off one. These two fields
|
|
7
|
+
* are the whole of what this project consumes — nothing reads `code`,
|
|
8
|
+
* `expected`, `received` or `fatal`.
|
|
9
|
+
*
|
|
10
|
+
* `path` is `PropertyKey[]` rather than `(string | number)[]` because that is
|
|
11
|
+
* the wider of the two spellings zod has used, so a validator issue assigns to
|
|
12
|
+
* this without a cast in either direction. Keep it the wider one: narrowing it
|
|
13
|
+
* would put the next reshape of that type back on the boundary.
|
|
14
|
+
*/
|
|
15
|
+
export interface RegistryValidationIssue {
|
|
16
|
+
path: PropertyKey[];
|
|
17
|
+
message: string;
|
|
18
|
+
}
|
|
19
|
+
/**
|
|
20
|
+
* The message `RegistryValidationError` carries, available without one.
|
|
21
|
+
*
|
|
22
|
+
* Three sites report `registry-invalid` — the throwing path in
|
|
23
|
+
* `defineRequirements`, and the two readers in `locate.ts` and
|
|
24
|
+
* `static-registry.ts` that report it without throwing — and their messages
|
|
25
|
+
* have to be identical rather than merely similar. Sharing this function is
|
|
26
|
+
* what makes that structural rather than a coincidence maintained by hand, so
|
|
27
|
+
* a new site formats through here and does not grow a fourth spelling.
|
|
28
|
+
*/
|
|
29
|
+
export declare function registryValidationMessage(issues: readonly RegistryValidationIssue[]): string;
|
|
30
|
+
//# sourceMappingURL=registry-issues.d.ts.map
|
|
@@ -0,0 +1,26 @@
|
|
|
1
|
+
// The shape of a registry schema failure, and the one rendering of it
|
|
2
|
+
// (design §5.1, §10).
|
|
3
|
+
//
|
|
4
|
+
// Separate from registry.ts because `./define` maps to that whole module, so
|
|
5
|
+
// everything it exports is a public contract. What has to be public here is the
|
|
6
|
+
// element type of `RegistryValidationError.issues`; the formatter does not, and
|
|
7
|
+
// both of its callers are internal.
|
|
8
|
+
/**
|
|
9
|
+
* The message `RegistryValidationError` carries, available without one.
|
|
10
|
+
*
|
|
11
|
+
* Three sites report `registry-invalid` — the throwing path in
|
|
12
|
+
* `defineRequirements`, and the two readers in `locate.ts` and
|
|
13
|
+
* `static-registry.ts` that report it without throwing — and their messages
|
|
14
|
+
* have to be identical rather than merely similar. Sharing this function is
|
|
15
|
+
* what makes that structural rather than a coincidence maintained by hand, so
|
|
16
|
+
* a new site formats through here and does not grow a fourth spelling.
|
|
17
|
+
*/
|
|
18
|
+
export function registryValidationMessage(issues) {
|
|
19
|
+
// `.map(String)` and not a bare `.join('.')`: under `PropertyKey[]` a segment
|
|
20
|
+
// can be a symbol, and `join` throws rather than converting one.
|
|
21
|
+
const fields = issues
|
|
22
|
+
.map((i) => ` - ${i.path.map(String).join('.') || '(root)'}: ${i.message}`)
|
|
23
|
+
.join('\n');
|
|
24
|
+
return `Invalid requirement registry:\n${fields}`;
|
|
25
|
+
}
|
|
26
|
+
//# sourceMappingURL=registry-issues.js.map
|
package/dist/core/registry.d.ts
CHANGED
|
@@ -1,10 +1,18 @@
|
|
|
1
|
-
import { z } from 'zod';
|
|
2
1
|
import { type RegistryInput, type RequirementInput } from './schema.js';
|
|
2
|
+
import { type RegistryValidationIssue } from './registry-issues.js';
|
|
3
3
|
import type { Registry, Requirement } from './types.js';
|
|
4
|
-
|
|
4
|
+
export type { RegistryValidationIssue } from './registry-issues.js';
|
|
5
|
+
/**
|
|
6
|
+
* Thrown when a registry fails structural validation (design §5.1).
|
|
7
|
+
*
|
|
8
|
+
* `issues` is typed as this project's own {@link RegistryValidationIssue} and
|
|
9
|
+
* not the validator's, because this class is reached through the `./define`
|
|
10
|
+
* subpath and the field is therefore a public contract — the same boundary
|
|
11
|
+
* `Issue.message` holds, with the difference that `Issue` was designed with it.
|
|
12
|
+
*/
|
|
5
13
|
export declare class RegistryValidationError extends Error {
|
|
6
|
-
readonly issues:
|
|
7
|
-
constructor(issues:
|
|
14
|
+
readonly issues: RegistryValidationIssue[];
|
|
15
|
+
constructor(issues: RegistryValidationIssue[]);
|
|
8
16
|
}
|
|
9
17
|
/**
|
|
10
18
|
* A `const` type parameter infers an array literal as a tuple, and would infer
|
|
@@ -139,5 +147,4 @@ export declare function delta<const T extends RegistryDelta>(d: T): DefinedDelta
|
|
|
139
147
|
* caller wrote is still the literal they hold.
|
|
140
148
|
*/
|
|
141
149
|
export declare function withProposedRequirements<T extends RegistryDelta>(d: T): T;
|
|
142
|
-
export {};
|
|
143
150
|
//# sourceMappingURL=registry.d.ts.map
|
package/dist/core/registry.js
CHANGED
|
@@ -1,22 +1,24 @@
|
|
|
1
1
|
// Requirement registry authoring API (design §2, §5.1, §7).
|
|
2
2
|
// defineRequirements validates against RegistrySchema and applies defaults;
|
|
3
3
|
// delta() declares a change delta, applied by applyDelta (see apply.ts).
|
|
4
|
-
import { z } from 'zod';
|
|
5
4
|
import { RegistrySchema, } from './schema.js';
|
|
6
|
-
|
|
5
|
+
import { registryValidationMessage, } from './registry-issues.js';
|
|
6
|
+
/**
|
|
7
|
+
* Thrown when a registry fails structural validation (design §5.1).
|
|
8
|
+
*
|
|
9
|
+
* `issues` is typed as this project's own {@link RegistryValidationIssue} and
|
|
10
|
+
* not the validator's, because this class is reached through the `./define`
|
|
11
|
+
* subpath and the field is therefore a public contract — the same boundary
|
|
12
|
+
* `Issue.message` holds, with the difference that `Issue` was designed with it.
|
|
13
|
+
*/
|
|
7
14
|
export class RegistryValidationError extends Error {
|
|
8
15
|
issues;
|
|
9
16
|
constructor(issues) {
|
|
10
|
-
super(
|
|
17
|
+
super(registryValidationMessage(issues));
|
|
11
18
|
this.issues = issues;
|
|
12
19
|
this.name = 'RegistryValidationError';
|
|
13
20
|
}
|
|
14
21
|
}
|
|
15
|
-
function formatIssues(issues) {
|
|
16
|
-
return issues
|
|
17
|
-
.map((i) => ` - ${i.path.join('.') || '(root)'}: ${i.message}`)
|
|
18
|
-
.join('\n');
|
|
19
|
-
}
|
|
20
22
|
/**
|
|
21
23
|
* Declare a requirement registry. Validates structure against RegistrySchema
|
|
22
24
|
* (SHALL/MUST keyword, non-empty rationale, well-formed IDs, scalar params) and
|
package/dist/core/runner.js
CHANGED
|
@@ -70,12 +70,19 @@ export async function runAndCollect(options = {}) {
|
|
|
70
70
|
const runtimeCoverage = new Map();
|
|
71
71
|
const outcomes = new Map();
|
|
72
72
|
// Reconstruct coverage from the task tree (not an in-process singleton).
|
|
73
|
-
|
|
73
|
+
//
|
|
74
|
+
// `file` is threaded down from the file task rather than read off the suite,
|
|
75
|
+
// because it is the only level that carries a path — and it is what keeps
|
|
76
|
+
// two spec files declaring the same scenario name under one `requirement()`
|
|
77
|
+
// from collapsing into a single entry that either of them could satisfy.
|
|
78
|
+
const walk = (task, file) => {
|
|
74
79
|
if (task.type === 'suite') {
|
|
75
80
|
const id = requirementIdOf(task.name);
|
|
76
81
|
if (id !== undefined) {
|
|
77
|
-
const
|
|
78
|
-
const
|
|
82
|
+
const byFile = runtimeCoverage.get(id) ?? new Map();
|
|
83
|
+
const outcomesByFile = outcomes.get(id) ?? new Map();
|
|
84
|
+
const set = byFile.get(file) ?? new Set();
|
|
85
|
+
const byName = outcomesByFile.get(file) ?? new Map();
|
|
79
86
|
for (const c of scenariosUnder(task)) {
|
|
80
87
|
// A scenario counts as covered only if it actually executed —
|
|
81
88
|
// skipped/todo scenarios have no run result (enables §8's
|
|
@@ -87,12 +94,14 @@ export async function runAndCollect(options = {}) {
|
|
|
87
94
|
set.add(c.name);
|
|
88
95
|
byName.set(c.name, outcome);
|
|
89
96
|
}
|
|
90
|
-
|
|
91
|
-
|
|
97
|
+
byFile.set(file, set);
|
|
98
|
+
outcomesByFile.set(file, byName);
|
|
99
|
+
runtimeCoverage.set(id, byFile);
|
|
100
|
+
outcomes.set(id, outcomesByFile);
|
|
92
101
|
}
|
|
93
102
|
}
|
|
94
103
|
for (const c of task.tasks ?? [])
|
|
95
|
-
walk(c);
|
|
104
|
+
walk(c, file);
|
|
96
105
|
};
|
|
97
106
|
// Assigned, never cast. `TaskLike` is the narrow view this walk needs, and
|
|
98
107
|
// Vitest's `File` satisfies it structurally — so the compiler is the thing
|
|
@@ -101,12 +110,15 @@ export async function runAndCollect(options = {}) {
|
|
|
101
110
|
// exactly the change that would silently empty the runtime coverage.
|
|
102
111
|
const unloadedFiles = [];
|
|
103
112
|
for (const file of vitest.state.getFiles()) {
|
|
104
|
-
walk(file);
|
|
105
113
|
// Relative and POSIX for the reason everything derived from the root is
|
|
106
114
|
// (`paths.ts`): this becomes an `Issue.file`, which a `--json` consumer
|
|
107
|
-
// diffs across two CI runs that may not share an operating system
|
|
115
|
+
// diffs across two CI runs that may not share an operating system — and,
|
|
116
|
+
// since the coverage maps are keyed by it, a comparison key against a plan
|
|
117
|
+
// built the same way.
|
|
118
|
+
const relative = relativePath(options.root ?? process.cwd(), file.filepath);
|
|
119
|
+
walk(file, relative);
|
|
108
120
|
if (failedToLoad(file)) {
|
|
109
|
-
unloadedFiles.push(
|
|
121
|
+
unloadedFiles.push(relative);
|
|
110
122
|
}
|
|
111
123
|
}
|
|
112
124
|
unloadedFiles.sort(byCodeUnit);
|
package/dist/core/schema.d.ts
CHANGED
|
@@ -1,27 +1,17 @@
|
|
|
1
1
|
import { z } from 'zod';
|
|
2
2
|
/** A scalar param value. `null` is included: it is how an author writes "empty". */
|
|
3
|
-
declare const scalar: z.ZodUnion<[z.ZodNumber, z.ZodString, z.ZodBoolean, z.ZodNull]>;
|
|
3
|
+
declare const scalar: z.ZodUnion<readonly [z.ZodNumber, z.ZodString, z.ZodBoolean, z.ZodNull]>;
|
|
4
4
|
/** Any JSON value — what a param may be. */
|
|
5
5
|
export type ParamValue = z.infer<typeof scalar> | ParamValue[] | {
|
|
6
6
|
[key: string]: ParamValue;
|
|
7
7
|
};
|
|
8
8
|
/** A single behavioural contract (design §2). */
|
|
9
9
|
export declare const RequirementSchema: z.ZodObject<{
|
|
10
|
-
statement: z.
|
|
10
|
+
statement: z.ZodString;
|
|
11
11
|
rationale: z.ZodString;
|
|
12
|
-
params: z.ZodDefault<z.ZodRecord<z.ZodString, z.ZodType<ParamValue, z.
|
|
13
|
-
outOfScope: z.ZodDefault<z.ZodArray<z.ZodString
|
|
14
|
-
},
|
|
15
|
-
params: Record<string, ParamValue>;
|
|
16
|
-
statement: string;
|
|
17
|
-
rationale: string;
|
|
18
|
-
outOfScope: string[];
|
|
19
|
-
}, {
|
|
20
|
-
statement: string;
|
|
21
|
-
rationale: string;
|
|
22
|
-
params?: Record<string, ParamValue> | undefined;
|
|
23
|
-
outOfScope?: string[] | undefined;
|
|
24
|
-
}>;
|
|
12
|
+
params: z.ZodDefault<z.ZodRecord<z.ZodString, z.ZodType<ParamValue, unknown, z.core.$ZodTypeInternals<ParamValue, unknown>>>>;
|
|
13
|
+
outOfScope: z.ZodDefault<z.ZodArray<z.ZodString>>;
|
|
14
|
+
}, z.core.$strip>;
|
|
25
15
|
/**
|
|
26
16
|
* The id grammar, on its own.
|
|
27
17
|
*
|
|
@@ -34,23 +24,38 @@ export declare const RequirementSchema: z.ZodObject<{
|
|
|
34
24
|
* two grammars as soon as one of them is edited.
|
|
35
25
|
*/
|
|
36
26
|
export declare const RequirementIdSchema: z.ZodString;
|
|
37
|
-
/**
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
|
|
27
|
+
/**
|
|
28
|
+
* The requirement registry: stable ID -> requirement (design §2, §5.1).
|
|
29
|
+
*
|
|
30
|
+
* Guarded by `isPlainObject` for the same reason `jsonObject` is, and it is the
|
|
31
|
+
* *evaluating* reader this protects: `{ '__proto__': { … } }` swaps the
|
|
32
|
+
* prototype rather than creating a key, so the registry a loader builds from
|
|
33
|
+
* that file comes from the prototype and not from anything the file declares.
|
|
34
|
+
* The static reader refuses such a source outright (`registry-not-static`), and
|
|
35
|
+
* the two readers agreeing is a security property (design §5.2), not a
|
|
36
|
+
* convenience — so the schema has to refuse it rather than enumerate it.
|
|
37
|
+
*
|
|
38
|
+
* The guard is written over `RegistryRecord`'s input so `z.input` still
|
|
39
|
+
* describes the authoring shape: `defineRequirements` constrains its `const`
|
|
40
|
+
* type parameter to `RegistryInput`, and a guard typed as a bare record would
|
|
41
|
+
* erase every literal that constraint exists to capture.
|
|
42
|
+
*/
|
|
43
|
+
export declare const RegistrySchema: z.ZodPipe<z.ZodCustom<Record<string, {
|
|
45
44
|
statement: string;
|
|
46
45
|
rationale: string;
|
|
47
|
-
|
|
48
|
-
|
|
46
|
+
params?: Record<string, unknown> | undefined;
|
|
47
|
+
outOfScope?: string[] | undefined;
|
|
48
|
+
}>, Record<string, {
|
|
49
49
|
statement: string;
|
|
50
50
|
rationale: string;
|
|
51
|
-
params?: Record<string,
|
|
51
|
+
params?: Record<string, unknown> | undefined;
|
|
52
52
|
outOfScope?: string[] | undefined;
|
|
53
|
-
}
|
|
53
|
+
}>>, z.ZodRecord<z.ZodString, z.ZodObject<{
|
|
54
|
+
statement: z.ZodString;
|
|
55
|
+
rationale: z.ZodString;
|
|
56
|
+
params: z.ZodDefault<z.ZodRecord<z.ZodString, z.ZodType<ParamValue, unknown, z.core.$ZodTypeInternals<ParamValue, unknown>>>>;
|
|
57
|
+
outOfScope: z.ZodDefault<z.ZodArray<z.ZodString>>;
|
|
58
|
+
}, z.core.$strip>>>;
|
|
54
59
|
/** Parsed (output) shapes — defaults applied. */
|
|
55
60
|
export type Requirement = z.infer<typeof RequirementSchema>;
|
|
56
61
|
export type Registry = z.infer<typeof RegistrySchema>;
|
package/dist/core/schema.js
CHANGED
|
@@ -35,9 +35,7 @@ const jsonObject = z
|
|
|
35
35
|
.custom(isPlainObject)
|
|
36
36
|
.pipe(z.record(z.string(), z.lazy(() => paramValue)));
|
|
37
37
|
const paramValue = z.lazy(() => z.union([scalar, z.array(paramValue), jsonObject], {
|
|
38
|
-
|
|
39
|
-
message: 'expected JSON data (no functions, dates, or class instances)',
|
|
40
|
-
}),
|
|
38
|
+
error: 'expected JSON data (no functions, dates, or class instances)',
|
|
41
39
|
}));
|
|
42
40
|
/** A single behavioural contract (design §2). */
|
|
43
41
|
export const RequirementSchema = z.object({
|
|
@@ -80,6 +78,36 @@ export const RequirementSchema = z.object({
|
|
|
80
78
|
export const RequirementIdSchema = z
|
|
81
79
|
.string()
|
|
82
80
|
.regex(/^[A-Z]+-\d+$/, 'id must look like AUTH-3');
|
|
83
|
-
/**
|
|
84
|
-
|
|
81
|
+
/**
|
|
82
|
+
* The registry's own record, before the plain-object guard below.
|
|
83
|
+
*
|
|
84
|
+
* `error` is carried explicitly because a record reports a key failure as
|
|
85
|
+
* "Invalid key in record" and does not surface the key schema's own message —
|
|
86
|
+
* and a mistyped id is the commonest way to fail this schema, so the grammar is
|
|
87
|
+
* the one thing the author needs to be told.
|
|
88
|
+
*/
|
|
89
|
+
const RegistryRecord = z.record(RequirementIdSchema, RequirementSchema, {
|
|
90
|
+
error: 'id must look like AUTH-3',
|
|
91
|
+
});
|
|
92
|
+
/**
|
|
93
|
+
* The requirement registry: stable ID -> requirement (design §2, §5.1).
|
|
94
|
+
*
|
|
95
|
+
* Guarded by `isPlainObject` for the same reason `jsonObject` is, and it is the
|
|
96
|
+
* *evaluating* reader this protects: `{ '__proto__': { … } }` swaps the
|
|
97
|
+
* prototype rather than creating a key, so the registry a loader builds from
|
|
98
|
+
* that file comes from the prototype and not from anything the file declares.
|
|
99
|
+
* The static reader refuses such a source outright (`registry-not-static`), and
|
|
100
|
+
* the two readers agreeing is a security property (design §5.2), not a
|
|
101
|
+
* convenience — so the schema has to refuse it rather than enumerate it.
|
|
102
|
+
*
|
|
103
|
+
* The guard is written over `RegistryRecord`'s input so `z.input` still
|
|
104
|
+
* describes the authoring shape: `defineRequirements` constrains its `const`
|
|
105
|
+
* type parameter to `RegistryInput`, and a guard typed as a bare record would
|
|
106
|
+
* erase every literal that constraint exists to capture.
|
|
107
|
+
*/
|
|
108
|
+
export const RegistrySchema = z
|
|
109
|
+
.custom(isPlainObject, {
|
|
110
|
+
error: 'a registry must be an object literal',
|
|
111
|
+
})
|
|
112
|
+
.pipe(RegistryRecord);
|
|
85
113
|
//# sourceMappingURL=schema.js.map
|