@am_shork/attest 0.7.4 → 0.8.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.
@@ -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
@@ -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
- /** Thrown when a registry fails structural validation (design §5.1). */
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: z.ZodIssue[];
7
- constructor(issues: z.ZodIssue[]);
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
@@ -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
- /** Thrown when a registry fails structural validation (design §5.1). */
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(`Invalid requirement registry:\n${formatIssues(issues)}`);
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
@@ -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
- const walk = (task) => {
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 set = runtimeCoverage.get(id) ?? new Set();
78
- const byName = outcomes.get(id) ?? new Map();
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
- runtimeCoverage.set(id, set);
91
- outcomes.set(id, byName);
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(relativePath(options.root ?? process.cwd(), file.filepath));
121
+ unloadedFiles.push(relative);
110
122
  }
111
123
  }
112
124
  unloadedFiles.sort(byCodeUnit);
@@ -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.ZodEffects<z.ZodString, string, string>;
10
+ statement: z.ZodString;
11
11
  rationale: z.ZodString;
12
- params: z.ZodDefault<z.ZodRecord<z.ZodString, z.ZodType<ParamValue, z.ZodTypeDef, ParamValue>>>;
13
- outOfScope: z.ZodDefault<z.ZodArray<z.ZodString, "many">>;
14
- }, "strip", z.ZodTypeAny, {
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
- /** The requirement registry: stable ID -> requirement (design §2, §5.1). */
38
- export declare const RegistrySchema: z.ZodRecord<z.ZodString, z.ZodObject<{
39
- statement: z.ZodEffects<z.ZodString, string, string>;
40
- rationale: z.ZodString;
41
- params: z.ZodDefault<z.ZodRecord<z.ZodString, z.ZodType<ParamValue, z.ZodTypeDef, ParamValue>>>;
42
- outOfScope: z.ZodDefault<z.ZodArray<z.ZodString, "many">>;
43
- }, "strip", z.ZodTypeAny, {
44
- params: Record<string, ParamValue>;
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
- outOfScope: string[];
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, ParamValue> | undefined;
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>;
@@ -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
- errorMap: () => ({
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
- /** The requirement registry: stable ID -> requirement (design §2, §5.1). */
84
- export const RegistrySchema = z.record(RequirementIdSchema, RequirementSchema);
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
@@ -102,6 +102,19 @@ promises is a two-stage workflow, and the stages are separate on purpose.
102
102
  rule forbids, and the distinction is the whole of it — that rule is about the
103
103
  **expectation** the system is measured against; this pin asserts what the
104
104
  **intent** is.
105
+ - **Prefer a quantifier a scenario can iterate.** A statement that says *every*,
106
+ *all* or *any* has a finite set of scenarios under it and nothing holding the
107
+ two together — the id is covered, \`check\` is green, and whether those
108
+ scenarios span what the sentence claims is the part no gate looks at. What
109
+ decides the risk is *what* the quantifier ranges over. Over the inputs one
110
+ place processes, representative inputs settle it. Over a list the code itself
111
+ enumerates, a scenario looping that same list covers a new member by
112
+ construction — **this is the shape to write**, and when that list lives in
113
+ \`params\` rather than in the code, the pin above is what keeps it honest, since
114
+ an author can shorten it. Over *places in the implementation*, each needing its
115
+ own hand-written scenario, a new place is a new obligation and nothing
116
+ enumerates them: that is the one that silently stops being true. When you cannot avoid it, name the places in the statement instead
117
+ of quantifying over them, so a reader can count what is missing.
105
118
 
106
119
  ## Rules the engine enforces
107
120
 
@@ -25,23 +25,26 @@ import { registryInsertionPoint } from './static-registry.js';
25
25
  import { toPosixPath } from './paths.js';
26
26
  import { byCodeUnit, sortDeep } from './order.js';
27
27
  /**
28
- * A TypeScript single-quoted string literal holding exactly `value`.
28
+ * The *inside* of a TypeScript string literal quoted with `quote`, holding
29
+ * exactly `value`.
29
30
  *
30
- * Hand-escaped rather than `JSON.stringify`, for one reason that is not style:
31
- * the registries this writes into are single-quoted throughout, and a merged
32
- * entry that arrives double-quoted is a diff hunk about quotation marks in the
33
- * middle of a merge the reviewer is trying to read. Control characters go out as
34
- * `\uXXXX` rather than raw, so a statement someone pasted a newline into cannot
35
- * produce a file that no longer parses.
31
+ * Split out from `tsString` because the other site that writes into a string
32
+ * literal `repointImport` writes between quotes that are already in the
33
+ * file, whose character is the file's choice and not this module's. Escaping
34
+ * has to answer to that character: `'` needs no escape inside `"…"`, and
35
+ * escaping it there would be a wrong byte rather than a safe one.
36
+ *
37
+ * Control characters go out as `\uXXXX` rather than raw, so a value someone
38
+ * pasted a newline into cannot produce a file that no longer parses.
36
39
  */
37
- function tsString(value) {
38
- let out = "'";
40
+ function tsStringBody(value, quote) {
41
+ let out = '';
39
42
  for (const ch of value) {
40
43
  const code = ch.codePointAt(0) ?? 0;
41
44
  if (ch === '\\')
42
45
  out += '\\\\';
43
- else if (ch === "'")
44
- out += "\\'";
46
+ else if (ch === quote)
47
+ out += `\\${ch}`;
45
48
  else if (ch === '\n')
46
49
  out += '\\n';
47
50
  else if (ch === '\r')
@@ -53,7 +56,18 @@ function tsString(value) {
53
56
  else
54
57
  out += ch;
55
58
  }
56
- return `${out}'`;
59
+ return out;
60
+ }
61
+ /**
62
+ * A TypeScript single-quoted string literal holding exactly `value`.
63
+ *
64
+ * Hand-escaped rather than `JSON.stringify`, for one reason that is not style:
65
+ * the registries this writes into are single-quoted throughout, and a merged
66
+ * entry that arrives double-quoted is a diff hunk about quotation marks in the
67
+ * middle of a merge the reviewer is trying to read.
68
+ */
69
+ function tsString(value) {
70
+ return `'${tsStringBody(value, "'")}'`;
57
71
  }
58
72
  /**
59
73
  * Thrown when a value reaches the emitter that cannot be written as source
@@ -205,10 +219,27 @@ export function repointImport(file, source, from, to) {
205
219
  // A bare `x.reqs.js` is a package specifier, not a sibling file.
206
220
  if (!target.startsWith('.'))
207
221
  target = `./${target}`;
208
- // Inside the quotes: the file's own quote style is left exactly as it was.
209
- edits.push({ start: spec.getStart(sf) + 1, end: spec.getEnd() - 1, text: target });
222
+ // Inside the quotes, so the file's own quote style is left exactly as it
223
+ // was which is why the escaping is told which quote it writes between.
224
+ // `to` is the registry file `--apply` chose, making this the one value in
225
+ // the emitter the checked repository names, and a name carrying that quote
226
+ // would otherwise close the literal and put what follows into a committed
227
+ // `*.spec.ts` as code (§7). Escaped rather than refused, unlike the
228
+ // `__proto__` key above: a specifier is a string, and every string has a
229
+ // correct spelling as one.
230
+ const quote = source[spec.getStart(sf)];
231
+ edits.push({
232
+ start: spec.getStart(sf) + 1,
233
+ end: spec.getEnd() - 1,
234
+ text: tsStringBody(target, quote),
235
+ });
210
236
  }
211
237
  let out = source;
238
+ // Back to front, which is what makes a correction term unnecessary: an edit
239
+ // cannot move an offset that lies before it, so every `start`/`end` above
240
+ // stays valid as the string is rewritten under them. Applying these in source
241
+ // order works only by carrying a running delta and adding it to each
242
+ // subsequent pair — the same result, one more thing to get wrong.
212
243
  for (const edit of edits.reverse()) {
213
244
  out = out.slice(0, edit.start) + edit.text + out.slice(edit.end);
214
245
  }
@@ -21,7 +21,8 @@
21
21
  // to "is this a literal", and those two answers must not be able to disagree.
22
22
  import ts from 'typescript';
23
23
  import { parseSource } from './compiler.js';
24
- import { RegistryValidationError, withProposedRequirements } from './registry.js';
24
+ import { withProposedRequirements } from './registry.js';
25
+ import { registryValidationMessage } from './registry-issues.js';
25
26
  import { RegistrySchema } from './schema.js';
26
27
  /** The authoring function a registry file must default-export the result of. */
27
28
  const DEFINE = 'defineRequirements';
@@ -128,12 +129,12 @@ export function readRegistrySource(file, source) {
128
129
  const result = RegistrySchema.safeParse(extracted.value);
129
130
  if (!result.success) {
130
131
  // defineRequirements throws RegistryValidationError and loadRegistry wraps
131
- // it; going through the same error keeps the message identical rather than
132
- // merely similar.
132
+ // it; sharing one formatter keeps the message identical rather than merely
133
+ // similar.
133
134
  return {
134
135
  ok: false,
135
136
  code: 'registry-invalid',
136
- message: `Failed to load registry: ${new RegistryValidationError(result.error.issues).message}`,
137
+ message: `Failed to load registry: ${registryValidationMessage(result.error.issues)}`,
137
138
  };
138
139
  }
139
140
  return { ok: true, registry: result.data };
@@ -49,7 +49,7 @@ export function statusRows(addedIds, plan, firstRun) {
49
49
  name: s.name,
50
50
  file: s.file,
51
51
  line: s.line,
52
- firstRun: recordedOutcome(firstRun, reqId, s.name) ?? null,
52
+ firstRun: recordedOutcome(firstRun, { reqId, file: s.file, name: s.name }) ?? null,
53
53
  }));
54
54
  return { reqId, state: obligationState(reqId, scenarios, firstRun), scenarios };
55
55
  });
@@ -59,7 +59,7 @@ function obligationState(reqId, scenarios, firstRun) {
59
59
  return 'no-scenario';
60
60
  // Every scenario, not any: the gate raises never-red per scenario, so one
61
61
  // proven scenario beside an unproven one is still a blocked change.
62
- return scenarios.every((s) => hasRecordedRed(firstRun, reqId, s.name))
62
+ return scenarios.every((s) => hasRecordedRed(firstRun, { reqId, file: s.file, name: s.name }))
63
63
  ? 'proven'
64
64
  : 'unproven';
65
65
  }
@@ -12,8 +12,11 @@
12
12
  // write to the terminal of whoever is checking it. On a fork MR the author is
13
13
  // not the reviewer, and the payload is a scenario name or an exception: erase
14
14
  // the lines above, repaint a red verdict green, rewrite the window title. The
15
- // `--json` path was never exposed `JSON.stringify` escapes every C0
16
- // character which is precisely why this has to hold on the side people read.
15
+ // `--json` path was recorded here as never exposed, on the grounds that
16
+ // `JSON.stringify` escapes every C0 character true, and narrower than the
17
+ // class below, which also holds DEL and C1. `cli/json.ts` now applies the same
18
+ // rule at the point that document is serialised (ATX-74); this still has to
19
+ // hold independently, because a stream has no such point.
17
20
  //
18
21
  // **In `core/` rather than in the CLI**, though the CLI is its main caller: the
19
22
  // loader has to sanitise Vite's log output for the same reason and cannot
@@ -1,11 +1,22 @@
1
1
  export type { Requirement, Registry, ParamValue } from './schema.js';
2
2
  export type { IssueCode } from './docs.js';
3
3
  import type { IssueCode } from './docs.js';
4
- /** A scenario extracted statically from a spec file (design §5.2). */
5
- export interface ParsedScenario {
4
+ /**
5
+ * What identifies one scenario, everywhere the engine compares one (design §5.4).
6
+ *
7
+ * All three, and as a bundle rather than three positional strings: the file was
8
+ * missing from the key that `declared-not-run` and the first-run record are
9
+ * decided by, and adding it as a fourth `string` argument beside `reqId` and
10
+ * `name` would have made two adjacent parameters of one type that a caller can
11
+ * silently transpose. The compiler cannot catch that; a field name can.
12
+ */
13
+ export interface ScenarioRef {
6
14
  reqId: string;
7
- name: string;
8
15
  file: string;
16
+ name: string;
17
+ }
18
+ /** A scenario extracted statically from a spec file (design §5.2). */
19
+ export interface ParsedScenario extends ScenarioRef {
9
20
  line: number;
10
21
  }
11
22
  /**
@@ -31,15 +42,33 @@ export type Outcome = 'pass' | 'fail';
31
42
  export interface RunResult {
32
43
  /** True when no test failed. */
33
44
  passed: boolean;
34
- /** reqId -> set of scenario names that actually executed (from the task tree). */
35
- runtimeCoverage: Map<string, Set<string>>;
36
45
  /**
37
- * reqId -> scenario name -> how it ended. The same task-tree walk that fills
38
- * `runtimeCoverage` already had to tell `pass` from `fail` to decide whether a
39
- * scenario executed at all; this keeps that distinction instead of discarding
40
- * it, which is what §6's mechanism 2 records.
46
+ * reqId -> spec file -> the scenario names that actually executed (from the
47
+ * task tree).
48
+ *
49
+ * **The file is part of the key, and its absence was a false green.** Keyed by
50
+ * `(reqId, name)` alone, two spec files declaring the same scenario name under
51
+ * the same `requirement()` are one entry, so whichever of them executed
52
+ * vouched for the other and `declared-not-run` stayed silent about a scenario
53
+ * that never ran — the one report this tool exists to refuse. The static plan
54
+ * carries `file` on every `ParsedScenario` and the task tree carries it on
55
+ * every file task, so only this map ever forgot it.
56
+ *
57
+ * Project-relative POSIX, like `unloadedFiles` and for the same reason: it is
58
+ * compared against a plan built that way (`paths.ts`).
59
+ */
60
+ runtimeCoverage: Map<string, Map<string, Set<string>>>;
61
+ /**
62
+ * reqId -> spec file -> scenario name -> how it ended. The same task-tree walk
63
+ * that fills `runtimeCoverage` already had to tell `pass` from `fail` to
64
+ * decide whether a scenario executed at all; this keeps that distinction
65
+ * instead of discarding it, which is what §6's mechanism 2 records.
66
+ *
67
+ * Keyed identically, and for the identical reason: the same collapse reaches
68
+ * `mergeRedRecord`, where a base scenario's recorded `fail` satisfied a
69
+ * same-named proposed scenario's first-red obligation.
41
70
  */
42
- outcomes: Map<string, Map<string, Outcome>>;
71
+ outcomes: Map<string, Map<string, Map<string, Outcome>>>;
43
72
  /**
44
73
  * Spec files that failed to *load*, as project-relative POSIX paths.
45
74
  *
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@am_shork/attest",
3
- "version": "0.7.4",
3
+ "version": "0.8.0",
4
4
  "description": "TDD-native spec framework: tests are the source of truth for verification, ID-bound requirements the source of truth for intent.",
5
5
  "type": "module",
6
6
  "engines": {
@@ -48,7 +48,7 @@
48
48
  "chalk": "^5.3.0",
49
49
  "commander": "^12.1.0",
50
50
  "typescript": "^5.5.0 || ^6.0.0",
51
- "zod": "^3.23.0"
51
+ "zod": "^4.4.3"
52
52
  },
53
53
  "peerDependencies": {
54
54
  "vite": "^8.0.0",