@am_shork/attest 0.4.0 → 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 (78) hide show
  1. package/CHANGELOG.md +945 -225
  2. package/README.md +48 -292
  3. package/dist/cli/index.js +9 -5
  4. package/dist/cli/json.js +25 -1
  5. package/dist/cli/report.d.ts +20 -0
  6. package/dist/cli/report.js +32 -44
  7. package/dist/core/apply.js +49 -1
  8. package/dist/core/docs.d.ts +23 -0
  9. package/dist/core/docs.js +92 -0
  10. package/dist/core/loader.d.ts +22 -0
  11. package/dist/core/loader.js +35 -1
  12. package/dist/core/pipeline.js +2 -3
  13. package/dist/core/red-record.d.ts +9 -1
  14. package/dist/core/red-record.js +67 -9
  15. package/dist/core/runner.d.ts +11 -0
  16. package/dist/core/runner.js +12 -1
  17. package/dist/core/schema.d.ts +12 -0
  18. package/dist/core/schema.js +15 -1
  19. package/dist/core/skill.js +13 -2
  20. package/dist/core/terminal.d.ts +24 -0
  21. package/dist/core/terminal.js +59 -0
  22. package/dist/core/types.d.ts +22 -1
  23. package/dist/{cli → core}/version.js +5 -1
  24. package/dist/core/write.js +37 -5
  25. package/package.json +5 -3
  26. package/dist/cli/index.d.ts.map +0 -1
  27. package/dist/cli/index.js.map +0 -1
  28. package/dist/cli/json.d.ts.map +0 -1
  29. package/dist/cli/json.js.map +0 -1
  30. package/dist/cli/report.d.ts.map +0 -1
  31. package/dist/cli/report.js.map +0 -1
  32. package/dist/cli/version.d.ts.map +0 -1
  33. package/dist/cli/version.js.map +0 -1
  34. package/dist/core/apply.d.ts.map +0 -1
  35. package/dist/core/apply.js.map +0 -1
  36. package/dist/core/gate.d.ts.map +0 -1
  37. package/dist/core/gate.js.map +0 -1
  38. package/dist/core/loader.d.ts.map +0 -1
  39. package/dist/core/loader.js.map +0 -1
  40. package/dist/core/locate.d.ts.map +0 -1
  41. package/dist/core/locate.js.map +0 -1
  42. package/dist/core/order.d.ts.map +0 -1
  43. package/dist/core/order.js.map +0 -1
  44. package/dist/core/parser.d.ts.map +0 -1
  45. package/dist/core/parser.js.map +0 -1
  46. package/dist/core/paths.d.ts.map +0 -1
  47. package/dist/core/paths.js.map +0 -1
  48. package/dist/core/pipeline.d.ts.map +0 -1
  49. package/dist/core/pipeline.js.map +0 -1
  50. package/dist/core/red-record.d.ts.map +0 -1
  51. package/dist/core/red-record.js.map +0 -1
  52. package/dist/core/registry.d.ts.map +0 -1
  53. package/dist/core/registry.js.map +0 -1
  54. package/dist/core/render.d.ts.map +0 -1
  55. package/dist/core/render.js.map +0 -1
  56. package/dist/core/runner.d.ts.map +0 -1
  57. package/dist/core/runner.js.map +0 -1
  58. package/dist/core/schema.d.ts.map +0 -1
  59. package/dist/core/schema.js.map +0 -1
  60. package/dist/core/skill.d.ts.map +0 -1
  61. package/dist/core/skill.js.map +0 -1
  62. package/dist/core/static-registry.d.ts.map +0 -1
  63. package/dist/core/static-registry.js.map +0 -1
  64. package/dist/core/status.d.ts.map +0 -1
  65. package/dist/core/status.js.map +0 -1
  66. package/dist/core/targets.d.ts.map +0 -1
  67. package/dist/core/targets.js.map +0 -1
  68. package/dist/core/types.d.ts.map +0 -1
  69. package/dist/core/types.js.map +0 -1
  70. package/dist/core/validator.d.ts.map +0 -1
  71. package/dist/core/validator.js.map +0 -1
  72. package/dist/core/write.d.ts.map +0 -1
  73. package/dist/core/write.js.map +0 -1
  74. package/dist/index.d.ts.map +0 -1
  75. package/dist/index.js.map +0 -1
  76. package/dist/runtime.d.ts.map +0 -1
  77. package/dist/runtime.js.map +0 -1
  78. /package/dist/{cli → core}/version.d.ts +0 -0
