@am_shork/attest 0.7.4 → 0.9.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -1,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
@@ -0,0 +1,26 @@
1
+ // The shape of a registry schema failure, and the one rendering of it
2
+ // (design §5.1, §10).
3
+ //
4
+ // Separate from registry.ts because `./define` maps to that whole module, so
5
+ // everything it exports is a public contract. What has to be public here is the
6
+ // element type of `RegistryValidationError.issues`; the formatter does not, and
7
+ // both of its callers are internal.
8
+ /**
9
+ * The message `RegistryValidationError` carries, available without one.
10
+ *
11
+ * Three sites report `registry-invalid` — the throwing path in
12
+ * `defineRequirements`, and the two readers in `locate.ts` and
13
+ * `static-registry.ts` that report it without throwing — and their messages
14
+ * have to be identical rather than merely similar. Sharing this function is
15
+ * what makes that structural rather than a coincidence maintained by hand, so
16
+ * a new site formats through here and does not grow a fourth spelling.
17
+ */
18
+ export function registryValidationMessage(issues) {
19
+ // `.map(String)` and not a bare `.join('.')`: under `PropertyKey[]` a segment
20
+ // can be a symbol, and `join` throws rather than converting one.
21
+ const fields = issues
22
+ .map((i) => ` - ${i.path.map(String).join('.') || '(root)'}: ${i.message}`)
23
+ .join('\n');
24
+ return `Invalid requirement registry:\n${fields}`;
25
+ }
26
+ //# sourceMappingURL=registry-issues.js.map
@@ -1,10 +1,18 @@
1
- import { z } from 'zod';
2
1
  import { type RegistryInput, type RequirementInput } from './schema.js';
2
+ import { type RegistryValidationIssue } from './registry-issues.js';
3
3
  import type { Registry, Requirement } from './types.js';
4
- /** Thrown when a registry fails structural validation (design §5.1). */
4
+ export type { RegistryValidationIssue } from './registry-issues.js';
5
+ /**
6
+ * Thrown when a registry fails structural validation (design §5.1).
7
+ *
8
+ * `issues` is typed as this project's own {@link RegistryValidationIssue} and
9
+ * not the validator's, because this class is reached through the `./define`
10
+ * subpath and the field is therefore a public contract — the same boundary
11
+ * `Issue.message` holds, with the difference that `Issue` was designed with it.
12
+ */
5
13
  export declare class RegistryValidationError extends Error {
6
- readonly issues: z.ZodIssue[];
7
- constructor(issues: z.ZodIssue[]);
14
+ readonly issues: RegistryValidationIssue[];
15
+ constructor(issues: RegistryValidationIssue[]);
8
16
  }
9
17
  /**
10
18
  * A `const` type parameter infers an array literal as a tuple, and would infer
@@ -139,5 +147,4 @@ export declare function delta<const T extends RegistryDelta>(d: T): DefinedDelta
139
147
  * caller wrote is still the literal they hold.
140
148
  */
141
149
  export declare function withProposedRequirements<T extends RegistryDelta>(d: T): T;
142
- export {};
143
150
  //# sourceMappingURL=registry.d.ts.map
@@ -1,22 +1,24 @@
1
1
  // Requirement registry authoring API (design §2, §5.1, §7).
2
2
  // defineRequirements validates against RegistrySchema and applies defaults;
3
3
  // delta() declares a change delta, applied by applyDelta (see apply.ts).
4
- import { z } from 'zod';
5
4
  import { RegistrySchema, } from './schema.js';
6
- /** Thrown when a registry fails structural validation (design §5.1). */
5
+ import { registryValidationMessage, } from './registry-issues.js';
6
+ /**
7
+ * Thrown when a registry fails structural validation (design §5.1).
8
+ *
9
+ * `issues` is typed as this project's own {@link RegistryValidationIssue} and
10
+ * not the validator's, because this class is reached through the `./define`
11
+ * subpath and the field is therefore a public contract — the same boundary
12
+ * `Issue.message` holds, with the difference that `Issue` was designed with it.
13
+ */
7
14
  export class RegistryValidationError extends Error {
8
15
  issues;
9
16
  constructor(issues) {
10
- super(`Invalid requirement registry:\n${formatIssues(issues)}`);
17
+ super(registryValidationMessage(issues));
11
18
  this.issues = issues;
12
19
  this.name = 'RegistryValidationError';
13
20
  }
14
21
  }
