@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.
package/README.md CHANGED
@@ -12,9 +12,9 @@ The killer move against drift: values a requirement **promises** (timeouts,
12
12
  limits, budgets) live **once** in its `params`, and tests read them from there —
13
13
  so a number is physically impossible to drift between the spec and the assertion.
14
14
  Values that merely tune behaviour stay ordinary constants; nothing is owed to
15
- anyone when a tuning knob changes. A param may be a scalar or an array of
16
- scalars, so list-shaped constants (vendor blacklists, id sets) get the same
17
- single source as a lone number.
15
+ anyone when a tuning knob changes. A param may be any JSON value, so a
16
+ composite constant a vendor blacklist, a `kind -> payload` table — gets the
17
+ same single source as a lone number, which is where drift is worst.
18
18
 
19
19
  What that does not buy is a warning when you change the value. `check` runs
20
20
  nothing, so editing a param leaves it at `✓ No issues` — nothing became unbound,
@@ -157,7 +157,7 @@ Every diagnostic carries a `code`, and every code has a section in
157
157
  ```
158
158
  ERROR registry-not-static (requirements/upload.reqs.ts:5)
159
159
  Value is not a literal.
160
- → https://gitlab.com/Pseudorca/attest/-/blob/v0.7.0/docs/en/troubleshooting.md#registry-not-static
160
+ → https://gitlab.com/Pseudorca/attest/-/blob/v0.7.2/docs/en/troubleshooting.md#registry-not-static
161
161
  ```
162
162
 
163
163
  The anchor **is** the code, so the link cannot point somewhere the section
