@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.
package/README.md CHANGED
@@ -166,7 +166,7 @@ Every diagnostic carries a `code`, and every code has a section in
166
166
  ```
167
167
  ERROR registry-not-static (requirements/upload.reqs.ts:5)
168
168
  Value is not a literal.
169
- → https://gitlab.com/Pseudorca/attest/-/blob/v0.7.4/docs/en/troubleshooting.md#registry-not-static
169
+ → https://gitlab.com/Pseudorca/attest/-/blob/v0.8.0/docs/en/troubleshooting.md#registry-not-static
170
170
  ```
171
171
 
172
172
  The anchor **is** the code, so the link cannot point somewhere the section
package/dist/cli/index.js CHANGED
@@ -13,7 +13,6 @@
13
13
  import { Command } from 'commander';
14
14
  import chalk from 'chalk';
15
15
  import { resolve } from 'node:path';
16
- import { writeAtomic } from '../core/write.js';
17
16
  import { runCheck, runVerify, runCover, runArchive, runArchiveApply, runInit, runRender, runRenderCheck, runStatus, DEFAULT_TARGET, TARGET_NAMES, } from '../core/pipeline.js';
18
17
  import { hasError } from '../core/types.js';
19
18
  // The one project-derived value this shell still interpolates itself. `--out`
@@ -135,12 +134,16 @@ program
135
134
  if (opts.json && !opts.out && !opts.check) {
136
135
  cmd.error('error: --json requires --out <file> (stdout cannot carry both the document and the report)');
137
136
  }