@@ -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
  }
@@ -0,0 +1,23 @@
1
+ /**
2
+ * Every code the engine can put on an `Issue`, and therefore every section the
3
+ * troubleshooting document must have. Alphabetical, so a diff against a heading
4
+ * list reads as a diff.
5
+ *
6
+ * This roster is asserted against two independent things: the `code: '…'`
7
+ * literals in `src/`, so a new diagnostic cannot be added without landing here,
8
+ * and the `##` headings of both language documents, so landing here cannot
9
+ * produce a dead link.
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", "rename-target-invalid", "stale-spec-doc", "tests-red", "unbound-param", "uncovered-requirement", "unknown-target"];
12
+ export type IssueCode = (typeof ISSUE_CODES)[number];
13
+ /**
14
+ * The page explaining `code`, or `undefined` when nothing explains it.
15
+ *
16
+ * `undefined` rather than a best-effort link: an anchor that resolves to the
17
+ * top of the page looks like an answer and is not one. In this repository the
18
+ * roster is exhaustive, so the undefined branch is unreachable from the
19
+ * engine's own issues — it exists because `code` is typed `string` on `Issue`,
20
+ * and a caller outside the engine can put anything there.
21
+ */
22
+ export declare function docsUrl(code: string): string | undefined;
23
+ //# sourceMappingURL=docs.d.ts.map
@@ -0,0 +1,92 @@
1
+ // The link from a diagnostic to the page that explains it (design §5.3).
2
+ //
3
+ // `Issue.code` is the machine-readable name of a rule, and the troubleshooting
4
+ // document gives every rule one section whose heading *is* that code. So the
5
+ // anchor is not a second thing to maintain — it is the code — and a link can be
6
+ // derived rather than authored. Nothing here is hand-written per issue site,
7
+ // which is the whole point: a diagnostic added without a link is impossible,
8
+ // and a link pointing at a section that does not exist is caught by
9
+ // `tests/docs-consistency.spec.ts` rather than by a reader clicking it.
10
+ import { packageVersion } from './version.js';
11
+ /**
12
+ * Every code the engine can put on an `Issue`, and therefore every section the
13
+ * troubleshooting document must have. Alphabetical, so a diff against a heading
14
+ * list reads as a diff.
15
+ *
16
+ * This roster is asserted against two independent things: the `code: '…'`
17
+ * literals in `src/`, so a new diagnostic cannot be added without landing here,
18
+ * and the `##` headings of both language documents, so landing here cannot
19
+ * produce a dead link.
20
+ */
21
+ export const ISSUE_CODES = [
22
+ 'add-conflict',
23
+ 'add-invalid',
24
+ 'change-not-found',
25
+ 'declared-not-run',
26
+ 'duplicate-prefix',
27
+ 'duplicate-requirement',
28
+ 'empty-spec',
29
+ 'internal-error',
30
+ 'invalid-change-name',
31
+ 'missing-spec-doc',
32
+ 'modify-invalid',
33
+ 'modify-missing',
34
+ 'never-red',
35
+ 'orphan-test',
36
+ 'possible-drift',
37
+ 'rationale-placeholder',
38
+ 'registry-invalid',
39
+ 'registry-no-default',
40
+ 'registry-not-static',
41
+ 'rename-source-missing',
42
+ 'rename-target-exists',
43
+ 'rename-target-invalid',
44
+ 'stale-spec-doc',
45
+ 'tests-red',
46
+ 'unbound-param',
47
+ 'uncovered-requirement',
48
+ 'unknown-target',
49
+ ];
50
+ const DOCUMENTED = new Set(ISSUE_CODES);
51
+ const REPO = 'https://gitlab.com/Pseudorca/attest/-/blob';
52
+ const DOC = 'docs/en/troubleshooting.md';
53
+ /**
54
+ * The version whose documents a link points at, as a git ref.
55
+ *
56
+ * Pinned to the tag of the build that printed the diagnostic, not to `main`.
57
+ * The two failure modes are not symmetric:
58
+ *
59
+ * - **Pinned, between a release commit and its tag** — the ref does not resolve
60
+ * yet. But that window is a version nobody has installed: the manifest is
61
+ * bumped and the package is not published, so no build in anyone's
62
+ * `node_modules` prints those links. The 404 is unreachable in practice.
63
+ * - **Unpinned, after any breaking change to the codes** — a build from a year
64
+ * ago links into today's document. A renamed or removed code lands on a
65
+ * missing anchor, and worse, a code whose *meaning* changed lands on a
66
+ * section confidently describing a different rule. That one is reachable by
67
+ * every installed copy, forever, and it gets worse with every release.
68
+ *
69
+ * So the ref is `v<version>`, matching this repository's release tags. The
70
+ * fallback is `main`, for the one case where pinning is impossible rather than
71
+ * merely imprecise: `packageVersion()` answers `0.0.0` when it cannot read the
72
+ * manifest, and `v0.0.0` is a tag that has never existed.
73
+ */
74
+ function ref() {
75
+ const version = packageVersion();
76
+ return version === '0.0.0' ? 'main' : `v${version}`;
77
+ }
78
+ /** Resolved once: the version cannot change while the process runs. */
79
+ const BASE = `${REPO}/${ref()}/${DOC}`;
80
+ /**
81
+ * The page explaining `code`, or `undefined` when nothing explains it.
82
+ *
83
+ * `undefined` rather than a best-effort link: an anchor that resolves to the
84
+ * top of the page looks like an answer and is not one. In this repository the
85
+ * roster is exhaustive, so the undefined branch is unreachable from the
86
+ * engine's own issues — it exists because `code` is typed `string` on `Issue`,
87
+ * and a caller outside the engine can put anything there.
88
+ */
89
+ export function docsUrl(code) {
90
+ return DOCUMENTED.has(code) ? `${BASE}#${code}` : undefined;
91
+ }
92
+ //# sourceMappingURL=docs.js.map
@@ -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
@@ -148,7 +148,7 @@ export default delta({
148
148
  'AUTH-3': { params: { idleTimeoutMin: 15 } }, // 30 → 15; the assertion follows, because it reads params
149
149
  },
150
150
  removed: ['AUTH-5'],
151
- renamed: [{ from: 'AUTH-2', to: 'AUTH-2-login' }],
151
+ renamed: [{ from: 'AUTH-2', to: 'SESSION-1' }], // a rename target is a new id, so it obeys the id grammar
152
152
  });