package/bin/attest.js CHANGED
File without changes
@@ -0,0 +1,48 @@
1
+ import { type JsonCommand, type JsonReport } from './json.js';
2
+ /** What an action produces: the machine report plus how to render it for humans. */
3
+ export interface Rendered {
4
+ report: JsonReport;
5
+ human: () => void;
6
+ }
7
+ export interface ActionOptions {
8
+ json?: boolean;
9
+ }
10
+ /**
11
+ * Send everything written to `process.stdout` to stderr until the returned
12
+ * function is called.
13
+ *
14
+ * This is what keeps the `--json` promise against output Attest does not write
15
+ * — the third obligation on the machine surface in design §9.1, stated there
16
+ * because it belongs to the command shell rather than to any emitter, and
17
+ * attested by ATX-64. The child Vitest run of `verify`/`archive` executes
18
+ * project code,
19
+ * and a bare `process.stdout.write` in a spec reaches this stream — `silent:
20
+ * true` only suppresses Vitest's *console interception*, which such a write
21
+ * never enters. Measured: the child's writes do arrive through the parent's
22
+ * `process.stdout.write` rather than through a separately inherited descriptor,
23
+ * which is why patching it here is sufficient and a change of Vitest pool is
24
+ * the thing that could quietly make it insufficient. The scenario under ATX-64
25
+ * runs a real child run for that reason, rather than asserting on this function
26
+ * with a stub.
27
+ *
28
+ * Diverted, not discarded: a human reading a red pipeline still needs the run
29
+ * output, and stderr is the stream no `--json` consumer parses. It is not
30
+ * sanitised on the way — `verify` executes project code by design, so painting
31
+ * the terminal is not a capability this path lacked (design §9.1 scopes its
32
+ * guarantee to what Attest itself writes).
33
+ */
34
+ export declare function divertStdout(): () => void;
35
+ /**
36
+ * Run one command's work with a single output + exit-code contract:
37
+ * - success: print the JSON report (`--json`) or the human rendering, and set
38
+ * the exit code from `report.ok` — the one source of the verdict.
39
+ * - failure: under `--json`, still emit one parseable `internal-error`
40
+ * envelope on stdout; otherwise print the stack on stderr. Exit code 1.
41
+ *
42
+ * Under `--json` the action runs with stdout diverted, so the report is the
43
+ * only thing on that stream. The divert is released before either rendering
44
+ * runs — the report has to reach the real stdout, and the human path was never
45
+ * under the promise.
46
+ */
47
+ export declare function runAction(command: JsonCommand, opts: ActionOptions, action: () => Promise<Rendered>): Promise<void>;
48
+ //# sourceMappingURL=action.d.ts.map
@@ -0,0 +1,100 @@
1
+ // The convergence point every command passes through: one output, one exit
2
+ // code (design §9). It lives here rather than in `cli/index.ts` because that
3
+ // module calls `program.parseAsync()` at import time — importing it to test the
4
+ // contract would run the CLI. The interface is the test surface, so the
5
+ // contract moved to where a scenario can cross the same seam a command does.
6
+ import { renderJson, errorReport } from './json.js';
7
+ import { formatCrash } from './report.js';
8
+ import { packageVersion } from '../core/version.js';
9
+ const VERSION = packageVersion();
10
+ /**
11
+ * Send everything written to `process.stdout` to stderr until the returned
12
+ * function is called.
13
+ *
14
+ * This is what keeps the `--json` promise against output Attest does not write
15
+ * — the third obligation on the machine surface in design §9.1, stated there
16
+ * because it belongs to the command shell rather than to any emitter, and
17
+ * attested by ATX-64. The child Vitest run of `verify`/`archive` executes
18
+ * project code,
19
+ * and a bare `process.stdout.write` in a spec reaches this stream — `silent:
20
+ * true` only suppresses Vitest's *console interception*, which such a write
21
+ * never enters. Measured: the child's writes do arrive through the parent's
22
+ * `process.stdout.write` rather than through a separately inherited descriptor,
23
+ * which is why patching it here is sufficient and a change of Vitest pool is
24
+ * the thing that could quietly make it insufficient. The scenario under ATX-64
25
+ * runs a real child run for that reason, rather than asserting on this function
26
+ * with a stub.
27
+ *
28
+ * Diverted, not discarded: a human reading a red pipeline still needs the run
29
+ * output, and stderr is the stream no `--json` consumer parses. It is not
30
+ * sanitised on the way — `verify` executes project code by design, so painting
31
+ * the terminal is not a capability this path lacked (design §9.1 scopes its
32
+ * guarantee to what Attest itself writes).
33
+ */
34
+ export function divertStdout() {
35
+ const stream = process.stdout;
36
+ // The prior *state* of the property, not the function it held. `write` is
37
+ // inherited from `Writable.prototype`, so the patch below is a new own
38
+ // property and undoing it means removing that property — assigning the old
39
+ // function back would leave a bound copy shadowing the prototype forever, and
40
+ // would silently swallow anyone else's patch on a nested divert.
41
+ const owned = Object.getOwnPropertyDescriptor(stream, 'write');
42
+ const divert = (chunk, encoding, callback) => typeof encoding === 'function'
43
+ ? process.stderr.write(chunk, encoding)
44
+ : process.stderr.write(chunk, encoding, callback);
45
+ stream.write = divert;
46
+ return () => {
47
+ if (owned)
48
+ Object.defineProperty(stream, 'write', owned);
49
+ else
50
+ Reflect.deleteProperty(stream, 'write');
51
+ };
52
+ }
53
+ /**
54
+ * Run one command's work with a single output + exit-code contract:
55
+ * - success: print the JSON report (`--json`) or the human rendering, and set
56
+ * the exit code from `report.ok` — the one source of the verdict.
57
+ * - failure: under `--json`, still emit one parseable `internal-error`
58
+ * envelope on stdout; otherwise print the stack on stderr. Exit code 1.
59
+ *
60
+ * Under `--json` the action runs with stdout diverted, so the report is the
61
+ * only thing on that stream. The divert is released before either rendering
62
+ * runs — the report has to reach the real stdout, and the human path was never
63
+ * under the promise.
64
+ */
65
+ export async function runAction(command, opts, action) {
66
+ try {
67
+ const { report, human } = await withStdoutDiverted(opts.json === true, action);
68
+ if (opts.json)
69
+ console.log(renderJson(report));
70
+ else
71
+ human();
72
+ process.exitCode = report.ok ? 0 : 1;
73
+ }
74
+ catch (err) {
75
+ if (opts.json) {
76
+ // Unsanitised on purpose: `JSON.stringify` escapes every C0 character,
77
+ // so these bytes cannot carry one out (see report.ts). That is a claim
78
+ // about what this line writes and nothing wider — what the project under
79
+ // test writes is the divert's business, above.
80
+ console.log(renderJson(errorReport(VERSION, command, err)));
81
+ }
82
+ else {
83
+ console.error(formatCrash(err));
84
+ }
85
+ process.exitCode = 1;
86
+ }
87
+ }
88
+ /** Run `action` with stdout diverted when `divert`, restoring on every path. */
89
+ async function withStdoutDiverted(divert, action) {
90
+ if (!divert)
91
+ return action();
92
+ const release = divertStdout();
93
+ try {
94
+ return await action();
95
+ }
96
+ finally {
97
+ release();
98
+ }
99
+ }
100
+ //# sourceMappingURL=action.js.map
package/dist/cli/index.js CHANGED
@@ -7,14 +7,22 @@
7
7
  // The exit code is always derived from that report's `ok`, so the JSON verdict
