@am_shork/attest 0.7.0 → 0.7.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.
@@ -109,9 +109,13 @@ export async function runCheck(root, options = {}) {
109
109
  // only one where that distinction is worth anything.
110
110
  return withLoader(options, async (loader) => {
111
111
  const { registry, issues, unreadableFiles } = await readRegistry(root, options, scan.reqsFiles, loader);
112
- const plan = await parseSpecs(scan.specFiles, root);
112
+ const { plan, issues: unreadableSpecs } = await parseSpecs(scan.specFiles, root);
113
113
  return [
114
114
  ...issues,
115
+ // A spec the parser could not read is reported here rather than dropped:
116
+ // `check`'s contract is breadth, and a file silently contributing no
117
+ // scenarios reads as a file with no scenarios (ATX-65).
118
+ ...unreadableSpecs,
115
119
  // `check` keeps reporting on a registry that only half-loaded,
116
120
  // deliberately: its contract is breadth, and the findings from the files
117
121
  // that *did* load are all still true. What it must not do is advise work
@@ -220,7 +224,14 @@ async function unclaimedProposedSpecIssues(root, scan, options, loader) {
220
224
  for (const id of claimedIds(read.delta))
221
225
  claimed.add(id);
222
226
  }
223
- const proposed = await parseSpecs(scan.proposedSpecFiles, root);
227
+ const { plan: proposed, issues: unreadable } = await parseSpecs(scan.proposedSpecFiles, root);
228
+ issues.push(...unreadable);
229
+ // A file that would not parse declares no scenarios, so the loop below would
230
+ // find it claimed by nothing and call it unclaimed — a true sentence about a
231
+ // file whose real problem is that it could not be read, and a fix hint
232
+ // pointing at work that must not be done. Same shape as `orphan-test` after a
233
+ // registry fails to load (ATX-62), and refused here for the same reason.
234
+ const unreadableFiles = new Set(unreadable.map((i) => i.file));
224
235
  const claimedFiles = new Set(proposed.scenarios.filter((s) => claimed.has(s.reqId)).map((s) => s.file));
225
236
  // Reported per file, not per scenario: the file is the unit a run includes,
226
237
  // so it is the unit that did or did not execute, and one line per scenario
@@ -230,7 +241,7 @@ async function unclaimedProposedSpecIssues(root, scan, options, loader) {
230
241
  // nothing for a second reason, and reading the plan alone cannot see it.
231
242
  for (const abs of scan.proposedSpecFiles) {
232
243
  const file = relativePath(root, abs);
233
- if (claimedFiles.has(file))
244
+ if (claimedFiles.has(file) || unreadableFiles.has(file))
234
245
  continue;
235
246
  issues.push({
236
247
  level: 'ERROR',
@@ -287,7 +298,9 @@ export async function runVerify(root, options = {}) {
287
298
  registry = loaded.registry;
288
299
  issues.push(...loaded.issues);
289
300
  unreadableFiles = loaded.unreadableFiles;
290
- plan = await parseSpecs(scan.specFiles, root);
301
+ const parsedSpecs = await parseSpecs(scan.specFiles, root);
302
+ plan = parsedSpecs.plan;
303
+ issues.push(...parsedSpecs.issues);
291
304
  }
292
305
  finally {
293
306
  await loader.close();
@@ -365,7 +378,7 @@ export async function runCover(root, options = {}) {
365
378
  const { registry, issues: loadIssues } = await readRegistry(root, options, scan.reqsFiles);
366
379
  if (hasError(loadIssues))
367
380
  return { rows: [], issues: loadIssues };
368
- const plan = await parseSpecs(scan.specFiles, root);
381
+ const { plan, issues: unreadableSpecs } = await parseSpecs(scan.specFiles, root);
369
382
  const counts = new Map();
370
383
  for (const s of plan.scenarios) {
371
384
  counts.set(s.reqId, (counts.get(s.reqId) ?? 0) + 1);
@@ -381,7 +394,7 @@ export async function runCover(root, options = {}) {
381
394
  covered: (counts.get(reqId) ?? 0) > 0,
382
395
  scenarioCount: counts.get(reqId) ?? 0,
383
396
  }));
384
- return { rows, issues: [...loadIssues, ...uncoveredIssues(registry, plan)] };
397
+ return { rows, issues: [...loadIssues, ...unreadableSpecs, ...uncoveredIssues(registry, plan)] };
385
398
  }
386
399
  /**
387
400
  * Markdown projection of the intent layer (design §9: `attest render`).
@@ -607,10 +620,15 @@ function claimedByDelta(proposed, delta) {
607
620
  * walking the tree again, so a caller that already scanned does not repeat it.
608
621
  */
609
622
  async function changeMergedPlan(root, delta, scan) {
610
- const basePlan = await parseSpecs(scan.specFiles, root);
611
- const proposed = await parseSpecs(scan.proposedSpecFiles, root);
612
- const claimed = claimedByDelta(proposed, delta);
623
+ const base = await parseSpecs(scan.specFiles, root);
624
+ const proposedSpecs = await parseSpecs(scan.proposedSpecFiles, root);
625
+ const basePlan = base.plan;
626
+ const claimed = claimedByDelta(proposedSpecs.plan, delta);
613
627
  return {
628
+ // A spec neither parse could read blocks the gate rather than vanishing
629
+ // from it: an unreadable proposed spec is a scenario the gate would
630
+ // otherwise report as absent (ATX-65).
631
+ issues: [...base.issues, ...proposedSpecs.issues],
614
632
  merged: {
615
633
  scenarios: [...basePlan.scenarios, ...claimed.scenarios],
616
634
  paramRefs: [...basePlan.paramRefs, ...claimed.paramRefs],
@@ -679,10 +697,7 @@ export async function runArchiveApply(root, changeName, options = {}) {
679
697
  * The gate, plus what finishing the merge would need.
680
698
  *
681
699
  * One function rather than a gate and a separate `--apply` path, because the
682
- * merge must act on **this** run's verdict. A second traversal could reach a
683
- * different answer than the one just printed — and a command able to file a
684
- * change as done against a stale verdict removes the hard definition of "done"
685
- * that is this tool's whole claim.
700
+ * merge must act on **this** run's verdict (design §8).
686
701
  *
687
702
  * `merge` is absent exactly when there is nothing to act on: a rejected name, an
688
703
  * unreadable delta, or a registry that would not load.
@@ -725,7 +740,12 @@ async function archiveRun(root, changeName, options = {}) {
725
740
  // Static plan = merged base suite + this change's specs (design §8), by the
726
741
  // same function `status` reports against — a progress report computed over a
727
742
  // different spec set than the gate uses would be a report about nothing.
728
- const { merged: plan, claimed } = await changeMergedPlan(root, delta, scan);
743
+ const { merged: plan, claimed, issues: unreadableSpecs } = await changeMergedPlan(root, delta, scan);
744
+ // Before the run, not after: an unreadable spec means the gate cannot see
745
+ // what that file declared, so letting the suite start would grade the
746
+ // change against a plan known to be short (ATX-65).
747
+ if (unreadableSpecs.length > 0)
748
+ return { issues: unreadableSpecs };
729
749
  // Other proposals need no exclude glob of their own: the include list below
730
750
  // is the plan's own files, and the plan holds only the proposed specs this
731
751
  // delta claims. That is what replaced `**/changes/<sibling>/**` — with the
@@ -25,8 +25,13 @@
25
25
  // and it is a *file* — committed, served, and read again long after the run
26
26
  // that wrote it. See `sanitised` for why the defence sits here rather than
27
27
  // at the terminal write.
28
- import { byCodeUnit } from './order.js';
28
+ import { byCodeUnit, sortDeep } from './order.js';
29
29
  import { control } from './terminal.js';
30
+ /** Whether a value reads as words in a sentence — a scalar, or a list of them. */
31
+ function isFlat(value) {
32
+ const scalar = (v) => v === null || typeof v !== 'object';
33
+ return Array.isArray(value) ? value.every(scalar) : scalar(value);
34
+ }
30
35
  const BANNER = '<!-- Generated by `attest render` — do not edit. Edit the `*.reqs.ts` registry and regenerate. -->';
31
36
  /**
32
37
  * Render the registry as a standalone Markdown document.
@@ -52,23 +57,11 @@ export function renderMarkdown(registry) {
52
57
  }
53
58
  /**
54
59
  * The registry with every string its author controls stripped of control
55
- * characters (ATX-58).
56
- *
57
- * **At the entry rather than at each emitter**, which is the whole of why this
58
- * defect existed. ATX-37 put every byte the *CLI* prints through `control`, and
59
- * this document is built by concatenation that never went past it — so a
60
- * statement carrying `ESC [2K CR` erased the reviewer's line and repainted a
61
- * verdict, from `attest render` with no flag at all. Sanitising here means a
62
- * field added to `Requirement` later is covered by having been added, instead of
63
- * by someone remembering; four call sites each doing it is the arrangement that
64
- * produced the gap in the first place.
60
+ * characters (ATX-58, design §9.1).
65
61
  *
66
- * **Over the document, not over stdout.** The obvious fix sanitise the
67
- * terminal write is wrong, and the loop that found this said so: `--out`
68
- * carried the payload into the file too. That file is committed, served, and
69
- * read later by `cat`, by `less -R`, or by a site generator, so the artifact
70
- * outlives the run and the run is the wrong place to defend. It also keeps
71
- * `--check` honest, since both sides of the comparison are built from here.
62
+ * This is the entry §9.1 names: sanitising here rather than at each emitter is
63
+ * what makes a field added to `Requirement` later covered by having been added,
64
+ * and what keeps the obligation over the document rather than over stdout.
72
65
  *
73
66
  * Ids are not sanitised and need not be: `RegistrySchema` holds every key to
74
67
  * `^[A-Z]+-\d+$` on **both** reader paths — the static one by construction, the
@@ -90,11 +83,25 @@ function sanitised(registry) {
90
83
  }
91
84
  return out;
92
85
  }
93
- /** A param value with its strings sanitised; numbers and booleans have none. */
86
+ /**
87
+ * A param value with every string in it sanitised; numbers, booleans and `null`
88
+ * have none.
89
+ *
90
+ * Recursive, over keys as well as values. A param is a JSON value, so the
91
+ * author-controlled strings inside one are at arbitrary depth — and a walk that
92
+ * stops at the first level would leave exactly the nested ones unsanitised,
93
+ * which is the shape the params of a `kind -> payload` table have. §9.1 puts the
94
+ * defence over the whole document; a depth limit is a hole in it.
95
+ */
94
96
  function sanitisedValue(value) {
97
+ if (typeof value === 'string')
98
+ return control(value);
95
99
  if (Array.isArray(value))
96
- return value.map((v) => (typeof v === 'string' ? control(v) : v));
97
- return typeof value === 'string' ? control(value) : value;
100
+ return value.map(sanitisedValue);
101
+ if (value !== null && typeof value === 'object') {
102
+ return Object.fromEntries(Object.entries(value).map(([k, v]) => [control(k), sanitisedValue(v)]));
103
+ }
104
+ return value;
98
105
  }
99
106
  /**
100
107
  * Line endings are a checkout artifact, not content.
@@ -164,16 +171,11 @@ function orderingKey(id) {
164
171
  * numerically. A plain string sort puts ATX-10 between ATX-1 and ATX-2, which
165
172
  * scrambles the document as soon as a registry reaches ten requirements.
166
173
  *
167
- * Total, and a function of the ids alone. `renderMarkdown` feeds `--check`, so
168
- * an order that depends on anything else the locale, the insertion order, the
169
- * engine's tie-breaking is a freshness gate that can disagree with the run
170
- * that generated the file it is checking.
171
- *
172
- * A malformed id cannot reach here through any command: `RequirementIdSchema`
173
- * rejects it and `render` returns early on a registry that failed to load. That
174
- * is why this is robustness rather than a fix — the property being bought is
175
- * that the function is correct on its own terms instead of correct because
176
- * something upstream is.
174
+ * Total, and a function of the ids alone (design §9.1). A malformed id cannot
175
+ * reach here through any command`RequirementIdSchema` rejects it and `render`
176
+ * returns early on a registry that failed to load — so this is the robustness
177
+ * §9.1 asks for rather than a fix: the function is correct on its own terms
178
+ * instead of correct because something upstream is.
177
179
  */
178
180
  function compareIds(a, b) {
179
181
  const [prefixA, numA] = orderingKey(a);
@@ -212,6 +214,15 @@ function section(id, req) {
212
214
  const params = Object.entries(req.params);
213
215
  if (params.length > 0) {
214
216
  out.push('', '| Param | Value |', '| --- | --- |', ...params.map(([name, value]) => `| ${code(name)} | ${cell(formatValue(value))} |`));
217
+ // A structured param goes below the table, not in it: a fenced block cannot
218
+ // live in a table cell — `cell` strips the newlines that make it a fence —
219
+ // and a nested object squeezed onto one line is the unreadable case, which
220
+ // is precisely the shape a `kind -> payload` table has.
221
+ for (const [name, value] of params) {
222
+ if (isFlat(value))
223
+ continue;
224
+ out.push('', `${code(name)}:`, '', ...jsonBlock(value));
225
+ }
215
226
  }
216
227
  if (req.outOfScope.length > 0) {
217
228
  out.push('', '**Out of scope**', '', ...req.outOfScope.map((s) => `- ${s}`));
@@ -238,13 +249,50 @@ function interpolate(statement, params) {
238
249
  */
239
250
  function plain(value) {
240
251
  const one = (v) => v.replace(/([*_`[\]\\])/g, '\\$1');
241
- return Array.isArray(value) ? value.map((v) => one(String(v))).join(', ') : one(String(value));
252
+ return Array.isArray(value)
253
+ ? value.map((v) => one(inlineText(v))).join(', ')
254
+ : one(inlineText(value));
242
255
  }
243
- /** A param value as code, for the params table. */
256
+ /**
257
+ * One param value as a run of text.
258
+ *
259
+ * `String(v)` on an object is `[object Object]`, and this function is the last
260
+ * place that can be stopped. It is not stopped by `non-scalar-interpolation`:
261
+ * `render` reads the registry and nothing else — no spec parse, so no
262
+ * `AttestPlan`, so no `validateStructure` — and `attest render` therefore runs
263
+ * happily on a registry `check` would refuse. Compact sorted JSON is not a good
264
+ * sentence, but it is the value, and `check` says what to do about it. A
265
+ * rendering that reports the shape wrongly is worse than one that reads oddly.
266
+ */
267
+ function inlineText(value) {
268
+ return value === null || typeof value !== 'object'
269
+ ? String(value)
270
+ : JSON.stringify(sortDeep(value));
271
+ }
272
+ /** A param value as code, for the params table. Structured values go below it. */
244
273
  function formatValue(value) {
274
+ if (!isFlat(value))
275
+ return '_see below_';
245
276
  return Array.isArray(value)
246
- ? value.map((v) => code(String(v))).join(', ')
247
- : code(String(value));
277
+ ? value.map((v) => code(inlineText(v))).join(', ')
278
+ : code(inlineText(value));
279
+ }
280
+ /**
281
+ * A structured param as a fenced JSON block.
282
+ *
283
+ * Keys sorted at every depth, because `JSON.stringify` writes them in insertion
284
+ * order and ATX-10 holds the same registry to the same bytes. The fence is
285
+ * measured rather than fixed at three for the reason `code` measures its own: the
286
+ * value is author-controlled, and a JSON string may contain a run of backticks
287
+ * that closes a fence written blind.
288
+ */
289
+ function jsonBlock(value) {
290
+ const text = JSON.stringify(sortDeep(value), null, 2);
291
+ let longest = 0;
292
+ for (const run of text.matchAll(/`+/g))
293
+ longest = Math.max(longest, run[0].length);
294
+ const fence = '`'.repeat(Math.max(3, longest + 1));
295
+ return [`${fence}json`, text, fence];
248
296
  }
249
297
  /**
250
298
  * Wrap text in a code span that survives backticks in the value: the fence has
@@ -268,22 +316,14 @@ function code(value) {
268
316
  /**
269
317
  * Make prose safe inside a table cell: no row-breaking pipes, no newlines.
270
318
  *
271
- * Each maximal whitespace run is matched once and inspected, rather than
272
- * matched by the old starred-`\s`, `\n`, starred-`\s` pattern (ATX-59). That
273
- * spelling puts a required character after a leading quantifier, so a
274
- * whitespace run with no newline in it was consumed, failed, and re-tried one
275
- * character shorter from every position in the run quadratic, and 8.6 seconds
276
- * of CPU for 120,000 spaces in one statement, reachable through `render
277
- * --check` in CI without executing a line of the project.
278
- *
279
- * The obvious repair does not work and was measured before this one was
280
- * written: `[^\S\n]*\n[^\S\n]*`, which stops the class matching the newline,
281
- * came out *slower*. The backtracking was never about which characters the
282
- * class held — it was about the quantifier having something after it. `\s+`
283
- * has nothing after it, so there is no failure to backtrack into, and the
284
- * decision moves to the callback. Byte-identical to the old pattern: a
285
- * whitespace run containing a newline collapses to one space, and a run
286
- * without one is left exactly as it was.
319
+ * Each maximal whitespace run is matched once and inspected, rather than split
320
+ * across a pattern that puts a required character after a leading quantifier
321
+ * (ATX-59). `\s+` has nothing after it to fail against, so there is no
322
+ * backtracking to be quadratic in, and the newline decision moves to the
323
+ * callback. That is the property to preserve: any rewrite that puts a literal
324
+ * behind a quantifier here reintroduces it, including the narrower character
325
+ * class that looks like the obvious repair, which came out slower. Measured in
326
+ * `[0.7.0]`.
287
327
  */
288
328
  function cell(text) {
289
329
  return text
@@ -125,12 +125,10 @@ export async function runAndCollect(options = {}) {
125
125
  /**
126
126
  * Every scenario belonging to a requirement suite, at any depth beneath it.
127
127
  *
128
- * Direct children only was the other half of the nested-`describe` defect: a
129
- * scenario grouped under one is a `test` inside a `suite` inside `[reqId]`, so
130
- * even once the runtime stopped throwing it would have been invisible here
131
- * `declared-not-run`, for a scenario that ran and passed. The static parser has
132
- * always recursed (`parser.ts` walks the whole subtree), and this is the seam
133
- * where the two readers of one plan have to agree.
128
+ * Recursive, not direct children only: this is the seam design §5.4 names, where
129
+ * the two readers of one plan have to descend the same way. A scenario grouped
130
+ * under a nested `describe` is a `test` inside a `suite` inside `[reqId]`, and
131
+ * taking direct children makes it invisible here while `parser.ts` still sees it.
134
132
  *
135
133
  * Descent stops at a nested requirement suite, so a `requirement()` written
136
134
  * inside another one keeps its own scenarios rather than donating them upward.
@@ -1,19 +1,25 @@
1
1
  import { z } from 'zod';
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]>;
4
+ /** Any JSON value — what a param may be. */
5
+ export type ParamValue = z.infer<typeof scalar> | ParamValue[] | {
6
+ [key: string]: ParamValue;
7
+ };
2
8
  /** A single behavioural contract (design §2). */
3
9
  export declare const RequirementSchema: z.ZodObject<{
4
10
  statement: z.ZodEffects<z.ZodString, string, string>;
5
11
  rationale: z.ZodString;
6
- params: z.ZodDefault<z.ZodRecord<z.ZodString, z.ZodUnion<[z.ZodUnion<[z.ZodNumber, z.ZodString, z.ZodBoolean]>, z.ZodArray<z.ZodUnion<[z.ZodNumber, z.ZodString, z.ZodBoolean]>, "many">]>>>;
12
+ params: z.ZodDefault<z.ZodRecord<z.ZodString, z.ZodType<ParamValue, z.ZodTypeDef, ParamValue>>>;
7
13
  outOfScope: z.ZodDefault<z.ZodArray<z.ZodString, "many">>;
8
14
  }, "strip", z.ZodTypeAny, {
9
- params: Record<string, string | number | boolean | (string | number | boolean)[]>;
15
+ params: Record<string, ParamValue>;
10
16
  statement: string;
11
17
  rationale: string;
12
18
  outOfScope: string[];
13
19
  }, {
14
20
  statement: string;
15
21
  rationale: string;
16
- params?: Record<string, string | number | boolean | (string | number | boolean)[]> | undefined;
22
+ params?: Record<string, ParamValue> | undefined;
17
23
  outOfScope?: string[] | undefined;
18
24
  }>;
19
25
  /**
@@ -32,17 +38,17 @@ export declare const RequirementIdSchema: z.ZodString;
32
38
  export declare const RegistrySchema: z.ZodRecord<z.ZodString, z.ZodObject<{
33
39
  statement: z.ZodEffects<z.ZodString, string, string>;
34
40
  rationale: z.ZodString;
35
- params: z.ZodDefault<z.ZodRecord<z.ZodString, z.ZodUnion<[z.ZodUnion<[z.ZodNumber, z.ZodString, z.ZodBoolean]>, z.ZodArray<z.ZodUnion<[z.ZodNumber, z.ZodString, z.ZodBoolean]>, "many">]>>>;
41
+ params: z.ZodDefault<z.ZodRecord<z.ZodString, z.ZodType<ParamValue, z.ZodTypeDef, ParamValue>>>;
36
42
  outOfScope: z.ZodDefault<z.ZodArray<z.ZodString, "many">>;
37
43
  }, "strip", z.ZodTypeAny, {
38
- params: Record<string, string | number | boolean | (string | number | boolean)[]>;
44
+ params: Record<string, ParamValue>;
39
45
  statement: string;
40
46
  rationale: string;
41
47
  outOfScope: string[];
42
48
  }, {
43
49
  statement: string;
44
50
  rationale: string;
45
- params?: Record<string, string | number | boolean | (string | number | boolean)[]> | undefined;
51
+ params?: Record<string, ParamValue> | undefined;
46
52
  outOfScope?: string[] | undefined;
47
53
  }>>;
48
54
  /** Parsed (output) shapes — defaults applied. */
@@ -51,4 +57,5 @@ export type Registry = z.infer<typeof RegistrySchema>;
51
57
  /** Authoring (input) shapes — params / outOfScope optional. */
52
58
  export type RequirementInput = z.input<typeof RequirementSchema>;
53
59
  export type RegistryInput = z.input<typeof RegistrySchema>;
60
+ export {};
54
61
  //# sourceMappingURL=schema.d.ts.map
@@ -2,6 +2,43 @@
2
2
  // This is the single source of truth for the shape of the intent layer; the
3
3
  // Requirement / Registry TypeScript types are inferred from it.
4
4
  import { z } from 'zod';
5
+ /** A scalar param value. `null` is included: it is how an author writes "empty". */
6
+ const scalar = z.union([z.number(), z.string(), z.boolean(), z.null()]);
7
+ /**
8
+ * An object literal and nothing else.
9
+ *
10
+ * The guard runs on the *input*, before `z.record` copies own keys into a fresh
11
+ * object, because that copy is exactly what hides the case it is here for:
12
+ * `{ __proto__: { … } }` swaps the prototype rather than creating a key, so the
13
+ * parsed value is `{}` and the taint is invisible one step later. The static
14
+ * reader refuses that source outright (`registry-not-static`); without this the
15
+ * evaluating reader would call the same file green, and the two readers agreeing
16
+ * is the property `tests/static-registry.spec.ts` exists to hold.
17
+ *
18
+ * A class instance and a `Date` fail here too, which is the other half of what
19
+ * "JSON data" means — both survive `typeof v === 'object'` and neither has a
20
+ * meaningful rendering.
21
+ */
22
+ function isPlainObject(input) {
23
+ if (typeof input !== 'object' || input === null || Array.isArray(input))
24
+ return false;
25
+ // Both spellings, because they are not the same thing and the static reader
26
+ // refuses both: a literal `__proto__:` swaps the prototype, while the key
27
+ // arriving through `JSON.parse` is an own property that survives into the
28
+ // registry and means something else to every later reader of it.
29
+ if (Object.hasOwn(input, '__proto__'))
30
+ return false;
31
+ const proto = Object.getPrototypeOf(input);
32
+ return proto === Object.prototype || proto === null;
33
+ }
34
+ const jsonObject = z
35
+ .custom(isPlainObject)
36
+ .pipe(z.record(z.string(), z.lazy(() => paramValue)));
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
+ }),
41
+ }));
5
42
  /** A single behavioural contract (design §2). */
6
43
  export const RequirementSchema = z.object({
7
44
  statement: z
@@ -10,24 +47,23 @@ export const RequirementSchema = z.object({
10
47
  message: 'statement must contain the RFC-2119 keyword SHALL or MUST',
11
48
  }),
12
49
  rationale: z.string().min(10, 'rationale must not be empty (the intent layer has to say why)'),
13
- // A param is a scalar or a homogeneous-enough list of scalars. Lists (vendor
14
- // blacklists, id sets) are the most drift-prone constants, so keeping them out
15
- // of params left the highest-risk values unguarded; an array still has exactly
16
- // one owner (the spec) read by exactly one place (the scenario).
17
- // The union carries its own message because a union's default one is the
18
- // word "Invalid input", which names neither what was given nor what is
19
- // accepted. A nested object is the value that reaches it — a table of
20
- // kind -> weight is the natural thing to try — and the constraint that
21
- // refuses it is stated nowhere the author is looking, so the message is
22
- // where they find it.
23
- params: z
24
- .record(z.string(), (() => {
25
- const scalar = z.union([z.number(), z.string(), z.boolean()]);
26
- return z.union([scalar, z.array(scalar)], {
27
- errorMap: () => ({ message: 'expected a string, number, boolean, or an array of those' }),
28
- });
29
- })())
30
- .default({}),
50
+ // A param is any JSON value. Lists (vendor blacklists, id sets) are the most
51
+ // drift-prone constants, so keeping them out of params left the highest-risk
52
+ // values unguarded; the same argument runs one step further, because a
53
+ // kind -> payload table drifts harder than a list and was the one shape left
54
+ // outside. A param still has exactly one owner (the spec) read by exactly one
55
+ // place (the scenario), whatever its depth.
56
+ //
57
+ // What the old scalar-or-list union was really protecting was the *rendering*:
58
+ // `{payloadKinds}` interpolated into a statement as `[object Object]`. That is
59
+ // a property of the interpolation point, not of the value, so it is enforced
60
+ // there — `non-scalar-interpolation` in `validator.ts`, and defensively in
61
+ // `render.ts`, which runs without the validator. This union keeps its own
62
+ // message anyway, because a union's default one is the word "Invalid input",
63
+ // which names neither what was given nor what is accepted, and the values that
64
+ // now reach it are the ones JSON has no place for: a function, a Date, a class
65
+ // instance.
66
+ params: z.record(z.string(), paramValue).default({}),
31
67
  outOfScope: z.array(z.string()).default([]),
32
68
  });
33
69
  /**
@@ -1,4 +1,15 @@
1
1
  import type { Registry, Requirement } from './types.js';
2
+ /**
3
+ * Thrown when a value reaches the emitter that cannot be written as source
4
+ * without the written form meaning something else than the value.
5
+ *
6
+ * A separate class rather than a bare `Error` because `merge.ts` has to tell it
7
+ * from an I/O failure: this one is an invariant of *this* module, and the
8
+ * caller's job on catching it is to report an internal inconsistency while
9
+ * keeping the account of what it had already written.
10
+ */
11
+ export declare class UnwritableValue extends Error {
12
+ }
2
13
  /**
3
14
  * One registry entry, at `indent`, with no trailing comma.
4
15
  *
@@ -28,18 +39,8 @@ export declare function spliceRequirements(file: string, source: string, additio
28
39
  /**
29
40
  * `source` with every import of `from` repointed at `to`.
30
41
  *
31
- * The second edit `--apply` makes to a file it did not write, and the one the
32
- * design record missed. Merging a proposed spec was described as a rename in
33
- * place, which is true of its *location*: the file already sits where it lands,
34
- * so no relative specifier moves. But a stage-1 scenario reads its proposed
35
- * params out of the change's delta (ATX-48, and the whole reason a delta reads
36
- * as the registry it proposes), and the delta is what step 3 moves into
37
- * `archive/`. Renaming without this leaves a merged spec importing a path that
38
- * no longer exists — a suite that loads nothing, reported as `declared-not-run`
39
- * against scenarios that are perfectly good.
40
- *
41
- * The expression around the import needs nothing done to it: `reqs['AUTH-7']
42
- * .params.x` reads the same on both sides, which is exactly what ATX-48 bought.
42
+ * The second edit `--apply` makes to a file it did not write: "renamed in place"
43
+ * is true of the spec's location and not of its imports (design §8, ATX-48).
43
44
  * So this replaces one string literal and touches nothing else — the same
44
45
  * discipline as the splice, for the same reason.
45
46
  *
@@ -23,7 +23,7 @@ import ts from 'typescript';
23
23
  import { dirname, relative, resolve } from 'node:path';
24
24
  import { registryInsertionPoint } from './static-registry.js';
25
25
  import { toPosixPath } from './paths.js';
26
- import { byCodeUnit } from './order.js';
26
+ import { byCodeUnit, sortDeep } from './order.js';
27
27
  /**
28
28
  * A TypeScript single-quoted string literal holding exactly `value`.
29
29
  *
@@ -55,13 +55,62 @@ function tsString(value) {
55
55
  }
56
56
  return `${out}'`;
57
57
  }
58
- /** A param key, bare when it is a plain identifier and quoted when it is not. */
58
+ /**
59
+ * Thrown when a value reaches the emitter that cannot be written as source
60
+ * without the written form meaning something else than the value.
61
+ *
62
+ * A separate class rather than a bare `Error` because `merge.ts` has to tell it
63
+ * from an I/O failure: this one is an invariant of *this* module, and the
64
+ * caller's job on catching it is to report an internal inconsistency while
65
+ * keeping the account of what it had already written.
66
+ */
67
+ export class UnwritableValue extends Error {
68
+ }
69
+ /**
70
+ * A param key, bare when it is a plain identifier and quoted when it is not.
71
+ *
72
+ * `__proto__` is neither, and quoting is not the repair — `{'__proto__': x}`
73
+ * swaps the prototype in a literal exactly as the bare form does, and the one
74
+ * spelling that would create an own property, `{['__proto__']: x}`, is a
75
+ * computed key the static reader refuses. So there is no text this function
76
+ * could emit whose evaluation is the value it was handed, and the only correct
77
+ * move is to refuse.
78
+ *
79
+ * Unreachable through any command today: `RequirementSchema` rejects a nested
80
+ * `__proto__` and `z.record` drops a top-level one, so `--apply` validates the
81
+ * delta before a value gets here. It is checked anyway because this is the one
82
+ * site that *writes* a registry, and the guard on the reading side
83
+ * (`static-registry.ts`, "refuse, so a file the evaluator also rejects stays
84
+ * rejected") is worth nothing if the writer can produce the file the reader
85
+ * exists to refuse. A defence that holds only because something upstream holds
86
+ * is not a defence — the same standard `compareIds` is written to.
87
+ */
59
88
  function keySource(key) {
89
+ if (key === '__proto__') {
90
+ throw new UnwritableValue('a param key named __proto__ cannot be written as source');
91
+ }
60
92
  return /^[A-Za-z_$][A-Za-z0-9_$]*$/.test(key) ? key : tsString(key);
61
93
  }
94
+ /**
95
+ * One param value as TypeScript source.
96
+ *
97
+ * Recursive over objects as well as arrays: a param is a JSON value, and the
98
+ * fallback here is `String(value)` — which writes `[object Object]` into a
99
+ * `*.reqs.ts` that `--apply` then merges and commits. Key order is the caller's
100
+ * job: `requirementSource` runs the whole params record through `sortDeep`, so
101
+ * emitting in iteration order here *is* code-unit order at every depth.
102
+ */
62
103
  function paramSource(value) {
63
104
  if (Array.isArray(value))
64
105
  return `[${value.map((v) => paramSource(v)).join(', ')}]`;
106
+ if (value !== null && typeof value === 'object') {
107
+ const body = Object.entries(value)
108
+ .map(([k, v]) => `${keySource(k)}: ${paramSource(v)}`)
109
+ .join(', ');
110
+ return body === '' ? '{}' : `{ ${body} }`;
111
+ }
112
+ // `String` is right for the rest and only for the rest: number, boolean, and
113
+ // `null` — whose spelling is `null`, which is also the literal that reads back.
65
114
  return typeof value === 'string' ? tsString(value) : String(value);
66
115
  }
67
116
  /**
@@ -85,10 +134,12 @@ export function requirementSource(id, req, indent) {
85
134
  `${inner}statement: ${tsString(req.statement)},`,
86
135
  `${inner}rationale: ${tsString(req.rationale)},`,
87
136
  ];
88
- // Code-unit key order, so one delta applied twice writes the same bytes — the
89
- // property `--apply`'s re-runnability rests on, and the reason `apply.ts`
90
- // sorts the same way when it canonicalises for `add-conflict`.
91
- const params = Object.entries(req.params).sort(([a], [b]) => byCodeUnit(a, b));
137
+ // Code-unit key order at every depth, so one delta applied twice writes the
138
+ // same bytes — the property `--apply`'s re-runnability rests on, and the
139
+ // reason `apply.ts` canonicalises through the same `sortDeep` for
140
+ // `add-conflict`. Depth matters because a param is a JSON value: nested keys
141
+ // are as much of the emitted text as the outer ones.
142
+ const params = Object.entries(sortDeep(req.params));
92
143
  if (params.length > 0) {
93
144
  const body = params.map(([k, v]) => `${keySource(k)}: ${paramSource(v)}`).join(', ');
94
145
  lines.push(`${inner}params: { ${body} },`);
@@ -125,18 +176,8 @@ export function spliceRequirements(file, source, additions) {
125
176
  /**
126
177
  * `source` with every import of `from` repointed at `to`.
127
178
  *
128
- * The second edit `--apply` makes to a file it did not write, and the one the
129
- * design record missed. Merging a proposed spec was described as a rename in
130
- * place, which is true of its *location*: the file already sits where it lands,
131
- * so no relative specifier moves. But a stage-1 scenario reads its proposed
132
- * params out of the change's delta (ATX-48, and the whole reason a delta reads
133
- * as the registry it proposes), and the delta is what step 3 moves into
134
- * `archive/`. Renaming without this leaves a merged spec importing a path that
135
- * no longer exists — a suite that loads nothing, reported as `declared-not-run`
136
- * against scenarios that are perfectly good.
137
- *
138
- * The expression around the import needs nothing done to it: `reqs['AUTH-7']
139
- * .params.x` reads the same on both sides, which is exactly what ATX-48 bought.
179
+ * The second edit `--apply` makes to a file it did not write: "renamed in place"
180
+ * is true of the spec's location and not of its imports (design §8, ATX-48).
140
181
  * So this replaces one string literal and touches nothing else — the same
141
182
  * discipline as the splice, for the same reason.
142
183
  *
@@ -251,6 +251,12 @@ function literalValue(node) {
251
251
  return true;
252
252
  if (expr.kind === ts.SyntaxKind.FalseKeyword)
253
253
  return false;
254
+ // `null` is a keyword, not a literal node, so it falls off the end of this
255
+ // function unless it is named here — and falling off means `registry-not-static`
256
+ // for the whole file, not a rejected param. The schema accepts `null`; a reader
257
+ // that does not is the two of them disagreeing about what a registry is.
258
+ if (expr.kind === ts.SyntaxKind.NullKeyword)
259
+ return null;
254
260
  if (ts.isPrefixUnaryExpression(expr)) {
255
261
  const operand = unwrap(expr.operand);
256
262
  if (ts.isNumericLiteral(operand)) {