@am_shork/attest 0.4.1 → 0.4.2

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.
Files changed (76) hide show
  1. package/CHANGELOG.md +835 -225
  2. package/README.md +9 -1
  3. package/dist/cli/index.js +8 -4
  4. package/dist/cli/report.d.ts +20 -0
  5. package/dist/cli/report.js +25 -51
  6. package/dist/core/apply.js +49 -1
  7. package/dist/core/docs.d.ts +1 -1
  8. package/dist/core/docs.js +1 -0
  9. package/dist/core/loader.d.ts +22 -0
  10. package/dist/core/loader.js +35 -1
  11. package/dist/core/pipeline.js +2 -3
  12. package/dist/core/red-record.d.ts +9 -1
  13. package/dist/core/red-record.js +67 -9
  14. package/dist/core/runner.d.ts +11 -0
  15. package/dist/core/runner.js +12 -1
  16. package/dist/core/schema.d.ts +12 -0
  17. package/dist/core/schema.js +15 -1
  18. package/dist/core/skill.js +13 -2
  19. package/dist/core/terminal.d.ts +24 -0
  20. package/dist/core/terminal.js +59 -0
  21. package/dist/core/write.js +37 -5
  22. package/package.json +5 -3
  23. package/dist/cli/index.d.ts.map +0 -1
  24. package/dist/cli/index.js.map +0 -1
  25. package/dist/cli/json.d.ts.map +0 -1
  26. package/dist/cli/json.js.map +0 -1
  27. package/dist/cli/report.d.ts.map +0 -1
  28. package/dist/cli/report.js.map +0 -1
  29. package/dist/core/apply.d.ts.map +0 -1
  30. package/dist/core/apply.js.map +0 -1
  31. package/dist/core/docs.d.ts.map +0 -1
  32. package/dist/core/docs.js.map +0 -1
  33. package/dist/core/gate.d.ts.map +0 -1
  34. package/dist/core/gate.js.map +0 -1
  35. package/dist/core/loader.d.ts.map +0 -1
  36. package/dist/core/loader.js.map +0 -1
  37. package/dist/core/locate.d.ts.map +0 -1
  38. package/dist/core/locate.js.map +0 -1
  39. package/dist/core/order.d.ts.map +0 -1
  40. package/dist/core/order.js.map +0 -1
  41. package/dist/core/parser.d.ts.map +0 -1
  42. package/dist/core/parser.js.map +0 -1
  43. package/dist/core/paths.d.ts.map +0 -1
  44. package/dist/core/paths.js.map +0 -1
  45. package/dist/core/pipeline.d.ts.map +0 -1
  46. package/dist/core/pipeline.js.map +0 -1
  47. package/dist/core/red-record.d.ts.map +0 -1
  48. package/dist/core/red-record.js.map +0 -1
  49. package/dist/core/registry.d.ts.map +0 -1
  50. package/dist/core/registry.js.map +0 -1
  51. package/dist/core/render.d.ts.map +0 -1
  52. package/dist/core/render.js.map +0 -1
  53. package/dist/core/runner.d.ts.map +0 -1
  54. package/dist/core/runner.js.map +0 -1
  55. package/dist/core/schema.d.ts.map +0 -1
  56. package/dist/core/schema.js.map +0 -1
  57. package/dist/core/skill.d.ts.map +0 -1
  58. package/dist/core/skill.js.map +0 -1
  59. package/dist/core/static-registry.d.ts.map +0 -1
  60. package/dist/core/static-registry.js.map +0 -1
  61. package/dist/core/status.d.ts.map +0 -1
  62. package/dist/core/status.js.map +0 -1
  63. package/dist/core/targets.d.ts.map +0 -1
  64. package/dist/core/targets.js.map +0 -1
  65. package/dist/core/types.d.ts.map +0 -1
  66. package/dist/core/types.js.map +0 -1
  67. package/dist/core/validator.d.ts.map +0 -1
  68. package/dist/core/validator.js.map +0 -1
  69. package/dist/core/version.d.ts.map +0 -1
  70. package/dist/core/version.js.map +0 -1
  71. package/dist/core/write.d.ts.map +0 -1
  72. package/dist/core/write.js.map +0 -1
  73. package/dist/index.d.ts.map +0 -1
  74. package/dist/index.js.map +0 -1
  75. package/dist/runtime.d.ts.map +0 -1
  76. package/dist/runtime.js.map +0 -1
package/README.md CHANGED
@@ -32,6 +32,14 @@ narrowed by 4, and ATX-36 — reading a registry opens no listening socket — i
32
32
  asserted against the Vite that is installed, so on any other major it is a claim
33
33
  rather than a measurement. If you are on Vitest 2 or 3, stay on `0.3.x`.
34
34
 