8
8
  // and the process status can never disagree — including on the crash path,
9
9
  // where the report is an `internal-error` envelope instead of a bare stack.
10
+ //
11
+ // Both halves of that promise are kept by `runAction` (cli/action.ts), which is
12
+ // where the contract is stated and tested; this file only supplies the work.
10
13
  import { Command } from 'commander';
11
14
  import chalk from 'chalk';
12
15
  import { resolve } from 'node:path';
13
16
  import { writeAtomic } from '../core/write.js';
14
17
  import { runCheck, runVerify, runCover, runArchive, runArchiveApply, runInit, runRender, runRenderCheck, runStatus, DEFAULT_TARGET, TARGET_NAMES, } from '../core/pipeline.js';
15
18
  import { hasError } from '../core/types.js';
19
+ // The one project-derived value this shell still interpolates itself. `--out`
20
+ // is argv, which on a fork MR pipeline is written by the repository's own CI
21
+ // config — so it is the project's text, not the operator's (ATX-37).
22
+ import { inline } from '../core/terminal.js';
23
+ import { runAction } from './action.js';
16
24
  import { formatArchiveVerdict, formatCrash, formatIssues, formatScope, summarize, formatCoverage, formatStatus, } from './report.js';
17
- import { archiveReport, checkReport, coverReport, errorReport, initReport, renderJson, renderReport, statusReport, verifyReport, } from './json.js';
25
+ import { archiveReport, checkReport, coverReport, initReport, renderReport, statusReport, verifyReport, } from './json.js';
18
26
  import { packageVersion } from '../core/version.js';
19
27
  const VERSION = packageVersion();
20
28
  /** `runArchive` in the shape `--apply` returns, so the two share one call site. */
@@ -47,34 +55,6 @@ const VITEST_CONFIG_HELP = 'load this Vitest config in the child run (transforms
47
55
  function vitestConfig(opts) {
48
56
  return opts.vitestConfig ? resolve(process.cwd(), opts.vitestConfig) : undefined;
49
57
  }
50
- /**
51
- * Run one command's work with a single output + exit-code contract:
52
- * - success: print the JSON report (`--json`) or the human rendering, and set
53
- * the exit code from `report.ok` — the one source of the verdict.
54
- * - failure: under `--json`, still emit one parseable `internal-error`
55
- * envelope on stdout; otherwise print the stack on stderr. Exit code 1.
56
- */
57
- async function runAction(command, opts, action) {
58
- try {
59
- const { report, human } = await action();
60
- if (opts.json)
61
- console.log(renderJson(report));
62
- else
63
- human();
64
- process.exitCode = report.ok ? 0 : 1;
65
- }
66
- catch (err) {
67
- if (opts.json) {
68
- // Unsanitised on purpose: `JSON.stringify` escapes every C0 character, so
69
- // the machine surface was never the exposed one (see report.ts).
70
- console.log(renderJson(errorReport(VERSION, command, err)));
71
- }
72
- else {
73
- console.error(formatCrash(err));
74
- }
75
- process.exitCode = 1;
76
- }
77
- }
78
58
  program
79
59
  .command('check')
80
60
  .description('Static structural validation (fast CI pre-check); runs no project code.')
@@ -168,7 +148,7 @@ program
168
148
  report: renderReport(VERSION, issues, opts.out),
169
149
  human: () => {
170
150
  if (issues.length === 0)
171
- console.log(chalk.green(`✓ ${opts.out} is up to date.`));
151
+ console.log(chalk.green(`✓ ${inline(opts.out ?? '')} is up to date.`));
172
152
  else {
173
153
  console.log(formatIssues(issues));
174
154
  console.log(summarize(issues));
@@ -188,7 +168,7 @@ program
188
168
  console.log(summarize(issues));
189
169
  }
190
170
  else if (dest) {
191
- console.log(chalk.green(`✓ Wrote ${opts.out}`));
171
+ console.log(chalk.green(`✓ Wrote ${inline(opts.out ?? '')}`));
192
172
  }
193
173
  else {
194
174
  process.stdout.write(markdown);
@@ -261,7 +241,6 @@ program
261
241
  console.log(summarize(result.issues));
262
242
  return;
263
243
  }
264
- console.log(chalk.bold(`Change: ${result.change}`));
265
244
  console.log(formatStatus(result));
266
245
  },
