@am_shork/attest 0.7.0 → 0.7.1

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.
@@ -10,4 +10,29 @@
10
10
  export function byCodeUnit(a, b) {
11
11
  return a < b ? -1 : a > b ? 1 : 0;
12
12
  }
13
+ /**
14
+ * A JSON value with every object's keys in code-unit order, at every depth.
15
+ *
16
+ * `JSON.stringify` writes object keys in insertion order, which is *how the
17
+ * source happened to be written* — the thing this module exists to keep out of
18
+ * anything compared or committed. Two callers need the same guarantee for the
19
+ * same reason, one depth apart:
20
+ *
21
+ * - `apply.ts` canonicalises a requirement to decide `add-conflict`, so an
22
+ * identical copy written with its keys in another order must not read as a
23
+ * conflict with itself.
24
+ * - `render.ts` emits a structured param as JSON, and ATX-10 holds the same
25
+ * registry to the same bytes.
26
+ *
27
+ * Arrays keep their order: an array is data whose order is part of the value.
28
+ */
29
+ export function sortDeep(value) {
30
+ if (Array.isArray(value))
31
+ return value.map(sortDeep);
32
+ // `typeof null === 'object'`, and a null param is a value like any other.
33
+ if (typeof value !== 'object' || value === null)
34
+ return value;
35
+ const entries = Object.entries(value).sort(([a], [b]) => byCodeUnit(a, b));
36
+ return Object.fromEntries(entries.map(([k, v]) => [k, sortDeep(v)]));
37
+ }
13
38
  //# sourceMappingURL=order.js.map
@@ -679,10 +679,7 @@ export async function runArchiveApply(root, changeName, options = {}) {
679
679
  * The gate, plus what finishing the merge would need.
680
680
  *
681
681
  * 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.
682
+ * merge must act on **this** run's verdict (design §8).
686
683
  *
687
684
  * `merge` is absent exactly when there is nothing to act on: a rejected name, an
688
685
  * unreadable delta, or a registry that would not load.
@@ -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)) {
@@ -31,16 +31,11 @@ import { hasRecordedRed, recordedOutcome } from './red-record.js';
31
31
  export function statusRows(addedIds, plan, firstRun) {
32
32
  // Index the scenarios once, the way `validator.ts`'s `detectPotentialDrift`
33
33
  // does and for the reason recorded there: filtering the whole plan per
34
- // requirement is O(requirements x scenarios). The plan here is the *merged*
35
- // one, so the inner term is the whole repository's scenario count while the
36
- // outer is only what this change adds.
34
+ // requirement is O(requirements x scenarios), and the plan here is the
35
+ // *merged* one, so the inner term is the whole repository's scenario count.
37
36
  //
38
- // The cost is invisible today 20 added ids against 2000 scenarios is 40k
39
- // comparisons, well under a millisecond so this is filed as the
40
- // inconsistency it is rather than as a slow path. Two functions over one
41
- // shape held two beliefs about whether it is worth indexing; they hold one
42
- // now. Insertion order is plan order, which is file then line, so the
43
- // scenarios a row carries are still in the order their author reads them.
37
+ // Insertion order is plan order, which is file then line, so the scenarios a
38
+ // row carries stay in the order their author reads them.
44
39
  const byReqId = new Map();
45
40
  for (const s of plan.scenarios) {
46
41
  const group = byReqId.get(s.reqId);
@@ -16,32 +16,18 @@ export declare function inline(text: string): string;
16
16
  /**
17
17
  * Every C0 control except the newline, plus DEL and the C1 range, as a space.
18
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.
19
+ * A space rather than deletion, and the newline exempt by not being in the
20
+ * class: both are design §9.1, stated there because they hold for every artifact
21
+ * Attest writes rather than only for this one.
22
22
  *
23
- * The class is written out rather than computed per character. The previous
24
- * spelling spread the string into a per-code-point array, mapped and rejoined
25
- * three allocations proportional to the input, where the engine's own scan needs
26
- * none when nothing matches, which is the case every real registry is. Measured
27
- * over 120,000 characters: **27x** faster on plain ASCII, **54x** with
28
- * newlines, **84x** on CJK. A payload that is *entirely* control bytes is a
29
- * wash (0.9x), because then there is nothing to fast-path and both spellings
30
- * build a new string; both are linear either way, so this is a constant factor
31
- * rather than a second ATX-59.
23
+ * The one thing §9.1 does not reach: surrogates `D800`–`DFFF` fall outside every
24
+ * range below, so a pair is never touched and never split the case that makes
25
+ * a per-code-point rewrite look necessary when it is not.
32
26
  *
33
- * It is worth the change because the count scales with the registry while the
34
- * length scales with whatever the registry chose: `render`'s `sanitised` calls
27
+ * Written out rather than computed per character so the scan allocates nothing
28
+ * when nothing matches, which is what every real registry is; `render` calls
35
29
  * this once per statement, rationale, param key, param value and out-of-scope
36
- * entry, on the same reachable `render --check` path ATX-59 came off.
37
- *
38
- * **Byte-identical to the spelling it replaces**, which is the only thing that
39
- * mattered: checked exhaustively over every code unit in the BMP, and over
40
- * 200,000 randomised strings mixing control bytes, CJK, astral characters and
41
- * lone surrogates. Surrogates are the case the spread existed to get right —
42
- * `D800`–`DFFF` fall outside every range below, so neither spelling touches a
43
- * pair or splits one — and newlines survive here as they always did, by not
44
- * being in the class.
30
+ * entry, on the `render --check` path ATX-59 came off. Measured in `[0.7.0]`.
45
31
  */
46
32
  export declare function control(text: string): string;
47
33
  //# sourceMappingURL=terminal.d.ts.map