35
+ The TypeScript compiler is a **bundled dependency**, not a peer — Attest reads
36
+ your registries and specs through the compiler API, and `typescript@7` no longer
37
+ exposes one (its AST moved behind `typescript/unstable/*`). So the supported
38
+ range is `^5.5.0 || ^6.0.0`, both ends run in CI, and your own compiler is not
39
+ involved: on a project already using TypeScript 5 or 6 the two resolve to one
40
+ copy, and on TypeScript 7 you will simply have a second one that only Attest
41
+ uses. Your project's TypeScript version is yours to choose either way.
42
+
35
43
  ## Getting started
36
44
 
37
45
  **Install** (the framework plus its `vitest` + `vite` peers):
@@ -128,7 +136,7 @@ Every diagnostic carries a `code`, and every code has a section in
128
136
  ```
129
137
  ERROR registry-not-static (requirements/upload.reqs.ts:5)
130
138
  Value is not a literal.
131
- → https://gitlab.com/Pseudorca/attest/-/blob/v0.4.1/docs/en/troubleshooting.md#registry-not-static
139
+ → https://gitlab.com/Pseudorca/attest/-/blob/v0.4.2/docs/en/troubleshooting.md#registry-not-static
132
140
  ```
133
141
 
134
142
  The anchor **is** the code, so the link cannot point somewhere the section
package/dist/cli/index.js CHANGED
@@ -13,7 +13,7 @@ import { resolve } from 'node:path';
13
13
  import { writeAtomic } from '../core/write.js';
14
14
  import { runCheck, runVerify, runCover, runArchive, runInit, runRender, runRenderCheck, runStatus, DEFAULT_TARGET, TARGET_NAMES, } from '../core/pipeline.js';
15
15
  import { hasError } from '../core/types.js';
16
- import { formatIssues, formatScope, summarize, formatCoverage, formatStatus, } from './report.js';
16
+ import { formatCrash, formatIssues, formatScope, summarize, formatCoverage, formatStatus, } from './report.js';
17
17
  import { archiveReport, checkReport, coverReport, errorReport, initReport, renderJson, renderReport, statusReport, verifyReport, } from './json.js';
18
18
  import { packageVersion } from '../core/version.js';
19
19
  const VERSION = packageVersion();
@@ -60,10 +60,12 @@ async function runAction(command, opts, action) {
60
60
  }
61
61
  catch (err) {
62
62
  if (opts.json) {
63
+ // Unsanitised on purpose: `JSON.stringify` escapes every C0 character, so
64
+ // the machine surface was never the exposed one (see report.ts).
63
65
  console.log(renderJson(errorReport(VERSION, command, err)));
64
66
  }
65
67
  else {
66
- console.error(chalk.red(err instanceof Error ? err.stack ?? err.message : String(err)));
68
+ console.error(formatCrash(err));
67
69
  }
68
70
  process.exitCode = 1;
69
71
  }
@@ -284,8 +286,10 @@ program
284
286
  }));
285
287
  program.parseAsync().catch((err) => {
286
288
  // Last resort for failures outside a command action (e.g. arg parsing), which
287
- // are not part of the per-command `--json` contract.
288
- console.error(chalk.red(err instanceof Error ? err.stack ?? err.message : String(err)));
289
+ // are not part of the per-command `--json` contract. Through the same
290
+ // formatter as the per-command path: this one is reached with a project root
291
+ // already resolved, so it is no less able to carry the repository's text.
292
+ console.error(formatCrash(err));
289
293
  process.exitCode = 1;
290
294
  });
291
295
  //# sourceMappingURL=index.js.map
@@ -1,5 +1,25 @@
1
1
  import type { CoverageRow, StatusResult, VerifyCounts } from '../core/pipeline.js';
2
2
  import type { Issue } from '../core/types.js';
3
+ /**
4
+ * A caught error, as it may be written to a terminal.
5
+ *
6
+ * The crash path was the hole in the guarantee above, and the least obvious
7
+ * place for one: every diagnostic went through `block`, and then the two
8
+ * `catch` blocks in the CLI printed `err.stack` raw. Under `verify`, `archive`
9
+ * or `--eval` that stack is not Attest's prose — a module from the repository
10
+ * under test threw it, so its message is that repository's text, arriving at
11
+ * the reviewer's terminal with nothing removed. The path a hostile registry
12
+ * reaches most easily is the one where it throws.
13
+ *
14
+ * `block`, not a rule of its own: a stack is exactly what that function is for
15
+ * — a multi-line message whose continuations must not be able to occupy the
16
+ * column a real header sits in. Frames indent by four more spaces than they
17
+ * already do, which is the whole cost.
18
+ *
19
+ * Here rather than in the CLI because this file is where the decision lives.
20
+ * Two `catch` blocks with a copy each is how the first one came to be missed.
21
+ */
22
+ export declare function formatCrash(err: unknown): string;
3
23
  export declare function formatIssues(issues: Issue[]): string;