267
246
  };
@@ -166,7 +166,15 @@ const STATE_MARK = {
166
166
  * the argument that keeps `render --check` comparing documents rather than bytes.
167
167
  */
168
168
  export function formatStatus(result) {
169
- const lines = [];
169
+ // The header is built here rather than in the CLI action, and that is a fix
170
+ // rather than a tidy-up. It was interpolated and printed in `cli/index.ts`,
171
+ // which put it past the sanitiser — three lines above a closing line that
172
+ // puts the same value through `inline`. `ATX-37` covers "everything the CLI
173
+ // writes to a terminal", and a write that lives in the shell can only be
174
+ // attested by spawning a process, because `cli/index.ts` runs the CLI at
175
+ // import. Moving the line to the module that already owns this report makes
176
+ // the obligation reachable by a scenario — the interface is the test surface.
177
+ const lines = [chalk.bold(`Change: ${inline(result.change)}`)];
170
178
  if (result.rows.length === 0) {
171
179
  lines.push(chalk.dim('(this change adds no requirements)'));
172
180
  }
@@ -3,7 +3,7 @@
3
3
  // Map<id, Requirement>, with content-compare on ADDED and already-synced
4
4
  // no-ops on RENAMED.
5
5
  import { RequirementIdSchema, RequirementSchema } from './schema.js';
6
- import { byCodeUnit } from './order.js';
6
+ import { byCodeUnit, sortDeep } from './order.js';
7
7
  /**
8
8
  * The ids a delta ADDs — the scope of the first-red obligation (design §6).
9
9
  *
@@ -185,16 +185,13 @@ function canonical(req) {
185
185
  return JSON.stringify({
186
186
  statement: req.statement,
187
187
  rationale: req.rationale,
188
- params: sortKeys(req.params),
188
+ // Deep, not one level: a param is a JSON value, so the nested keys of a
189
+ // kind -> payload table are as much a part of this string as the top-level
190
+ // ones, and the failure is identical one level down — an identical copy
191
+ // written with its inner keys in another order reported as `add-conflict`
192
+ // against itself. Code-unit order throughout, and `sortDeep` says why.
193
+ params: sortDeep(req.params),
189
194
  outOfScope: [...req.outOfScope],
190
195
  });
191
196
  }
192
- // Code-unit order, not localeCompare: this string is a *verdict input*.
193
- // localeCompare calls some distinct keys equal, and a stable sort then leaves
194
- // them in insertion order — so the canonical form would encode how the params
195
- // happened to be written, and `add-conflict` would report a requirement as
196
- // conflicting with an identical copy of itself.
197
- function sortKeys(obj) {
198
- return Object.fromEntries(Object.entries(obj).sort(([a], [b]) => byCodeUnit(a, b)));
199
- }
200
197
  //# sourceMappingURL=apply.js.map
@@ -8,7 +8,7 @@
8
8
  * and the `##` headings of both language documents, so landing here cannot
9
9
  * produce a dead link.
10
10
  */
11
- export declare const ISSUE_CODES: readonly ["add-conflict", "add-invalid", "added-id-unmerged", "apply-no-prefix-owner", "apply-unsupported-delta", "change-not-found", "compiler-unsupported", "declared-not-run", "duplicate-prefix", "duplicate-requirement", "empty-spec", "internal-error", "invalid-change-name", "missing-spec-doc", "modify-invalid", "modify-missing", "never-red", "orphan-test", "possible-drift", "proposed-spec-name-taken", "proposed-spec-unclaimed", "rationale-placeholder", "registry-invalid", "registry-no-default", "registry-not-static", "rename-source-missing", "rename-target-exists", "rename-target-invalid", "spec-in-change-dir", "spec-load-failed", "stale-spec-doc", "tests-red", "unbound-param", "uncovered-requirement", "unknown-target"];
11
+ export declare const ISSUE_CODES: readonly ["add-conflict", "add-invalid", "added-id-unmerged", "apply-no-prefix-owner", "apply-unsupported-delta", "change-not-found", "compiler-unsupported", "declared-not-run", "duplicate-prefix", "duplicate-requirement", "empty-spec", "internal-error", "invalid-change-name", "missing-spec-doc", "modify-invalid", "modify-missing", "never-red", "non-scalar-interpolation", "orphan-test", "possible-drift", "proposed-spec-name-taken", "proposed-spec-unclaimed", "rationale-placeholder", "registry-invalid", "registry-no-default", "registry-not-static", "rename-source-missing", "rename-target-exists", "rename-target-invalid", "spec-in-change-dir", "spec-load-failed", "stale-spec-doc", "tests-red", "unbound-param", "uncovered-requirement", "unknown-target", "unreadable-file"];
12
12
  export type IssueCode = (typeof ISSUE_CODES)[number];
13
13
  /**
14
14
  * The page explaining `code`, or `undefined` when nothing explains it.
package/dist/core/docs.js CHANGED
@@ -36,6 +36,7 @@ export const ISSUE_CODES = [
36
36
  'modify-invalid',
37
37
  'modify-missing',
38
38
  'never-red',
39
+ 'non-scalar-interpolation',
39
40
  'orphan-test',
40
41
  'possible-drift',
41
42
  'proposed-spec-name-taken',
@@ -54,6 +55,7 @@ export const ISSUE_CODES = [
54
55
  'unbound-param',
55
56
  'uncovered-requirement',
56
57
  'unknown-target',
58
+ 'unreadable-file',
57
59
  ];
58
60
  const DOCUMENTED = new Set(ISSUE_CODES);
59
61
  const REPO = 'https://gitlab.com/Pseudorca/attest/-/blob';
@@ -141,22 +141,21 @@ export declare function idPrefix(id: string): string;
141
141
  * displayed, they become the child run's `include` globs, where a Windows
142
142
  * separator would silently match nothing.
143
143
  *
144
- * Each source is parsed as it arrives rather than after all of them. The
145
- * previous `Promise.all(files.map(readFile))` held every spec file in memory at
146
- * once measured at ~47 MiB on a synthetic tree of 6000 files for input size
147
- * that is not ours to choose, since `check` is the command this project tells
148
- * people to run first on an untrusted fork MR. Parsing at the point of arrival
149
- * makes the peak `PARSE_CONCURRENCY` sources instead of `files.length`, and the
150
- * plan is the only thing that still grows with the tree.
144
+ * Each source is parsed as it arrives rather than after all of them, so the peak
145
+ * is `PARSE_CONCURRENCY` sources rather than `files.length` and the plan is the
146
+ * only thing still growing with the tree. Reading them all first is the shape to
147
+ * avoid: input size is not ours to choose here, `check` being what this project
148
+ * tells people to run first on an untrusted fork MR. Measured in `[0.7.0]`.
151
149
  *
152
150
  * `findFiles` above is deliberately left unbounded: its fan-out is real, but the
153
151
  * failure it invites is descriptor exhaustion, which no measurement on either
154
152
  * development platform could produce (see CHANGELOG.md, `Under consideration`).
155
153
  * The memory here needed no such evidence — it is arithmetic, and portable.
156
154
  */
157
- export declare function parseSpecs(files: string[], displayRoot: string): Promise<AttestPlan>;
158
- /** Parse every `*.spec.ts` under root into one merged plan (file paths shown relative to root). */
159
- export declare function parseAllSpecFiles(root: string): Promise<AttestPlan>;
155
+ export declare function parseSpecs(files: string[], displayRoot: string): Promise<{
156
+ plan: AttestPlan;
157
+ issues: Issue[];
158
+ }>;
160
159
  /**
161
160
  * Spec-shaped files sitting under `root/changes` — the location a change's
162
161
  * specs used to live at, and which nothing walks any more (design §7).
@@ -95,6 +95,43 @@ export async function scanProject(root) {
95
95
  }
96
96
  return { reqsFiles, specFiles, proposedSpecFiles };
97
97
  }
98
+ /**
99
+ * The message an unreadable file gets, in one place because two call sites
100
+ * raise it — a registry and a spec — and they must not drift into two
101
+ * descriptions of one condition (ATX-65).
102
+ */
103
+ function unreadableIssue(err) {
104
+ // `RangeError` in practice, from the call stack running out inside
105
+ // TypeScript's recursive-descent parser. Caught as `unknown` rather than
106
+ // narrowed to it: the guard's promise is that *no* throw from one file ends
107
+ // the run, and narrowing would make that promise true only of the one trigger
108
+ // that has been measured.
109
+ const detail = err instanceof Error ? err.message : String(err);
110
+ return {
111
+ level: 'ERROR',
112
+ code: 'unreadable-file',
113
+ message: `Could not be read, so nothing in it was checked: ${detail}. ` +
114
+ `Everything else in this run was still reported.`,
115
+ };
116
+ }
117
+ /**
118
+ * `reader.read`, with a throw turned into an issue about that file.
119
+ *
120
+ * At the loop rather than inside either reader, because what is being kept is a
121
+ * property of the *run* — one file's failure is not the run's failure — and
122
+ * both readers need it. `readRegistry` folds these concurrently, so an
123
+ * uncaught throw here rejects the whole `Promise.all` and ends the command:
124
+ * measured, a 20,000-deep literal in one `params` value reduced `attest check`
125
+ * to a single `internal-error` (design §5.1, ATX-65).
126
+ */
127
+ async function readGuarded(reader, file) {
128
+ try {
129
+ return await reader.read(file);
130
+ }
131
+ catch (err) {
132
+ return { issue: unreadableIssue(err) };
133
+ }
134
+ }
98
135
  /**
99
136
  * Read registries by **executing** the module through the Vite loader.
100
137
  *
@@ -207,7 +244,7 @@ export async function loadRegistry(root, reader, files) {
207
244
  const paths = files ?? (await scanProject(root)).reqsFiles;
208
245
  // Read the files concurrently, then fold the results in sorted file order:
209
246
  // the issue list stays deterministic regardless of which one finished first.
210
- const loaded = await Promise.all(paths.map(async (file) => ({ file, outcome: await reader.read(file) })));
247
+ const loaded = await Promise.all(paths.map(async (file) => ({ file, outcome: await readGuarded(reader, file) })));
211
248
  const registry = {};
212
249
  const issues = [];
213
250
  const unreadableFiles = [];
@@ -294,13 +331,11 @@ const PARSE_CONCURRENCY = 32;
294
331
  * displayed, they become the child run's `include` globs, where a Windows
295
332
  * separator would silently match nothing.
296
333
  *
297
- * Each source is parsed as it arrives rather than after all of them. The
298
- * previous `Promise.all(files.map(readFile))` held every spec file in memory at
299
- * once measured at ~47 MiB on a synthetic tree of 6000 files for input size
300
- * that is not ours to choose, since `check` is the command this project tells
301
- * people to run first on an untrusted fork MR. Parsing at the point of arrival
302
- * makes the peak `PARSE_CONCURRENCY` sources instead of `files.length`, and the
303
- * plan is the only thing that still grows with the tree.
334
+ * Each source is parsed as it arrives rather than after all of them, so the peak
335
+ * is `PARSE_CONCURRENCY` sources rather than `files.length` and the plan is the
336
+ * only thing still growing with the tree. Reading them all first is the shape to
337
+ * avoid: input size is not ours to choose here, `check` being what this project
338
+ * tells people to run first on an untrusted fork MR. Measured in `[0.7.0]`.
304
339
  *
305
340
  * `findFiles` above is deliberately left unbounded: its fan-out is real, but the
306
341
  * failure it invites is descriptor exhaustion, which no measurement on either
@@ -311,12 +346,24 @@ export async function parseSpecs(files, displayRoot) {
311
346
  // Indexed rather than appended, so the merge below follows the input order
312
347
  // whatever order the reads finish in.
313
348
  const parsed = new Array(files.length);
349
+ // Same index space, so a file contributes either a parse or an issue and the
350
+ // two lists cannot disagree about which file is which.
351
+ const failures = new Array(files.length);
314
352
  let next = 0;
315
353
  const worker = async () => {
316
354
  for (let i = next++; i < files.length; i = next++) {
317
355
  const file = files[i];
318
- const source = await readFile(file, 'utf8');
319
- parsed[i] = parseSpecFile(relativePath(displayRoot, file), source);
356
+ const display = relativePath(displayRoot, file);
357
+ // Per file, for the reason `readGuarded` exists above: these run
358
+ // concurrently, so one throw rejects the whole `Promise.all` and takes
359
+ // the command with it. A hostile spec scraps only itself (ATX-65).
360
+ try {
361
+ parsed[i] = parseSpecFile(display, await readFile(file, 'utf8'));
362
+ }
363
+ catch (err) {
364
+ parsed[i] = { scenarios: [], paramRefs: [] };
365
+ failures[i] = { ...unreadableIssue(err), file: display };
366
+ }
320
367
  }
321
368
  };
322
369
  await Promise.all(Array.from({ length: Math.min(PARSE_CONCURRENCY, files.length) }, worker));
@@ -325,11 +372,7 @@ export async function parseSpecs(files, displayRoot) {
325
372
  plan.scenarios.push(...one.scenarios);
326
373
  plan.paramRefs.push(...one.paramRefs);
327
374
  }
328
- return plan;
329
- }
330
- /** Parse every `*.spec.ts` under root into one merged plan (file paths shown relative to root). */
331
- export async function parseAllSpecFiles(root) {
332
- return parseSpecs(await findFiles(root, isSpecFile), root);
375
+ return { plan, issues: failures.filter((i) => i !== undefined) };
333
376
  }
334
377
  /**
335
378
  * Spec-shaped files sitting under `root/changes` — the location a change's
@@ -30,7 +30,7 @@
30
30
  // at runtime on the happy path, and a later reordering would look harmless.
31
31
  import { mkdir, readFile, rename, stat } from 'node:fs/promises';
32
32
  import { join, dirname, basename } from 'node:path';
33
- import { repointImport, spliceRequirements } from './splice.js';
33
+ import { repointImport, spliceRequirements, UnwritableValue } from './splice.js';
34
34
  import { writeAtomic } from './write.js';
35
35
  import { idPrefix } from './locate.js';
36
36
  import { addedIds } from './apply.js';
@@ -178,7 +178,32 @@ export async function applyMerge(input) {
178
178
  }
179
179
  for (const file of [...byFile.keys()].sort(byCodeUnit)) {
180
180
  const source = await readFile(file, 'utf8');
181
- const spliced = spliceRequirements(file, source, byFile.get(file));
181
+ let spliced;
182
+ try {
183
+ spliced = spliceRequirements(file, source, byFile.get(file));
184
+ }
185
+ catch (err) {
186
+ // The emitter refused a value it cannot write as source — today only a
187
+ // `__proto__` param key, which the schema rejects before `--apply` runs.
188
+ // Caught rather than left to the CLI's crash envelope so the account of
189
+ // what this merge had already written survives: `--apply` is destructive
190
+ // and half a merge reported as a bare stack is the shape a resume cannot
191
+ // read. The write for *this* file has not happened — the throw is in the
192
+ // text generation, above `writeAtomic`.
193
+ if (!(err instanceof UnwritableValue))
194
+ throw err;
195
+ return {
196
+ issues: [
197
+ {
198
+ level: 'ERROR',
199
+ code: 'internal-error',
200
+ file: relativePath(root, file),
201
+ message: `${relativePath(root, file)} could not be written: ${err.message}.`,
202
+ },
203
+ ],
204
+ written,
205
+ };
206
+ }
182
207
  if (spliced === undefined) {
183
208
  // Unreachable through the command — the gate read this file as a literal
184
209
  // moments ago — so it is reported as the internal inconsistency it is
@@ -1,3 +1,20 @@
1
1
  /** Compare by UTF-16 code unit — the same order as a bare `Array#sort()`. */
2
2
  export declare function byCodeUnit(a: string, b: string): number;
3
+ /**
4
+ * A JSON value with every object's keys in code-unit order, at every depth.
5
+ *
6
+ * `JSON.stringify` writes object keys in insertion order, which is *how the
7
+ * source happened to be written* — the thing this module exists to keep out of
8
+ * anything compared or committed. Two callers need the same guarantee for the
9
+ * same reason, one depth apart:
10
+ *
11
+ * - `apply.ts` canonicalises a requirement to decide `add-conflict`, so an
12
+ * identical copy written with its keys in another order must not read as a
13
+ * conflict with itself.
14
+ * - `render.ts` emits a structured param as JSON, and ATX-10 holds the same
15
+ * registry to the same bytes.
16
+ *
17
+ * Arrays keep their order: an array is data whose order is part of the value.
18
+ */
19
+ export declare function sortDeep<T>(value: T): T;
3
20
  //# sourceMappingURL=order.d.ts.map
@@ -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