15
- function formatIssues(issues) {
16
- return issues
17
- .map((i) => ` - ${i.path.join('.') || '(root)'}: ${i.message}`)
18
- .join('\n');
19
- }
20
22
  /**
21
23
  * Declare a requirement registry. Validates structure against RegistrySchema
22
24
  * (SHALL/MUST keyword, non-empty rationale, well-formed IDs, scalar params) and
@@ -70,12 +70,19 @@ export async function runAndCollect(options = {}) {
70
70
  const runtimeCoverage = new Map();
71
71
  const outcomes = new Map();
72
72
  // Reconstruct coverage from the task tree (not an in-process singleton).
73
- const walk = (task) => {
73
+ //
74
+ // `file` is threaded down from the file task rather than read off the suite,
75
+ // because it is the only level that carries a path — and it is what keeps
76
+ // two spec files declaring the same scenario name under one `requirement()`
77
+ // from collapsing into a single entry that either of them could satisfy.
78
+ const walk = (task, file) => {
74
79
  if (task.type === 'suite') {
75
80
  const id = requirementIdOf(task.name);
76
81
  if (id !== undefined) {
77
- const set = runtimeCoverage.get(id) ?? new Set();
78
- const byName = outcomes.get(id) ?? new Map();
82
+ const byFile = runtimeCoverage.get(id) ?? new Map();
83
+ const outcomesByFile = outcomes.get(id) ?? new Map();
84
+ const set = byFile.get(file) ?? new Set();
85
+ const byName = outcomesByFile.get(file) ?? new Map();
79
86
  for (const c of scenariosUnder(task)) {
80
87
  // A scenario counts as covered only if it actually executed —
81
88
  // skipped/todo scenarios have no run result (enables §8's
@@ -87,12 +94,14 @@ export async function runAndCollect(options = {}) {
87
94
  set.add(c.name);
88
95
  byName.set(c.name, outcome);
89
96
  }
90
- runtimeCoverage.set(id, set);
91
- outcomes.set(id, byName);
97
+ byFile.set(file, set);
98
+ outcomesByFile.set(file, byName);
99
+ runtimeCoverage.set(id, byFile);
100
+ outcomes.set(id, outcomesByFile);
92
101
  }
93
102
  }
94
103
  for (const c of task.tasks ?? [])
95
- walk(c);
104
+ walk(c, file);
96
105
  };
97
106
  // Assigned, never cast. `TaskLike` is the narrow view this walk needs, and
98
107
  // Vitest's `File` satisfies it structurally — so the compiler is the thing
@@ -101,12 +110,15 @@ export async function runAndCollect(options = {}) {
101
110
  // exactly the change that would silently empty the runtime coverage.
102
111
  const unloadedFiles = [];
103
112
  for (const file of vitest.state.getFiles()) {
104
- walk(file);
105
113
  // Relative and POSIX for the reason everything derived from the root is
106
114
  // (`paths.ts`): this becomes an `Issue.file`, which a `--json` consumer
107
- // diffs across two CI runs that may not share an operating system.
115
+ // diffs across two CI runs that may not share an operating system — and,
116
+ // since the coverage maps are keyed by it, a comparison key against a plan
117
+ // built the same way.
118
+ const relative = relativePath(options.root ?? process.cwd(), file.filepath);
119
+ walk(file, relative);
108
120
  if (failedToLoad(file)) {
109
- unloadedFiles.push(relativePath(options.root ?? process.cwd(), file.filepath));
121
+ unloadedFiles.push(relative);
110
122
  }
111
123
  }
112
124
  unloadedFiles.sort(byCodeUnit);
@@ -1,27 +1,17 @@
1
1
  import { z } from 'zod';
2
2
  /** A scalar param value. `null` is included: it is how an author writes "empty". */