4
24
  /**
5
25
  * A code repeating across files is one rule and many sites, and a reader looking
@@ -3,63 +3,15 @@
3
3
  import chalk from 'chalk';
4
4
  import { docsUrl } from '../core/docs.js';
5
5
  import { byCodeUnit } from '../core/order.js';
6
+ import { INDENT, block, inline } from '../core/terminal.js';
6
7
  const LEVEL_TAG = {
7
8
  ERROR: (s) => chalk.red.bold(s),
8
9
  WARNING: (s) => chalk.yellow.bold(s),
9
10
  INFO: (s) => chalk.blueBright(s),
10
11
  };
11
12
  const ORDER = ['ERROR', 'WARNING', 'INFO'];
12
- /** The one control character a report keeps: see `block`. */
13
- const NEWLINE = 0x0a;
14
- /** How far a message is indented under its header line. */
15
- const INDENT = ' ';
16
- /**
17
- * Text from the project, on its way to a terminal.
18
- *
19
- * Most of what a report prints is Attest's own prose, but the parts a reader
20
- * actually navigates by are not: a scenario name and a file path are quoted
21
- * verbatim out of the repository under test (`orphan-test`, `declared-not-run`,
22
- * `never-red`), and the parser hands back the *cooked* string, so an ESC
23
- * written as an escape in a spec file is a real control byte by the time it
24
- * arrives here.
25
- *
26
- * That makes the human report a place where the repository being checked can
27
- * write to the terminal of whoever is checking it. On a fork MR the author is
28
- * not the reviewer, and the payload is a scenario name: erase the lines above,
29
- * repaint a red verdict green, rewrite the window title. The `--json` path was
30
- * never exposed — `JSON.stringify` escapes every C0 character — which is
31
- * precisely why this had to be fixed on the side people read.
32
- *
33
- * A newline survives, because one diagnostic is legitimately multi-line:
34
- * `registry-invalid` carries Zod's list of field errors, and flattening it
35
- * would cost a real reader a real thing to save a hypothetical one. It is
36
- * re-indented instead, so injected text lands inside the message block rather
37
- * than at column 0 where a forged `ERROR ...` header would be indistinguishable
38
- * from a real one.
39
- */
40
- function block(text) {
41
- return control(text).replaceAll('\n', `\n${INDENT}`);
42
- }
43
- /** The same, for text that sits inline in a header and must stay one line. */
44
- function inline(text) {
45
- return control(text).replaceAll('\n', ' ');
46
- }
47
- /**
48
- * Every C0 control except the newline, plus DEL and the C1 range, as a space.
49
- *
50
- * A space rather than deletion: removing the byte would silently splice
51
- * `atte` + `st` into a word that was never in the file, and a diagnostic that
52
- * quietly rewrites what it quotes is its own kind of wrong.
53
- */
54
- function control(text) {
55
- return [...text]
56
- .map((ch) => {
57
- const c = ch.codePointAt(0);
58
- const isControl = (c < 0x20 && c !== NEWLINE) || (c >= 0x7f && c <= 0x9f);
59
- return isControl ? ' ' : ch;
60
- })
61
- .join('');
62
- }
13
+ // `block` / `inline` live in core/terminal.ts: the loader has to make the same
14
+ // decision about Vite's log output and cannot import from this layer.
63
15
  /**
64
16
  * The line under a message pointing at the section that explains the code.
65
17
  *
@@ -73,6 +25,28 @@ function docsLine(issue) {
73
25
  const url = issue.docsUrl ?? docsUrl(issue.code);
74
26
  return url ? `\n${INDENT}${chalk.dim(`→ ${url}`)}` : '';
75
27
  }
28
+ /**
29
+ * A caught error, as it may be written to a terminal.
30
+ *
31
+ * The crash path was the hole in the guarantee above, and the least obvious
32
+ * place for one: every diagnostic went through `block`, and then the two
33
+ * `catch` blocks in the CLI printed `err.stack` raw. Under `verify`, `archive`
34
+ * or `--eval` that stack is not Attest's prose — a module from the repository
35
+ * under test threw it, so its message is that repository's text, arriving at
36
+ * the reviewer's terminal with nothing removed. The path a hostile registry
37
+ * reaches most easily is the one where it throws.
38
+ *
39
+ * `block`, not a rule of its own: a stack is exactly what that function is for
40
+ * — a multi-line message whose continuations must not be able to occupy the
41
+ * column a real header sits in. Frames indent by four more spaces than they
42
+ * already do, which is the whole cost.
43
+ *
44
+ * Here rather than in the CLI because this file is where the decision lives.
45
+ * Two `catch` blocks with a copy each is how the first one came to be missed.
46
+ */
47
+ export function formatCrash(err) {
48
+ return chalk.red(block(err instanceof Error ? err.stack ?? err.message : String(err)));
49
+ }
76
50
  export function formatIssues(issues) {
77
51
  if (issues.length === 0)
78
52
  return chalk.green('✓ No issues.');
@@ -2,7 +2,7 @@
2
2
  // OpenSpec's specs-apply: RENAMED -> REMOVED -> MODIFIED -> ADDED over a
3
3
  // Map<id, Requirement>, with content-compare on ADDED and already-synced
4
4
  // no-ops on RENAMED.
5
- import { RequirementSchema } from './schema.js';
5
+ import { RequirementIdSchema, RequirementSchema } from './schema.js';
6
6
  import { byCodeUnit } from './order.js';
7
7
  /**
8
8
  * The ids a delta ADDs — the scope of the first-red obligation (design §6).
@@ -24,6 +24,18 @@ export function applyDelta(base, d) {
24
24
  const issues = [];
25
25
  // 1) RENAMED
26
26
  for (const { from, to } of d.renamed ?? []) {
27
+ // The target before anything else, because it is an id this delta
28
+ // *introduces* — the same thing an ADDED key is, and held to the same
29
+ // grammar by the same function. Checked ahead of the source so a delta
30
+ // naming an impossible target is diagnosed as that, rather than as whatever
31
+ // the source happens to be: `rename-source-missing` would send its reader to
32
+ // look for a requirement when the thing to fix is on the other side of the
33
+ // arrow.
34
+ const badTarget = introducedIdIssue(to, 'rename-target-invalid', `Rename target "${to}"`);
35
+ if (badTarget) {
36
+ issues.push(badTarget);
37
+ continue;
38
+ }
27
39
  if (map.has(from)) {
28
40
  if (map.has(to)) {
29
41
  issues.push(err('rename-target-exists', `Rename "${from}" -> "${to}" failed: "${to}" already exists.`, to));
@@ -64,6 +76,12 @@ export function applyDelta(base, d) {
64
76
  }
65
77
  // 4) ADDED (content-compare on collision)
66
78
  for (const [id, value] of Object.entries(d.added ?? {})) {
79
+ // The id, before the requirement. See `introducedIdIssue`.
80
+ const badId = introducedIdIssue(id, 'add-invalid', `Added requirement "${id}"`);
81
+ if (badId) {
82
+ issues.push(badId);
83
+ continue;
84
+ }
67
85
  const parsed = RequirementSchema.safeParse(value);
68
86
  if (!parsed.success) {
69
87
  issues.push(err('add-invalid', `Added requirement "${id}" is invalid: ${firstMessage(parsed.error)}`, id));
@@ -81,6 +99,36 @@ export function applyDelta(base, d) {
81
99
  }
82
100
  return { registry: Object.fromEntries(map), issues };
83
101
  }
102
+ /**
103
+ * The one place that decides whether a delta may bring an id into the registry.
104
+ *
105
+ * A delta introduces an id in exactly two ways — as an ADDED key, and as the
106
+ * target of a RENAME — and neither was checked: `applyDelta` validated the
107
+ * requirement a delta adds and never the key it files it under, so the grammar
108
+ * `defineRequirements` holds every registry key to did not apply on the one
109
+ * other path that also creates them. The mild form is an inconsistency, and an
110
+ * expensive one: a change could archive under an id nobody could then write
111
+ * into the `*.reqs.ts` the change is supposed to merge into, so the gate passed
112
+ * on an end state the intent layer cannot represent.
113
+ *
114
+ * One function rather than a check at each site, for the reason `hasError` is
115
+ * one function and `declaredNotRunIssues` was extracted from the two commands
116
+ * that had a copy each: this is a single rule about a single thing, and two
117
+ * spellings of a grammar are two grammars the moment one of them is edited. The
118
+ * `code` and the noun differ because the reader's next move does — one sends
119
+ * them to a key in `added`, the other to the right-hand side of an arrow — and
120
+ * that is the whole of the difference.
121
+ *
122
+ * `prefix` is the caller's, not built here: it is the part that names the site,
123
+ * and composing it from a noun and an id would put the message's shape in the
124
+ * one place that must not care which site it is serving.
125
+ */
126
+ function introducedIdIssue(id, code, prefix) {
127
+ const parsed = RequirementIdSchema.safeParse(id);
128
+ if (parsed.success)
129
+ return undefined;
130
+ return err(code, `${prefix} is invalid: ${firstMessage(parsed.error)}`, id);
131
+ }
84
132
  function err(code, message, reqId) {
85
133
  return { level: 'ERROR', code, ...(reqId ? { reqId } : {}), message };
86
134
  }
@@ -8,7 +8,7 @@
8
8
  * and the `##` headings of both language documents, so landing here cannot
9
9
  * produce a dead link.
10
10
  */
11
- export declare const ISSUE_CODES: readonly ["add-conflict", "add-invalid", "change-not-found", "declared-not-run", "duplicate-prefix", "duplicate-requirement", "empty-spec", "internal-error", "invalid-change-name", "missing-spec-doc", "modify-invalid", "modify-missing", "never-red", "orphan-test", "possible-drift", "rationale-placeholder", "registry-invalid", "registry-no-default", "registry-not-static", "rename-source-missing", "rename-target-exists", "stale-spec-doc", "tests-red", "unbound-param", "uncovered-requirement", "unknown-target"];
11
+ export declare const ISSUE_CODES: readonly ["add-conflict", "add-invalid", "change-not-found", "declared-not-run", "duplicate-prefix", "duplicate-requirement", "empty-spec", "internal-error", "invalid-change-name", "missing-spec-doc", "modify-invalid", "modify-missing", "never-red", "orphan-test", "possible-drift", "rationale-placeholder", "registry-invalid", "registry-no-default", "registry-not-static", "rename-source-missing", "rename-target-exists", "rename-target-invalid", "stale-spec-doc", "tests-red", "unbound-param", "uncovered-requirement", "unknown-target"];
12
12
  export type IssueCode = (typeof ISSUE_CODES)[number];
13
13
  /**
14
14
  * The page explaining `code`, or `undefined` when nothing explains it.
package/dist/core/docs.js CHANGED
@@ -40,6 +40,7 @@ export const ISSUE_CODES = [
40
40
  'registry-not-static',
41
41
  'rename-source-missing',
42
42
  'rename-target-exists',
43
+ 'rename-target-invalid',
43
44
  'stale-spec-doc',
44
45
  'tests-red',
45
46
  'unbound-param',
@@ -1,3 +1,25 @@
1
+ import { type Logger } from 'vite';
2
+ /**
3
+ * Vite's logger, with everything it prints put through Attest's sanitiser
4
+ * (ATX-37).
5
+ *
6
+ * `logLevel: 'error'` silences the chatter and not the errors, and an error
7
+ * here is the one message guaranteed to carry the project's own text: when a
8
+ * `*.reqs.ts` throws, Vite prints `Error when evaluating SSR module …: <the
9
+ * message>` straight to stderr, before Attest ever sees the exception. So the
10
+ * `registry-invalid` diagnostic built from that same error was sanitised while
11
+ * the raw bytes had already gone past it — a forged `ERROR` header at column 0
12
+ * and a `\r` to erase the line above, from a file the reviewer only ran.
13
+ *
14
+ * The whole message is treated as untrusted, which costs Vite its own colour:
15
+ * by the time it arrives the project's text has already been interpolated into
16
+ * it, so there is no half to trust. Losing colour on the one path where a
17
+ * registry threw is not a price worth arguing about.
18
+ *
19
+ * Exported for its own scenario. The logger a live server holds is not
20
+ * reachable from outside, and a guarantee nothing can fail is not a guarantee.
21
+ */
22
+ export declare function sanitisedLogger(base?: Logger): Logger;
1
23
  export interface Loader {
2
24
  load<T = unknown>(absPath: string): Promise<T>;
3
25
  close(): Promise<void>;
@@ -6,11 +6,41 @@
6
6
  // requirement()/scenario(), but the `attest` barrel transitively imports
7
7
  // runtime.ts (which imports vitest), and importing vitest outside a test run
8
8
  // throws. The stub sidesteps that without affecting any read value.
9
- import { createServer } from 'vite';
9
+ import { createLogger, createServer } from 'vite';
10
10
  import { mkdtempSync, writeFileSync } from 'node:fs';
11
11
  import { rm } from 'node:fs/promises';
12
12
  import { tmpdir } from 'node:os';
13
13
  import { join } from 'node:path';
14
+ import { block } from './terminal.js';
15
+ /**
16
+ * Vite's logger, with everything it prints put through Attest's sanitiser
17
+ * (ATX-37).
18
+ *
19
+ * `logLevel: 'error'` silences the chatter and not the errors, and an error
20
+ * here is the one message guaranteed to carry the project's own text: when a
21
+ * `*.reqs.ts` throws, Vite prints `Error when evaluating SSR module …: <the
22
+ * message>` straight to stderr, before Attest ever sees the exception. So the
23
+ * `registry-invalid` diagnostic built from that same error was sanitised while
24
+ * the raw bytes had already gone past it — a forged `ERROR` header at column 0
25
+ * and a `\r` to erase the line above, from a file the reviewer only ran.
26
+ *
27
+ * The whole message is treated as untrusted, which costs Vite its own colour:
28
+ * by the time it arrives the project's text has already been interpolated into
29
+ * it, so there is no half to trust. Losing colour on the one path where a
30
+ * registry threw is not a price worth arguing about.
31
+ *
32
+ * Exported for its own scenario. The logger a live server holds is not
33
+ * reachable from outside, and a guarantee nothing can fail is not a guarantee.
34
+ */
35
+ export function sanitisedLogger(base = createLogger('error')) {
36
+ return {
37
+ ...base,
38
+ info: (msg, opts) => base.info(block(msg), opts),
39
+ warn: (msg, opts) => base.warn(block(msg), opts),
40
+ warnOnce: (msg, opts) => base.warnOnce(block(msg), opts),
41
+ error: (msg, opts) => base.error(block(msg), opts),
42
+ };
43
+ }
14
44
  const VITEST_STUB = 'export const describe=()=>{};export const it=()=>{};export const test=()=>{};' +
15
45
  'export const expect=()=>{};export const beforeAll=()=>{};export const afterAll=()=>{};' +
16
46
  'export const beforeEach=()=>{};export const afterEach=()=>{};export const vi={};' +
@@ -22,6 +52,10 @@ export async function createLoader() {
22
52
  const server = await createServer({
23
53
  configFile: false,
24
54
  logLevel: 'error',
55
+ // Not just quiet — sanitised. See `sanitisedLogger`: what survives
56
+ // `logLevel: 'error'` is exactly the message that carries the checked
57
+ // project's own text.
58
+ customLogger: sanitisedLogger(),
25
59
  appType: 'custom',
26
60
  // `ws: false` is load-bearing, and `middlewareMode` is not enough on its
27
61
  // own: it suppresses the HTTP server but Vite still starts the HMR
@@ -4,7 +4,7 @@ import { createLoader } from './loader.js';
4
4
  import { evalReader, loadRegistry, parseSpecs, parseChangeSpecs, listChangeNames, scanProject, staticReader, } from './locate.js';
5
5
  import { validateStructure, detectPotentialDrift, uncoveredIssues } from './validator.js';
6
6
  import { byCodeUnit } from './order.js';
7
- import { runAndCollect } from './runner.js';
7
+ import { runAndCollect, BASE_EXCLUDE } from './runner.js';
8
8
  import { applyDelta, addedIds } from './apply.js';
9
9
  import { readDeltaSource } from './static-registry.js';
10
10
  import { statusRows, statusCounts } from './status.js';
@@ -16,7 +16,6 @@ import { writeAtomic } from './write.js';
16
16
  import { mkdir, readFile } from 'node:fs/promises';
17
17
  import { basename, dirname, join } from 'node:path';
18
18
  import { relativePath } from './paths.js';
19
- import { configDefaults } from 'vitest/config';
20
19
  import { hasError } from './types.js';
21
20
  /**
22
21
  * Read every registry under root with the reader `options` asks for, and close
@@ -400,7 +399,7 @@ export async function runArchive(root, changeName, options = {}) {
400
399
  // Run base specs + this change's specs; exclude other proposals and archive.
401
400
  const others = (await listChangeNames(root)).filter((n) => n !== changeName);
402
401
  const exclude = [
403
- ...configDefaults.exclude,
402
+ ...BASE_EXCLUDE,
404
403
  '**/archive/**',
405
404
  ...changeExcludeGlobs(others),
406
405
  ];
@@ -25,7 +25,15 @@ export type RedRecord = Record<string, Record<string, Outcome>>;
25
25
  * the whole one.
26
26
  */
27
27
  export declare function readRedRecord(root: string, changeName: string): Promise<RedRecord>;
28
- /** What the record says about one scenario, or undefined if it never saw it. */
28
+ /**
29
+ * What the record says about one scenario, or undefined if it never saw it.
30
+ *
31
+ * Own properties only, at both levels. The containers this module builds have
32
+ * no prototype, so for those this is belt and braces — but `RedRecord` is an
33
+ * ordinary structural type and this function is the gate's single reader, so
34
+ * what it must not do is answer from an inherited key whoever constructed the
35
+ * object it was handed. `record[reqId]?.[scenario]` did exactly that.
36
+ */
29
37
  export declare function recordedOutcome(record: RedRecord, reqId: string, scenario: string): Outcome | undefined;
30
38
  /**
31
39
  * Whether this scenario has satisfied the first-red obligation (design §6).
@@ -47,6 +47,30 @@ 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
+ /**
51
+ * **The record is a map, so it is built without a prototype.**
52
+ *
53
+ * Its keys are requirement ids and scenario names — the first from a delta, the
54
+ * second from a spec file — and on a plain object some of those strings are not
55
+ * keys at all. Two things followed from that, and both are about the gate:
56
+ *
57
+ * - `record[id] ??= {}` never fired for `__proto__`, because reading it yields
58
+ * `Object.prototype` rather than `undefined`. The outcome was then written
59
+ * onto the shared prototype of every object in the process.
60
+ * - A record read off disk carrying that key re-pointed the *record's* own
61
+ * prototype, so `hasRecordedRed` answered `true` for a requirement the file
62
+ * said nothing about — a never-red obligation satisfied by a file that
63
+ * serialises back as `{}`. Evidence that can be inherited is not evidence.
64
+ *
65
+ * Prototype-free rather than a check for the few key names that misbehave: a
66
+ * blocklist is a second answer to "is this a data key", and the container not
67
+ * having a prototype is the first one. apply.ts holds the other end by refusing
68
+ * such an id in the delta; neither is allowed to rely on the other, because
69
+ * `status` reads `addedIds` without applying the delta at all.
70
+ */
71
+ function emptyMap() {
72
+ return Object.create(null);
73
+ }
50
74
  /** Where the record lives, relative to the project root. */
51
75
  export function redRecordPath(root, changeName) {
52
76
  return join(root, 'changes', changeName, RED_RECORD_FILE);
@@ -87,24 +111,55 @@ export async function readRedRecord(root, changeName) {
87
111
  raw = await readFile(redRecordPath(root, changeName), 'utf8');
88
112
  }
89
113
  catch {
90
- return {};
114
+ return emptyMap();
91
115
  }
92
116
  let parsed;
93
117
  try {
94
118
  parsed = JSON.parse(raw);
95
119
  }
96
120
  catch {
97
- return {};
121
+ return emptyMap();
98
122
  }
99
123
  // `JSON.parse` yields `any`; nothing about the file is known until the schema
100
124
  // says so, including whether it is an object at all (`JSON.parse('null')`).
101
125
  const firstRun = isRecord(parsed) ? parsed['firstRun'] : undefined;
102
126
  const result = RedRecordSchema.safeParse(firstRun);
103
- return result.success ? result.data : {};
127
+ return result.success ? adopt(result.data) : emptyMap();
104
128
  }
105
- /** What the record says about one scenario, or undefined if it never saw it. */
129
+ /**
130
+ * Re-home a parsed record into containers this module owns.
131
+ *
132
+ * The validator decides what the *values* may be; this decides what the
133
+ * container is, and the two are separate on purpose. `JSON.parse` makes
134
+ * `__proto__` an ordinary own property, and whether a schema library then
135
+ * carries it through, drops it, or assigns it onto a plain object — swapping
136
+ * that object's prototype — is an implementation detail of the library. The
137
+ * gate cannot rest on one, so the record is copied into a map that has no
138
+ * prototype for such a key to reach.
139
+ */
140
+ function adopt(parsed) {
141
+ const record = emptyMap();
142
+ for (const [id, outcomes] of Object.entries(parsed)) {
143
+ record[id] = Object.assign(emptyMap(), outcomes);
144
+ }
145
+ return record;
146
+ }
147
+ /**
148
+ * What the record says about one scenario, or undefined if it never saw it.
149
+ *
150
+ * Own properties only, at both levels. The containers this module builds have
151
+ * no prototype, so for those this is belt and braces — but `RedRecord` is an
152
+ * ordinary structural type and this function is the gate's single reader, so
153
+ * what it must not do is answer from an inherited key whoever constructed the
154
+ * object it was handed. `record[reqId]?.[scenario]` did exactly that.
155
+ */
106
156
  export function recordedOutcome(record, reqId, scenario) {
107
- return record[reqId]?.[scenario];
157
+ if (!Object.hasOwn(record, reqId))
158
+ return undefined;
159
+ const outcomes = record[reqId];
160
+ if (outcomes === undefined || !Object.hasOwn(outcomes, scenario))
161
+ return undefined;
162
+ return outcomes[scenario];
108
163
  }
109
164
  /**
110
165
  * Whether this scenario has satisfied the first-red obligation (design §6).
@@ -130,12 +185,15 @@ export function hasRecordedRed(record, reqId, scenario) {
130
185
  */
131
186
  export function mergeRedRecord(existing, plan, run, addedIds) {
132
187
  const added = new Set(addedIds);
133
- const record = {};
188
+ const record = emptyMap();
134
189
  // Rebuild rather than mutate, so key order is a function of the data and two
135
190
  // machines write byte-identical files (the same reason `render` orders ids
136
191
  // through one code-unit comparator).
137
192
  for (const id of Object.keys(existing).sort(byCodeUnit)) {
138
- record[id] = { ...existing[id] };
193
+ const outcomes = existing[id];
194
+ if (outcomes === undefined)
195
+ continue;
196
+ record[id] = Object.assign(emptyMap(), outcomes);
139
197
  }
140
198
  let changed = false;
141
199
  for (const s of plan.scenarios) {
@@ -147,7 +205,7 @@ export function mergeRedRecord(existing, plan, run, addedIds) {
147
205
  // obligation unobserved rather than inventing a state for it.
148
206
  if (!outcome)
149
207
  continue;
150
- const forId = (record[s.reqId] ??= {});
208
+ const forId = (record[s.reqId] ??= emptyMap());
151
209
  // Monotonic toward `fail`: a recorded fail is final, a recorded pass can
152
210
  // still be corrected by a real one. See the note at the top of this file.
153
211
  if (forId[s.name] === 'fail' || forId[s.name] === outcome)
@@ -157,7 +215,7 @@ export function mergeRedRecord(existing, plan, run, addedIds) {
157
215
  }
158
216
  // Sort within each requirement for the same byte-stability reason.
159
217
  for (const id of Object.keys(record)) {
160
- const sorted = {};
218
+ const sorted = emptyMap();
161
219
  for (const name of Object.keys(record[id]).sort(byCodeUnit)) {
162
220
  sorted[name] = record[id][name];
163
221
  }
@@ -1,5 +1,16 @@
1
1
  import { startVitest } from 'vitest/node';
2
2
  import type { RunResult } from './types.js';
3
+ /**
4
+ * Vitest's own default exclusions, which every Attest run keeps on top of
5
+ * whatever else it excludes.
6
+ *
7
+ * Re-exported from here because this module is one of the three allowed to name
8
+ * `vite`/`vitest` at all (`tests/import-boundary.spec.ts`). `runArchive` needs
9
+ * this list to compose an exclude set of its own, and importing `vitest/config`
10
+ * in `pipeline.ts` to get it put the dependency on the module every static
11
+ * command goes through — the boundary the gate exists to hold.
12
+ */
13
+ export declare const BASE_EXCLUDE: readonly string[];
3
14
  export interface RunAndCollectOptions {
4
15
  /** Project root to run. Defaults to the current working directory. */
5
16
  root?: string;
@@ -6,8 +6,19 @@ import { startVitest } from 'vitest/node';
6
6
  import { configDefaults } from 'vitest/config';
7
7
  /** requirement() names each describe block `[reqId]`; recover the id from that. */
8
8
  const REQ_SUITE = /^\[(.+)\]$/;
9
+ /**
10
+ * Vitest's own default exclusions, which every Attest run keeps on top of
11
+ * whatever else it excludes.
12
+ *
13
+ * Re-exported from here because this module is one of the three allowed to name
14
+ * `vite`/`vitest` at all (`tests/import-boundary.spec.ts`). `runArchive` needs
15
+ * this list to compose an exclude set of its own, and importing `vitest/config`
16
+ * in `pipeline.ts` to get it put the dependency on the module every static
17
+ * command goes through — the boundary the gate exists to hold.
18
+ */
19
+ export const BASE_EXCLUDE = configDefaults.exclude;
9
20
  /** Proposed / archived changes are excluded from a normal run (design §7, §8). */
10
- const DEFAULT_EXCLUDE = [...configDefaults.exclude, '**/changes/**', '**/archive/**'];
21
+ const DEFAULT_EXCLUDE = [...BASE_EXCLUDE, '**/changes/**', '**/archive/**'];
11
22
  /**
12
23
  * Build the child-run options. Attest owns the run *scope* — include/exclude/
13
24
  * root/watch always come from here, so the caller can neither widen the spec
@@ -16,6 +16,18 @@ export declare const RequirementSchema: z.ZodObject<{
16
16
  params?: Record<string, string | number | boolean | (string | number | boolean)[]> | undefined;
17
17
  outOfScope?: string[] | undefined;
18
18
  }>;
19
+ /**
20
+ * The id grammar, on its own.
21
+ *
22
+ * Exported because the registry is not the only thing that files a requirement
23
+ * under an id: a delta's ADDED block does too, and `applyDelta` validated the
24
+ * requirement while never looking at the key it went under (design §7). So
25
+ * `changes/` could introduce an id `defineRequirements` would refuse — one that
26
+ * could therefore never be written into a `*.reqs.ts` after the change merged.
27
+ * One schema rather than a second regex, because two spellings of a grammar are
28
+ * two grammars as soon as one of them is edited.
29
+ */
30
+ export declare const RequirementIdSchema: z.ZodString;
19
31
  /** The requirement registry: stable ID -> requirement (design §2, §5.1). */
20
32
  export declare const RegistrySchema: z.ZodRecord<z.ZodString, z.ZodObject<{
21
33
  statement: z.ZodEffects<z.ZodString, string, string>;
@@ -22,6 +22,20 @@ export const RequirementSchema = z.object({
22
22
  .default({}),
23
23
  outOfScope: z.array(z.string()).default([]),
24
24
  });
25
+ /**
26
+ * The id grammar, on its own.
27
+ *
28
+ * Exported because the registry is not the only thing that files a requirement
29
+ * under an id: a delta's ADDED block does too, and `applyDelta` validated the
30
+ * requirement while never looking at the key it went under (design §7). So
31
+ * `changes/` could introduce an id `defineRequirements` would refuse — one that
32
+ * could therefore never be written into a `*.reqs.ts` after the change merged.
33
+ * One schema rather than a second regex, because two spellings of a grammar are
34
+ * two grammars as soon as one of them is edited.
35
+ */
36
+ export const RequirementIdSchema = z
37
+ .string()
38
+ .regex(/^[A-Z]+-\d+$/, 'id must look like AUTH-3');
25
39
  /** The requirement registry: stable ID -> requirement (design §2, §5.1). */
26
- export const RegistrySchema = z.record(z.string().regex(/^[A-Z]+-\d+$/, 'id must look like AUTH-3'), RequirementSchema);
40
+ export const RegistrySchema = z.record(RequirementIdSchema, RequirementSchema);
27
41
  //# sourceMappingURL=schema.js.map