@am_shork/attest 0.7.3 → 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.
@@ -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
  }
@@ -1,7 +1,7 @@
1
1
  import type { RegistryDelta } from './registry.js';
2
2
  import type { Registry } from './types.js';
3
3
  /** Codes this reader can produce; each is also produced by the evaluating path. */
4
- export type StaticReadCode = 'registry-invalid' | 'registry-no-default' | 'registry-not-static';
4
+ export type StaticReadCode = 'registry-invalid' | 'registry-no-default' | 'registry-not-static' | 'unreadable-file';
5
5
  export type StaticReadResult = {
6
6
  ok: true;
7
7
  registry: Registry;
@@ -13,7 +13,7 @@ export type StaticReadResult = {
13
13
  };
14
14
  export declare function readRegistrySource(file: string, source: string): StaticReadResult;
15
15
  /** Codes the delta reader can produce; both are codes `archive` already emits. */
16
- export type DeltaReadCode = 'change-not-found' | 'registry-not-static';
16
+ export type DeltaReadCode = 'change-not-found' | 'registry-not-static' | 'unreadable-file';
17
17
  export type DeltaReadResult = {
18
18
  ok: true;
19
19
  delta: RegistryDelta;
@@ -39,6 +39,38 @@ export type DeltaReadResult = {
39
39
  * (write the value inline, or `--eval`) is the same sentence either way.
40
40
  */
41
41
  export declare function readDeltaSource(file: string, source: string): DeltaReadResult;
42
+ /**
43
+ * The requirement ids a source *declares*, read from a file that could not be
44
+ * read as a registry (design §5.3).
45
+ *
46
+ * This is not a second registry reader and cannot become one: it returns ids and
47
+ * nothing else, it validates none of them, and no command builds a `Registry`
48
+ * from what it finds. What it exists for is the question `orphan-test` cannot
49
+ * otherwise answer — is this scenario attesting an id that a *broken* file
50
+ * declares, or one that genuinely does not exist — and the answer decides
51
+ * whether a finding is a fact or fallout.
52
+ *
53
+ * It reads the ids rather than the file's id *prefix*, which is what this was
54
+ * first framed as needing. The prefix cannot be recovered from a path: this
55
+ * repository's own registry is `attest.reqs.ts` holding `ATX-*`, which is the
56
+ * evidence that killed the prefix-matches-filename rule (see CHANGELOG,
57
+ * `Considered and rejected`). The ids are in the source, so nothing has to be
58
+ * inferred from a naming convention that nothing enforces.
59
+ *
60
+ * Recall is partial by construction, and the direction of the miss is the point:
61
+ * an id it does not find keeps its `orphan-test`, so the report stays noisy —
62
+ * never wrong. Measured over the failure modes that produce an unreadable
63
+ * registry: a truncated file, a missing default export, a non-literal *value*, a
64
+ * schema-invalid entry, a module that throws at import, and a spread of ids from
65
+ * another module all yield the ids written in this file; only a registry built
66
+ * by a call — `export default buildReqs()` — yields none, and that file contains
67
+ * no id to find.
68
+ *
69
+ * `ts.createSourceFile` is deliberately error-tolerant, which is what lets the
70
+ * first of those cases work at all: the parser recovers an object literal from a
71
+ * file that does not compile.
72
+ */
73
+ export declare function declaredIdsFromSource(file: string, source: string): string[];
42
74
  /**
43
75
  * Where a new entry may be written into a registry file's literal, as an offset
44
76
  * into its source (design §7).
@@ -20,7 +20,9 @@
20
20
  // value is fixed by the source text — a second walker would be a second answer
21
21
  // to "is this a literal", and those two answers must not be able to disagree.
22
22
  import ts from 'typescript';
23
- import { RegistryValidationError, withProposedRequirements } from './registry.js';
23
+ import { parseSource } from './compiler.js';
24
+ import { withProposedRequirements } from './registry.js';
25
+ import { registryValidationMessage } from './registry-issues.js';
24
26
  import { RegistrySchema } from './schema.js';
25
27
  /** The authoring function a registry file must default-export the result of. */
26
28
  const DEFINE = 'defineRequirements';
@@ -45,7 +47,20 @@ class NotStatic extends Error {
45
47
  * has to be run — and only the prose around it differs.
46
48
  */
47
49
  function extractLiteralExport(file, source, fn, noun) {
48
- const sf = ts.createSourceFile(file, source, ts.ScriptTarget.Latest, /* setParentNodes */ true);
50
+ // Strict: a source that does not compile is refused here rather than read out
51
+ // of whatever the parser recovered from it. Before this, a registry truncated
52
+ // before its closing `});` came back as a complete registry.
53
+ const parsed = parseSource(file, source);
54
+ if ('error' in parsed) {
55
+ return {
56
+ ok: false,
57
+ reason: 'syntax',
58
+ message: `This ${noun} does not compile (${parsed.error.message}), so nothing in it was checked. ` +
59
+ `Fix the syntax error and run again.`,
60
+ ...(parsed.error.line === undefined ? {} : { line: parsed.error.line }),
61
+ };
62
+ }
63
+ const sf = parsed.sf;
49
64
  const lineOf = (node) => sf.getLineAndCharacterOfPosition(node.getStart(sf)).line + 1;
50
65
  const exported = defaultExportExpression(sf);
51
66
  if (!exported) {
@@ -91,24 +106,45 @@ function failure(extracted, code) {
91
106
  ...(extracted.line === undefined ? {} : { line: extracted.line }),
92
107
  };
93
108
  }
109
+ /**
110
+ * What this reader calls the three ways extraction fails; `DELTA_CODE` below is
111
+ * the other half.
112
+ *
113
+ * A table per reader rather than a ternary in each, because only one row
114
+ * differs in kind: `syntax` is the same answer for both — a file that does not
115
+ * compile is not a registry problem or a delta problem, it is not a file — and
116
+ * two readers spelling that differently is exactly the disagreement this module
117
+ * opens by refusing to allow.
118
+ */
119
+ const REGISTRY_CODE = {
120
+ syntax: 'unreadable-file',
121
+ 'no-default': 'registry-no-default',
122
+ 'not-static': 'registry-not-static',
123
+ };
94
124
  export function readRegistrySource(file, source) {
95
125
  const extracted = extractLiteralExport(file, source, DEFINE, 'registry');
96
126
  if (!extracted.ok) {
97
- return failure(extracted, extracted.reason === 'no-default' ? 'registry-no-default' : 'registry-not-static');
127
+ return failure(extracted, REGISTRY_CODE[extracted.reason]);
98
128
  }
99
129
  const result = RegistrySchema.safeParse(extracted.value);
100
130
  if (!result.success) {
101
131
  // defineRequirements throws RegistryValidationError and loadRegistry wraps
102
- // it; going through the same error keeps the message identical rather than
103
- // merely similar.
132
+ // it; sharing one formatter keeps the message identical rather than merely
133
+ // similar.
104
134
  return {
105
135
  ok: false,
106
136
  code: 'registry-invalid',
107
- message: `Failed to load registry: ${new RegistryValidationError(result.error.issues).message}`,
137
+ message: `Failed to load registry: ${registryValidationMessage(result.error.issues)}`,
108
138
  };
109
139
  }
110
140
  return { ok: true, registry: result.data };
111
141
  }
142
+ /** The delta reader's half of the table above `readRegistrySource`. */
143
+ const DELTA_CODE = {
144
+ syntax: 'unreadable-file',
145
+ 'no-default': 'change-not-found',
146
+ 'not-static': 'registry-not-static',
147
+ };
112
148
  /**
113
149
  * Read a change's `delta({ … })` from its source (design §7).
114
150
  *
@@ -127,7 +163,7 @@ export function readRegistrySource(file, source) {
127
163
  export function readDeltaSource(file, source) {
128
164
  const extracted = extractLiteralExport(file, source, DELTA, 'delta');
129
165
  if (!extracted.ok) {
130
- return failure(extracted, extracted.reason === 'no-default' ? 'change-not-found' : 'registry-not-static');
166
+ return failure(extracted, DELTA_CODE[extracted.reason]);
131
167
  }
132
168
  const value = extracted.value;
133
169
  if (typeof value !== 'object' || value === null || Array.isArray(value)) {
@@ -148,6 +184,78 @@ export function readDeltaSource(file, source) {
148
184
  // thing that would notice, and it should have nothing to notice.
149
185
  return { ok: true, delta: withProposedRequirements(value) };
150
186
  }
187
+ /**
188
+ * The requirement ids a source *declares*, read from a file that could not be
189
+ * read as a registry (design §5.3).
190
+ *
191
+ * This is not a second registry reader and cannot become one: it returns ids and
192
+ * nothing else, it validates none of them, and no command builds a `Registry`
193
+ * from what it finds. What it exists for is the question `orphan-test` cannot
194
+ * otherwise answer — is this scenario attesting an id that a *broken* file
195
+ * declares, or one that genuinely does not exist — and the answer decides
196
+ * whether a finding is a fact or fallout.
197
+ *
198
+ * It reads the ids rather than the file's id *prefix*, which is what this was
199
+ * first framed as needing. The prefix cannot be recovered from a path: this
200
+ * repository's own registry is `attest.reqs.ts` holding `ATX-*`, which is the
201
+ * evidence that killed the prefix-matches-filename rule (see CHANGELOG,
202
+ * `Considered and rejected`). The ids are in the source, so nothing has to be
203
+ * inferred from a naming convention that nothing enforces.
204
+ *
205
+ * Recall is partial by construction, and the direction of the miss is the point:
206
+ * an id it does not find keeps its `orphan-test`, so the report stays noisy —
207
+ * never wrong. Measured over the failure modes that produce an unreadable
208
+ * registry: a truncated file, a missing default export, a non-literal *value*, a
209
+ * schema-invalid entry, a module that throws at import, and a spread of ids from
210
+ * another module all yield the ids written in this file; only a registry built
211
+ * by a call — `export default buildReqs()` — yields none, and that file contains
212
+ * no id to find.
213
+ *
214
+ * `ts.createSourceFile` is deliberately error-tolerant, which is what lets the
215
+ * first of those cases work at all: the parser recovers an object literal from a
216
+ * file that does not compile.
217
+ */
218
+ export function declaredIdsFromSource(file, source) {
219
+ const sf = ts.createSourceFile(file, source, ts.ScriptTarget.Latest, /* setParentNodes */ true);
220
+ const names = localNames(sf, DEFINE);
221
+ const ids = new Set();
222
+ // Any `defineRequirements({ … })` in the file, not only the exported one: the
223
+ // failure being diagnosed is often that the call is not where it should be —
224
+ // `export const registry = …` is a whole failure mode — and the ids are no
225
+ // less declared for it. Scoped to that call's argument all the same, so an
226
+ // id-shaped key in unrelated data is not mistaken for a requirement.
227
+ const visit = (node) => {
228
+ // `arguments.length > 0` rather than `=== 1`, which is what the reader
229
+ // demands: this runs on files the reader has already refused, so being
230
+ // stricter than it here could only lose ids it is not deciding anything by.
231
+ if (ts.isCallExpression(node) && node.arguments.length > 0 && callsAuthoringFn(node, names, DEFINE)) {
232
+ const arg = unwrap(node.arguments[0]);
233
+ if (ts.isObjectLiteralExpression(arg)) {
234
+ for (const prop of arg.properties) {
235
+ const name = prop.name;
236
+ if (!name)
237
+ continue;
238
+ if (!ts.isStringLiteral(name) && !ts.isIdentifier(name))
239
+ continue;
240
+ if (REQUIREMENT_ID.test(name.text))
241
+ ids.add(name.text);
242
+ }
243
+ }
244
+ }
245
+ ts.forEachChild(node, visit);
246
+ };
247
+ visit(sf);
248
+ return [...ids];
249
+ }
250
+ /**
251
+ * The id shape, as a bare regex rather than through `RequirementIdSchema`.
252
+ *
253
+ * Nothing is being validated here — a key that does not look like an id is
254
+ * simply not one, and there is no author to tell. Kept identical to the schema's
255
+ * pattern by `tests/static-registry.spec.ts`, which is the only place the two
256
+ * spellings can be held together.
257
+ */
258
+ const REQUIREMENT_ID = /^[A-Z]+-\d+$/;
151
259
  /**
152
260
  * The expression a file default-exports, following one level of local binding.
153
261
  *
@@ -191,10 +299,20 @@ function constInitializer(sf, name) {
191
299
  function authoringCall(expr, sf, fn) {
192
300
  if (!ts.isCallExpression(expr) || expr.arguments.length !== 1)
193
301
  return undefined;
194
- const callee = expr.expression;
195
- const named = (ts.isIdentifier(callee) && localNames(sf, fn).has(callee.text)) ||
196
- (ts.isPropertyAccessExpression(callee) && callee.name.text === fn);
197
- return named ? expr.arguments[0] : undefined;
302
+ return callsAuthoringFn(expr, localNames(sf, fn), fn) ? expr.arguments[0] : undefined;
303
+ }
304
+ /**
305
+ * Whether a call is a call of `fn`, by the local names the imports bound to it.
306
+ *
307
+ * One predicate for both readers of this question — the extraction above and the
308
+ * id recovery below — because they must not be able to disagree about what
309
+ * counts as the authoring call. An alias rule taught to one and not the other
310
+ * would make recovery silently miss exactly the files the reader refuses.
311
+ */
312
+ function callsAuthoringFn(call, names, fn) {
313
+ const callee = call.expression;
314
+ return ((ts.isIdentifier(callee) && names.has(callee.text)) ||
315
+ (ts.isPropertyAccessExpression(callee) && callee.name.text === fn));
198
316
  }
199
317
  /** Local names bound to the imported `fn`, including aliases. */
200
318
  function localNames(sf, fn) {
@@ -329,7 +447,15 @@ function numericValue(node) {
329
447
  return value;
330
448
  }
331
449
  export function registryInsertionPoint(file, source) {
332
- const sf = ts.createSourceFile(file, source, ts.ScriptTarget.Latest, /* setParentNodes */ true);
450
+ // The write site, and the one where a recovered AST does damage rather than
451
+ // merely misreports: an offset taken from a file that does not compile would
452
+ // splice a new requirement into it. `undefined` is this function's documented
453
+ // refusal to guess, and a source that will not parse is the clearest case of
454
+ // one there is.
455
+ const parsed = parseSource(file, source);
456
+ if ('error' in parsed)
457
+ return undefined;
458
+ const sf = parsed.sf;
333
459
  const exported = defaultExportExpression(sf);
334
460
  if (!exported)
335
461
  return undefined;
@@ -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
  *
@@ -1,3 +1,4 @@
1
+ import type { UnreadableRegistry } from './locate.js';
1
2
  import type { AttestPlan, Issue, ParamRef, Registry } from './types.js';
2
3
  /**
3
4
  * Uncovered requirements: intent exists but no scenario attests it (design §5.3).
@@ -20,11 +21,15 @@ export declare function uncoveredIssues(registry: Registry, plan: AttestPlan): I
20
21
  * and one WARNING:
21
22
  * - rationale-placeholder: a `{name}` in a rationale, which is never interpolated
22
23
  *
23
- * `registryIncomplete` says that at least one `*.reqs.ts` failed to load, so
24
- * `registry` is known to be missing whatever was in it. It changes no verdict —
25
- * only what `orphan-test` advises, for the reason below.
24
+ * and one more WARNING, which exists only when a registry file failed to load:
25
+ * - orphan-from-failed-registry: the scenarios attesting ids that file declares
26
+ *
27
+ * `unreadable` is the `*.reqs.ts` files that failed to load, each with the ids
28
+ * its source still names (see `loadRegistry`). It changes no verdict — only
29
+ * which findings are stated per scenario and which are stated once, for the
30
+ * reason below.
26
31
  */
27
- export declare function validateStructure(registry: Registry, plan: AttestPlan, registryIncomplete?: boolean): Issue[];
32
+ export declare function validateStructure(registry: Registry, plan: AttestPlan, unreadable?: readonly UnreadableRegistry[]): Issue[];
28
33
  /**
29
34
  * Weak anti-drift heuristic (design §6, mechanism 3). Evaluated per requirement,
30
35
  * not per scenario: a requirement that owns params is quiet as soon as *any* one