3
- declare const scalar: z.ZodUnion<[z.ZodNumber, z.ZodString, z.ZodBoolean, z.ZodNull]>;
3
+ declare const scalar: z.ZodUnion<readonly [z.ZodNumber, z.ZodString, z.ZodBoolean, z.ZodNull]>;
4
4
  /** Any JSON value — what a param may be. */
5
5
  export type ParamValue = z.infer<typeof scalar> | ParamValue[] | {
6
6
  [key: string]: ParamValue;
7
7
  };
8
8
  /** A single behavioural contract (design §2). */
9
9
  export declare const RequirementSchema: z.ZodObject<{
10
- statement: z.ZodEffects<z.ZodString, string, string>;
10
+ statement: z.ZodString;
11
11
  rationale: z.ZodString;
12
- params: z.ZodDefault<z.ZodRecord<z.ZodString, z.ZodType<ParamValue, z.ZodTypeDef, ParamValue>>>;
13
- outOfScope: z.ZodDefault<z.ZodArray<z.ZodString, "many">>;
14
- }, "strip", z.ZodTypeAny, {
15
- params: Record<string, ParamValue>;
16
- statement: string;
17
- rationale: string;
18
- outOfScope: string[];
19
- }, {
20
- statement: string;
21
- rationale: string;
22
- params?: Record<string, ParamValue> | undefined;
23
- outOfScope?: string[] | undefined;
24
- }>;
12
+ params: z.ZodDefault<z.ZodRecord<z.ZodString, z.ZodType<ParamValue, unknown, z.core.$ZodTypeInternals<ParamValue, unknown>>>>;
13
+ outOfScope: z.ZodDefault<z.ZodArray<z.ZodString>>;
14
+ }, z.core.$strip>;
25
15
  /**
26
16
  * The id grammar, on its own.
27
17
  *
@@ -34,23 +24,38 @@ export declare const RequirementSchema: z.ZodObject<{
34
24
  * two grammars as soon as one of them is edited.
35
25
  */
36
26
  export declare const RequirementIdSchema: z.ZodString;
37
- /** The requirement registry: stable ID -> requirement (design §2, §5.1). */
38
- export declare const RegistrySchema: z.ZodRecord<z.ZodString, z.ZodObject<{
39
- statement: z.ZodEffects<z.ZodString, string, string>;
40
- rationale: z.ZodString;
41
- params: z.ZodDefault<z.ZodRecord<z.ZodString, z.ZodType<ParamValue, z.ZodTypeDef, ParamValue>>>;
42
- outOfScope: z.ZodDefault<z.ZodArray<z.ZodString, "many">>;
43
- }, "strip", z.ZodTypeAny, {
44
- params: Record<string, ParamValue>;
27
+ /**
28
+ * The requirement registry: stable ID -> requirement (design §2, §5.1).
29
+ *
30
+ * Guarded by `isPlainObject` for the same reason `jsonObject` is, and it is the
31
+ * *evaluating* reader this protects: `{ '__proto__': { … } }` swaps the
32
+ * prototype rather than creating a key, so the registry a loader builds from
33
+ * that file comes from the prototype and not from anything the file declares.
34
+ * The static reader refuses such a source outright (`registry-not-static`), and
35
+ * the two readers agreeing is a security property (design §5.2), not a
36
+ * convenience — so the schema has to refuse it rather than enumerate it.
37
+ *
38
+ * The guard is written over `RegistryRecord`'s input so `z.input` still
39
+ * describes the authoring shape: `defineRequirements` constrains its `const`
40
+ * type parameter to `RegistryInput`, and a guard typed as a bare record would
41
+ * erase every literal that constraint exists to capture.
42
+ */
43
+ export declare const RegistrySchema: z.ZodPipe<z.ZodCustom<Record<string, {
45
44
  statement: string;
46
45
  rationale: string;
47
- outOfScope: string[];
48
- }, {
46
+ params?: Record<string, unknown> | undefined;
47
+ outOfScope?: string[] | undefined;
48
+ }>, Record<string, {
49
49
  statement: string;
50
50
  rationale: string;
51
- params?: Record<string, ParamValue> | undefined;
51
+ params?: Record<string, unknown> | undefined;
52
52
  outOfScope?: string[] | undefined;
53
- }>>;
53
+ }>>, z.ZodRecord<z.ZodString, z.ZodObject<{
54
+ statement: z.ZodString;
55
+ rationale: z.ZodString;
56
+ params: z.ZodDefault<z.ZodRecord<z.ZodString, z.ZodType<ParamValue, unknown, z.core.$ZodTypeInternals<ParamValue, unknown>>>>;
57
+ outOfScope: z.ZodDefault<z.ZodArray<z.ZodString>>;
58
+ }, z.core.$strip>>>;
54
59
  /** Parsed (output) shapes — defaults applied. */