138
- const dest = opts.out ? resolve(process.cwd(), opts.out) : undefined;
137
+ // Both spellings of the destination, because both are needed downstream: the
138
+ // resolved one is what the filesystem is given, the typed one is what a
139
+ // diagnostic echoes. Where it may point, and the write itself, are the
140
+ // pipeline's — this shell decides what to print and nothing else.
141
+ const out = opts.out ? { file: resolve(process.cwd(), opts.out), display: opts.out } : undefined;
139
142
  return runAction('render', opts, async () => {
140
143
  if (opts.check) {
141
144
  // The fix hint has to be the command the user can actually re-run from
142
145
  // here: their own --out string, and the [dir] they passed.
143
- const issues = await runRenderCheck(root(dir), dest, {
146
+ const issues = await runRenderCheck(root(dir), out.file, {
144
147
  display: opts.out,
145
148
  command: `attest render${dir ? ` ${dir}` : ''} --out ${opts.out}`,
146
149
  }, { evaluate: opts.eval });
@@ -156,10 +159,10 @@ program
156
159
  },
157
160
  };
158
161
  }
159
- const { markdown, issues } = await runRender(root(dir), { evaluate: opts.eval });
160
- // Never overwrite a good document with the output of a broken registry.
161
- if (!hasError(issues) && dest)
162
- await writeAtomic(dest, markdown);
162
+ const { markdown, issues, wrote } = await runRender(root(dir), {
163
+ evaluate: opts.eval,
164
+ out,
165
+ });
163
166
  return {
164
167
  report: renderReport(VERSION, issues, opts.out),
165
168
  human: () => {
@@ -167,7 +170,7 @@ program
167
170
  console.log(formatIssues(issues));
168
171
  console.log(summarize(issues));
169
172
  }
170
- else if (dest) {
173
+ else if (wrote) {
171
174
  console.log(chalk.green(`✓ Wrote ${inline(opts.out ?? '')}`));
172
175
  }
173
176
  else {
@@ -100,6 +100,31 @@ export declare function statusReport(version: string, result: StatusResult): Jso
100
100
  * empty stdout and a stack trace on stderr.
101
101
  */
102
102
  export declare function errorReport(version: string, command: JsonCommand, err: unknown): JsonReport;
103
- /** Serialize a report for stdout. Pretty-printed: still one parseable document. */
103
+ /**
104
+ * Serialize a report for stdout. Pretty-printed: still one parseable document.
105
+ *
106
+ * Every string in it loses its control characters first (design §9.1). The
107
+ * machine surface was recorded as exempt because `JSON.stringify` escapes every
108
+ * C0 character — true, and narrower than the rule: §9.1's class also holds DEL
109
+ * and the C1 range, and `JSON.stringify` emits those verbatim. `U+009B` is
110
+ * 8-bit CSI, the same introducer as `ESC [` on a terminal that reads
111
+ * UTF-8-decoded C1 as control — so a scenario name or a thrown message can
112
+ * drive the terminal of someone reading the JSON rather than parsing it, from
113
+ * `check --json`, which executes nothing.
114
+ *
115
+ * Applied here rather than at the report builders, and that is §9.1's own
116
+ * distinction rather than a preference: this is a *document*, not a stream. It
117
+ * has one point of assembly, before any of it is seen, so an obligation sitting
118
+ * there covers a field nobody has added yet — as `outFiles` and `docsUrl` both
119
+ * were. The terminal path is sanitised at each write site for the opposite
120
+ * reason, recorded in core/terminal.ts.
121
+ *
122
+ * A replacer rather than a walk over the fields that carry project text —
123
+ * `issues[].message`, `issues[].file`, `change`, `outFile`, `outFiles` today —
124
+ * because enumerating them is the arrangement that leaves the next one out. It
125
+ * costs nothing on the fields that cannot carry a control character: `control`
126
+ * allocates when something matches, and nothing does. Non-mutating, because a
127
+ * caller may render the same report for a human afterwards.
128
+ */
104
129
  export declare function renderJson(report: JsonReport): string;
105
130
  //# sourceMappingURL=json.d.ts.map
package/dist/cli/json.js CHANGED
@@ -6,6 +6,7 @@
6
6
  // and every command emits the same envelope (tool/version/command/ok/summary/
7
7
  // issues) plus command-specific fields.
8
8
  import { docsUrl } from '../core/docs.js';
9
+ import { control } from '../core/terminal.js';
9
10
  import { hasError } from '../core/types.js';
10
11
  /** Bumped whenever the emitted shape changes incompatibly. */
11
12
  export const SCHEMA_VERSION = 1;
@@ -156,8 +157,33 @@ export function errorReport(version, command, err) {
156
157
  const issue = { level: 'ERROR', code: 'internal-error', message };
157
158
  return envelope({ version, command, ok: false, issues: [issue] });
158
159
  }
159
- /** Serialize a report for stdout. Pretty-printed: still one parseable document. */
160
+ /**
161
+ * Serialize a report for stdout. Pretty-printed: still one parseable document.
162
+ *
163
+ * Every string in it loses its control characters first (design §9.1). The
164
+ * machine surface was recorded as exempt because `JSON.stringify` escapes every
165
+ * C0 character — true, and narrower than the rule: §9.1's class also holds DEL
166
+ * and the C1 range, and `JSON.stringify` emits those verbatim. `U+009B` is
167
+ * 8-bit CSI, the same introducer as `ESC [` on a terminal that reads
168
+ * UTF-8-decoded C1 as control — so a scenario name or a thrown message can
169
+ * drive the terminal of someone reading the JSON rather than parsing it, from
170
+ * `check --json`, which executes nothing.
171
+ *
172
+ * Applied here rather than at the report builders, and that is §9.1's own
173
+ * distinction rather than a preference: this is a *document*, not a stream. It
174
+ * has one point of assembly, before any of it is seen, so an obligation sitting
175
+ * there covers a field nobody has added yet — as `outFiles` and `docsUrl` both
176
+ * were. The terminal path is sanitised at each write site for the opposite
177
+ * reason, recorded in core/terminal.ts.
178
+ *
179
+ * A replacer rather than a walk over the fields that carry project text —
180
+ * `issues[].message`, `issues[].file`, `change`, `outFile`, `outFiles` today —
181
+ * because enumerating them is the arrangement that leaves the next one out. It
182
+ * costs nothing on the fields that cannot carry a control character: `control`
183
+ * allocates when something matches, and nothing does. Non-mutating, because a
184
+ * caller may render the same report for a human afterwards.
185
+ */
160
186
  export function renderJson(report) {
161
- return JSON.stringify(report, null, 2);
187
+ return JSON.stringify(report, (_key, value) => (typeof value === 'string' ? control(value) : value), 2);
162
188
  }
163
189
  //# sourceMappingURL=json.js.map
package/dist/core/gate.js CHANGED
@@ -16,7 +16,7 @@ import { RED_RECORD_FILE, hasRecordedRed, recordedOutcome, } from './red-record.
16
16
  export function declaredNotRunIssues(plan, run) {
17
17
  const issues = [];
18
18
  for (const s of plan.scenarios) {
19
- if (!run.runtimeCoverage.get(s.reqId)?.has(s.name)) {
19
+ if (!run.runtimeCoverage.get(s.reqId)?.get(s.file)?.has(s.name)) {
20
20
  issues.push({
21
21
  level: 'ERROR',
22
22
  code: 'declared-not-run',
@@ -155,9 +155,9 @@ export function neverRedIssues(plan, addedIds, firstRun) {
155
155
  for (const s of plan.scenarios) {
156
156
  if (!added.has(s.reqId))
157
157
  continue;
158
- if (hasRecordedRed(firstRun, s.reqId, s.name))
158
+ if (hasRecordedRed(firstRun, s))
159
159
  continue;
160
- const outcome = recordedOutcome(firstRun, s.reqId, s.name);
160
+ const outcome = recordedOutcome(firstRun, s);
161
161
  issues.push({
162
162
  level: 'ERROR',
163
163
  code: 'never-red',
@@ -5,7 +5,7 @@ import { basename, join } from 'node:path';
5
5
  import { relativePath } from './paths.js';
6
6
  import { parseSpecFile } from './parser.js';
7
7
  import { declaredIdsFromSource, readRegistrySource } from './static-registry.js';
8
- import { RegistryValidationError } from './registry.js';
8
+ import { registryValidationMessage } from './registry-issues.js';
9
9
  import { RegistrySchema } from './schema.js';
10
10
  import { byCodeUnit } from './order.js';
11
11
  import { SourceNotCompiled } from './compiler.js';
@@ -191,13 +191,13 @@ export function evalReader(loader) {
191
191
  const parsed = RegistrySchema.safeParse(exported);
192
192
  if (!parsed.success) {
193
193
  // The same code, the same prefix and the same rendering of the field
194
- // errors as `readRegistrySource` — going through RegistryValidationError
195
- // keeps the message identical rather than merely similar.
194
+ // errors as `readRegistrySource` — sharing one formatter keeps the
195
+ // message identical rather than merely similar.
196
196
  return {
197
197
  issue: {
198
198
  level: 'ERROR',
199
199
  code: 'registry-invalid',
200
- message: `Failed to load registry: ${new RegistryValidationError(parsed.error.issues).message}`,
200
+ message: `Failed to load registry: ${registryValidationMessage(parsed.error.issues)}`,
201
201
  },
202
202
  };
203
203
  }
@@ -81,6 +81,20 @@ export interface RenderResult {
81
81
  markdown: string;
82
82
  /** Registry-loading issues — rendering a half-loaded registry would lie. */
83
83
  issues: Issue[];
84
+ /** Whether the document reached `out.file`. False whenever `out` is absent. */
85
+ wrote: boolean;
86
+ }
87
+ /**
88
+ * A destination `render` was asked to write, as the two spellings it needs.
89
+ *
90
+ * `file` is resolved against the caller's working directory and is what the
91
+ * filesystem is given; `display` is the string the user typed, and is what a
92
+ * diagnostic has to echo so the path in the report is the path they can act on.
93
+ * They travel together because a destination is not usable without both.
94
+ */
95
+ export interface RenderOut {
96
+ file: string;
97
+ display: string;
84
98
  }
85
99
  /**
86
100
  * Markdown projection of the intent layer (design §9: `attest render`).
@@ -90,7 +104,9 @@ export interface RenderResult {
90
104
  * `*.reqs.ts` files, so a committed rendering can be gated without going stale
91
105
  * every time a line moves in a test file.
92
106
  */
93
- export declare function runRender(root: string, options?: ReadOptions): Promise<RenderResult>;
107
+ export declare function runRender(root: string, options?: ReadOptions & {
108
+ out?: RenderOut | undefined;
109
+ }): Promise<RenderResult>;
94
110
  /**
95
111
  * Freshness gate for a committed rendering (`attest render --check`).
96
112
  *
@@ -14,7 +14,7 @@ import { writeAtomic } from './write.js';
14
14
  import { applyMerge, mergedSpecPath } from './merge.js';
15
15
  import { compilerIssue } from './compiler.js';
16
16
  import { mkdir, readFile, realpath } from 'node:fs/promises';
17
- import { basename, dirname, join } from 'node:path';
17
+ import { basename, dirname, join, resolve } from 'node:path';
18
18
  import { isInside, relativePath } from './paths.js';
19
19
  import { hasError } from './types.js';
20
20
  // The runner half of the engine, reached only when a command actually needs it.
@@ -405,15 +405,38 @@ export async function runCover(root, options = {}) {
405
405
  * every time a line moves in a test file.
406
406
  */
407
407
  export async function runRender(root, options = {}) {
408
+ // The compiler guard stays the first statement, as it is in every other entry
409
+ // point (ATX-56): a TypeScript with no AST API is a refusal about the
410
+ // toolchain, and it outranks anything this run could say about the caller's
411
+ // paths or registry.
408
412
  const unusable = compilerIssue();
409
413
  if (unusable)
410
- return { markdown: '', issues: [unusable] };
414
+ return { markdown: '', issues: [unusable], wrote: false };
415
+ // Then the destination, before the registry is read, so a refused path is the
416
+ // only thing reported: a run that cannot write anywhere has no reason to also
417
+ // tell the user about their registry, and the report a pipeline reads should
418
+ // name the one thing that has to change.
419
+ const dest = options.out ? await resolveOutFile(root, options.out) : undefined;
420
+ if (dest && !dest.ok)
421
+ return { markdown: '', issues: [dest.issue], wrote: false };
411
422
  const { registry, issues } = await readRegistry(root, options);
412
423
  // A registry that failed to load yields a document that silently omits
413
- // requirements. An incomplete spec doc is worse than none, so refuse.
424
+ // requirements. An incomplete spec doc is worse than none, so refuse — and
425
+ // since the write is below this line, refusing is also what keeps a broken
426
+ // registry from overwriting a good committed document.
414
427
  if (hasError(issues))
415
- return { markdown: '', issues };
416
- return { markdown: renderMarkdown(registry), issues };
428
+ return { markdown: '', issues, wrote: false };
429
+ const markdown = renderMarkdown(registry);
430
+ // The write lives here rather than in the CLI, and that is the whole of what
431
+ // ATX-73 is structurally about: while it sat in the shell it was the one
432
+ // `writeAtomic` call outside this layer, and it reached the filesystem
433
+ // without the resolution `runInit` had performed since ATX-66. Resolving and
434
+ // writing in one function is what stops the two from coming apart again —
435
+ // a later caller cannot forget a step it never had to take.
436
+ if (!dest)
437
+ return { markdown, issues, wrote: false };
438
+ await writeAtomic(dest.dest, markdown);
439
+ return { markdown, issues, wrote: true };
417
440
  }
418
441
  /**
419
442
  * Freshness gate for a committed rendering (`attest render --check`).
@@ -423,10 +446,29 @@ export async function runRender(root, options = {}) {
423
446
  * alongside a gate that fails when the file no longer matches its source.
424
447
  */
425
448
  export async function runRenderCheck(root, outFile, target, options = {}) {
449
+ // Called here despite the delegation further down, because the resolution
450
+ // below it runs first: the guard has to be the first statement of an entry
451
+ // point (ATX-56), and delegation cannot supply that once anything precedes
452
+ // it. Same precedence in both, for the reason `runRender` states.
453
+ const unusable = compilerIssue();
454
+ if (unusable)
455
+ return [unusable];
456
+ // Resolved before it is read, not only before it is written. A link along the
457
+ // path makes this compare a fresh document against a file outside the project
458
+ // — and both verdicts that can produce are ones the gate reports normally, so
459
+ // the wrong answer is indistinguishable from a right one.
460
+ //
461
+ // `target` already carries the spelling a diagnostic must echo, so the pair
462
+ // `resolveOutFile` wants is assembled here rather than asked of the caller:
463
+ // taking a `RenderOut` beside a `RenderTarget` would make the shell pass the
464
+ // same string twice, which is a clump waiting to disagree with itself.
465
+ const dest = await resolveOutFile(root, { file: outFile, display: target.display });
466
+ if (!dest.ok)
467
+ return [dest.issue];
426
468
  const { markdown, issues } = await runRender(root, options);
427
469
  if (hasError(issues))
428
470
  return issues;
429
- const current = await readFile(outFile, 'utf8').catch(() => undefined);
471
+ const current = await readFile(dest.dest, 'utf8').catch(() => undefined);
430
472
  const stale = staleIssue(target, current, markdown);
431
473
  return stale ? [...issues, stale] : issues;
432
474
  }
@@ -467,12 +509,7 @@ export async function runInit(root, names) {
467
509
  writes.push({ target, dest: resolved.dest });
468
510
  }
469
511
  else {
470
- refusals.push({
471
- level: 'ERROR',
472
- code: 'unsafe-target-path',
473
- file: target.file,
474
- message: `Refusing to write ${target.file}: it resolves to ${resolved.escape}, outside the project. Remove the link at that path and re-run.`,
475
- });
512
+ refusals.push(unsafeTargetPathIssue(target.file, resolved.escape));
476
513
  }
477
514
  }
478
515
  if (refusals.length > 0)
@@ -485,6 +522,61 @@ export async function runInit(root, names) {
485
522
  }
486
523
  return { files, issues };
487
524
  }
525
+ /**
526
+ * Where a `render` destination really lands, or the refusal that a link
527
+ * redirected it (design §9: ownership of the path is checked, and who named the
528
+ * path decides what the refusal is about).
529
+ *
530
+ * `init` names its own destinations, so every one of them is checked; `--out`
531
+ * is the caller's path, and that is the whole of the difference between the two
532
+ * callers. A path spelled outside the project is the user asking for a file
533
+ * outside the project — `--out ../site/SPEC.md` is a destination, not an
534
+ * escape, and refusing it would be refusing the flag. What is not visible in
535
+ * the argument is a *link* along a path that reads as staying inside, which is
536
+ * the one a checkout can plant: the victim's `--out` is the string their README
537
+ * or CI already documents, and the content redirected is the document their
538
+ * repository wrote.
539
+ *
540
+ * So the check applies exactly when the destination is lexically inside the
541
+ * root, and it is `resolveDest`'s, unchanged. The leaf needs no separate
542
+ * defence and never did — `writeAtomic` lands by `rename`, which replaces a
543
+ * symlink rather than following it — but it is resolved with the rest anyway,
544
+ * because a link there is still a file the user made deliberately and this
545
+ * refuses rather than destroys it.
546
+ *
547
+ * Both entry points that name a destination come through here — the one that
548
+ * writes it and the one that reads it back to date it — which is why this is
549
+ * private to this module rather than something a shell is trusted to call.
550
+ */
551
+ async function resolveOutFile(root, out) {
552
+ const dest = resolve(out.file);
553
+ const realRoot = await realpath(root).catch(() => root);
554
+ // Asked against both spellings of the root, because they differ whenever the
555
+ // project itself sits under a link: `dest` is built from the caller's cwd, so
556
+ // it is lexical, and testing only the resolved root would skip the check for
557
+ // every such checkout — failing open on exactly the layout that has links in
558
+ // it. `''` is the root itself, which is a directory and not a destination.
559
+ const rel = isInside(root, dest)
560
+ ? relativePath(root, dest)
561
+ : isInside(realRoot, dest)
562
+ ? relativePath(realRoot, dest)
563
+ : undefined;
564
+ if (rel === undefined || rel === '')
565
+ return { ok: true, dest };
566
+ const resolved = await resolveDest(realRoot, rel);
567
+ return resolved.ok
568
+ ? { ok: true, dest: resolved.dest }
569
+ : { ok: false, issue: unsafeTargetPathIssue(out.display, resolved.escape) };
570
+ }
571
+ /** The one refusal both destination checks report (design §9). */
572
+ function unsafeTargetPathIssue(display, escape) {
573
+ return {
574
+ level: 'ERROR',
575
+ code: 'unsafe-target-path',
576
+ file: display,
577
+ message: `Refusing to write ${display}: it resolves to ${escape}, outside the project. Remove the link at that path and re-run.`,
578
+ };
579
+ }
488
580
  /**
489
581
  * Where a target's file really goes, or the path outside the project that
490
582
  * asking put it at (design §9: ownership of the path is checked, not assumed).
@@ -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, reqId: string, scenario: string): Outcome | undefined;
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, reqId: string, scenario: string): boolean;
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.
@@ -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
- const firstRun = isRecord(parsed) ? parsed['firstRun'] : undefined;
126
- const result = RedRecordSchema.safeParse(firstRun);
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, outcomes] of Object.entries(parsed)) {
143
- record[id] = Object.assign(emptyMap(), outcomes);
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, reqId, scenario) {
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 outcomes = record[reqId];
160
- if (outcomes === undefined || !Object.hasOwn(outcomes, scenario))
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
- return outcomes[scenario];
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, reqId, scenario) {
174
- return recordedOutcome(record, reqId, scenario) === 'fail';
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 outcomes = existing[id];
194
- if (outcomes === undefined)
238
+ const byFile = existing[id];
239
+ if (byFile === undefined)
195
240
  continue;
196
- record[id] = Object.assign(emptyMap(), outcomes);
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 (forId[s.name] === 'fail' || forId[s.name] === outcome)
264
+ if (forFile[s.name] === 'fail' || forFile[s.name] === outcome)
212
265
  continue;
213
- forId[s.name] = outcome;
266
+ forFile[s.name] = outcome;
214
267
  changed = true;
215
268
  }
216
- // Sort within each requirement for the same byte-stability reason.
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 sorted = emptyMap();
219
- for (const name of Object.keys(record[id]).sort(byCodeUnit)) {
220
- sorted[name] = record[id][name];
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] = sorted;
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: 1, change: changeName, firstRun: record };
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