153
153
  \`\`\`
154
154
 
@@ -184,6 +184,13 @@ Two consequences worth knowing before you start:
184
184
  Commit \`first-run.json\` with the change. It is evidence a reviewer reads, and
185
185
  the gate has to reach the same verdict on a CI checkout as on your machine.
186
186
 
187
+ **Never write or edit that file yourself.** The gate trusts it and cannot check
188
+ it: everything else Attest writes can be regenerated and compared, but a first
189
+ run cannot be recomputed once the change is green — which is the whole reason
190
+ the record exists. Producing it any way other than by running the gate destroys
191
+ the only thing it is for. If it says something you did not expect, run the gate
192
+ again; a real failing run replaces a recorded pass.
193
+
187
194
  To see where the change stands at any point, without paying for a run:
188
195
 
189
196
  \`\`\`
@@ -225,7 +232,7 @@ once. Branch on \`issues[].code\`, never on \`message\`:
225
232
  | \`add-conflict\` | the delta adds an id that already exists with different content |
226
233
  | \`change-not-found\` | no \`requirements.delta.ts\` for that name |
227
234
 
228
- ### Three things you must not do
235
+ ### Four things you must not do
229
236
 
230
237
  Each turns the gate green without changing the system, which is the exact
231
238
  failure this framework exists to make visible:
@@ -240,6 +247,10 @@ failure this framework exists to make visible:
240
247
  is the drift the single source exists to prevent, and the param is typed at
241
248
  the value written in the registry, so a stale expectation stops compiling
242
249
  rather than silently passing.
250
+ 4. **Do not write or edit \`first-run.json\`.** It is the one file here the gate
251
+ trusts without being able to check it, so a hand-written \`"fail"\` clears
252
+ \`never-red\` while proving nothing at all. Run the gate before implementing;
253
+ that is what produces the record honestly.
243
254
 
244
255
  ### When the gate passes
245
256
 
@@ -0,0 +1,24 @@
1
+ /** How far a message is indented under its header line. */
2
+ export declare const INDENT = " ";
3
+ /**
4
+ * A multi-line message, safe to print under a header.
5
+ *
6
+ * A newline survives, because one diagnostic is legitimately multi-line:
7
+ * `registry-invalid` carries Zod's list of field errors, a stack trace is a
8
+ * list of frames, and flattening either would cost a real reader a real thing
9
+ * to save a hypothetical one. It is re-indented instead, so injected text lands
10
+ * inside the message block rather than at column 0 where a forged `ERROR ...`
11
+ * header would be indistinguishable from a real one.
12
+ */
13
+ export declare function block(text: string): string;
14
+ /** The same, for text that sits inline in a header and must stay one line. */
15
+ export declare function inline(text: string): string;
16
+ /**
17
+ * Every C0 control except the newline, plus DEL and the C1 range, as a space.
18
+ *
19
+ * A space rather than deletion: removing the byte would silently splice
20
+ * `atte` + `st` into a word that was never in the file, and a diagnostic that
21
+ * quietly rewrites what it quotes is its own kind of wrong.
22
+ */
23
+ export declare function control(text: string): string;
24
+ //# sourceMappingURL=terminal.d.ts.map
@@ -0,0 +1,59 @@
1
+ // Text from the project under test, on its way to a terminal (ATX-37).
2
+ //
3
+ // Most of what Attest prints is its own prose, but the parts a reader actually
4
+ // navigates by are not: a scenario name and a file path are quoted verbatim out
5
+ // of the repository being checked (`orphan-test`, `declared-not-run`,
6
+ // `never-red`), and the parser hands back the *cooked* string, so an ESC written
7
+ // as an escape in a spec file is a real control byte by the time it arrives
8
+ // here. So is the message of anything that repository throws, which under
9
+ // `verify`, `archive` or `--eval` is a module Attest is running.
10
+ //
11
+ // That makes Attest's output a place where the repository being checked can
12
+ // write to the terminal of whoever is checking it. On a fork MR the author is
13
+ // not the reviewer, and the payload is a scenario name or an exception: erase
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.
17
+ //
18
+ // **In `core/` rather than in the CLI**, though the CLI is its main caller: the
19
+ // loader has to sanitise Vite's log output for the same reason and cannot
20
+ // import from a layer above it. Two copies of this decision is how the crash
21
+ // path came to be missed once already.
22
+ /** The one control character that survives: see `block`. */
23
+ const NEWLINE = 0x0a;
24
+ /** How far a message is indented under its header line. */
25
+ export const INDENT = ' ';
26
+ /**
27
+ * A multi-line message, safe to print under a header.
28
+ *
29
+ * A newline survives, because one diagnostic is legitimately multi-line:
30
+ * `registry-invalid` carries Zod's list of field errors, a stack trace is a
31
+ * list of frames, and flattening either would cost a real reader a real thing
32
+ * to save a hypothetical one. It is re-indented instead, so injected text lands
33
+ * inside the message block rather than at column 0 where a forged `ERROR ...`
34
+ * header would be indistinguishable from a real one.
35
+ */
36
+ export function block(text) {
37
+ return control(text).replaceAll('\n', `\n${INDENT}`);
38
+ }
39
+ /** The same, for text that sits inline in a header and must stay one line. */
40
+ export function inline(text) {
41
+ return control(text).replaceAll('\n', ' ');
42
+ }
43
+ /**
44
+ * Every C0 control except the newline, plus DEL and the C1 range, as a space.
45
+ *
46
+ * A space rather than deletion: removing the byte would silently splice
47
+ * `atte` + `st` into a word that was never in the file, and a diagnostic that
48
+ * quietly rewrites what it quotes is its own kind of wrong.
49
+ */
50
+ export function control(text) {
51
+ return [...text]
52
+ .map((ch) => {
53
+ const c = ch.codePointAt(0);
54
+ const isControl = (c < 0x20 && c !== NEWLINE) || (c >= 0x7f && c <= 0x9f);
55
+ return isControl ? ' ' : ch;
56
+ })
57
+ .join('');
58
+ }
59
+ //# sourceMappingURL=terminal.js.map