55
60
  export type Requirement = z.infer<typeof RequirementSchema>;
56
61
  export type Registry = z.infer<typeof RegistrySchema>;
@@ -35,9 +35,7 @@ const jsonObject = z
35
35
  .custom(isPlainObject)
36
36
  .pipe(z.record(z.string(), z.lazy(() => paramValue)));
37
37
  const paramValue = z.lazy(() => z.union([scalar, z.array(paramValue), jsonObject], {
38
- errorMap: () => ({
39
- message: 'expected JSON data (no functions, dates, or class instances)',
40
- }),
38
+ error: 'expected JSON data (no functions, dates, or class instances)',
41
39
  }));
42
40
  /** A single behavioural contract (design §2). */
43
41
  export const RequirementSchema = z.object({
@@ -80,6 +78,36 @@ export const RequirementSchema = z.object({
80
78
  export const RequirementIdSchema = z
81
79
  .string()
82
80
  .regex(/^[A-Z]+-\d+$/, 'id must look like AUTH-3');
83
- /** The requirement registry: stable ID -> requirement (design §2, §5.1). */
84
- export const RegistrySchema = z.record(RequirementIdSchema, RequirementSchema);
81
+ /**
82
+ * The registry's own record, before the plain-object guard below.
83
+ *
84
+ * `error` is carried explicitly because a record reports a key failure as
85
+ * "Invalid key in record" and does not surface the key schema's own message —
86
+ * and a mistyped id is the commonest way to fail this schema, so the grammar is
87
+ * the one thing the author needs to be told.
88
+ */
89
+ const RegistryRecord = z.record(RequirementIdSchema, RequirementSchema, {
90
+ error: 'id must look like AUTH-3',
91
+ });
92
+ /**
93
+ * The requirement registry: stable ID -> requirement (design §2, §5.1).
94
+ *
95
+ * Guarded by `isPlainObject` for the same reason `jsonObject` is, and it is the
96
+ * *evaluating* reader this protects: `{ '__proto__': { … } }` swaps the
97
+ * prototype rather than creating a key, so the registry a loader builds from
98
+ * that file comes from the prototype and not from anything the file declares.
99
+ * The static reader refuses such a source outright (`registry-not-static`), and
100
+ * the two readers agreeing is a security property (design §5.2), not a
101
+ * convenience — so the schema has to refuse it rather than enumerate it.
102
+ *
103
+ * The guard is written over `RegistryRecord`'s input so `z.input` still
104
+ * describes the authoring shape: `defineRequirements` constrains its `const`
105
+ * type parameter to `RegistryInput`, and a guard typed as a bare record would
106
+ * erase every literal that constraint exists to capture.
107
+ */
108
+ export const RegistrySchema = z
109
+ .custom(isPlainObject, {
110
+ error: 'a registry must be an object literal',
111
+ })
112
+ .pipe(RegistryRecord);
85
113
  //# sourceMappingURL=schema.js.map