@am_shork/attest 0.7.1 → 0.7.3

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
@@ -29,6 +29,15 @@ together and the test stays green through any edit. Pin the expectation to
29
29
  something that does not move with the param — a fixture, a literal in the test,
30
30
  or a second independently derived value.
31
31
 
32
+ A composite param has a second failure of its own, and it runs the other way. When
33
+ a test **loops over** a list it read from `params`, that list is the set of cases
34
+ the run covers: drop a member and every assertion inside the loop still passes
35
+ over what is left, so the suite quietly tests less with nothing to show for it.
36
+ Pin the extent beside the loop — the members against a literal when their identity
37
+ is the promise, the length when the size is. That literal is not the copy the
38
+ single source exists to prevent: it is not what the system is measured against,
39
+ it is what the intent claimed to cover.
40
+
32
41
  ## Prerequisites
33
42
 
34
43
  - Node ≥ 20.19
@@ -157,7 +166,7 @@ Every diagnostic carries a `code`, and every code has a section in
157
166
  ```
158
167
  ERROR registry-not-static (requirements/upload.reqs.ts:5)
159
168
  Value is not a literal.
160
- → https://gitlab.com/Pseudorca/attest/-/blob/v0.7.1/docs/en/troubleshooting.md#registry-not-static
169
+ → https://gitlab.com/Pseudorca/attest/-/blob/v0.7.3/docs/en/troubleshooting.md#registry-not-static
161
170
  ```
162
171
 
163
172
  The anchor **is** the code, so the link cannot point somewhere the section
@@ -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
  };
@@ -74,8 +74,9 @@ export declare function renderReport(version: string, issues: Issue[], outFile?:
74
74
  /**
75
75
  * `init`. Carries the paths written so a consumer learns where the instructions
76
76
  * went without hardcoding the convention. `issues` is empty on the success path
77
- * — `init` writes or throws — apart from an `unknown-target` ERROR, which is
78
- * refused before anything is written and so reports no files at all.
77
+ * — `init` writes or throws — apart from the two ERRORs it refuses on,
78
+ * `unknown-target` and `unsafe-target-path`. Both are decided before anything is
79
+ * written and so report no files at all.
79
80
  *
80
81
  * `outFile` survives beside `outFiles` for the one-file run, which is what the
81
82
  * default invocation still is: dropping it would break the consumer the field
package/dist/cli/json.js CHANGED
@@ -92,8 +92,9 @@ export function renderReport(version, issues, outFile) {
92
92
  /**
93
93
  * `init`. Carries the paths written so a consumer learns where the instructions
94
94
  * went without hardcoding the convention. `issues` is empty on the success path
95
- * — `init` writes or throws — apart from an `unknown-target` ERROR, which is
96
- * refused before anything is written and so reports no files at all.
95
+ * — `init` writes or throws — apart from the two ERRORs it refuses on,
96
+ * `unknown-target` and `unsafe-target-path`. Both are decided before anything is
97
+ * written and so report no files at all.
97
98
  *
98
99
  * `outFile` survives beside `outFiles` for the one-file run, which is what the
99
100
  * default invocation still is: dropping it would break the consumer the field
@@ -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
  }
@@ -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", "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"];
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", "unsafe-target-path"];
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
@@ -55,6 +55,8 @@ export const ISSUE_CODES = [
55
55
  'unbound-param',
56
56
  'uncovered-requirement',
57
57
  'unknown-target',
58
+ 'unreadable-file',
59
+ 'unsafe-target-path',
58
60
  ];
59
61
  const DOCUMENTED = new Set(ISSUE_CODES);
60
62
  const REPO = 'https://gitlab.com/Pseudorca/attest/-/blob';
@@ -60,38 +60,59 @@ const VITEST_STUB = 'export const describe=()=>{};export const it=()=>{};export
60
60
  'export default {};';
61
61
  export async function createLoader() {
62
62
  const dir = mkdtempSync(join(tmpdir(), 'attest-loader-'));
63
- const stub = join(dir, 'vitest-stub.mjs');
64
- writeFileSync(stub, VITEST_STUB);
65
- const server = await createServer({
66
- configFile: false,
67
- logLevel: 'error',
68
- // Not just quiet — sanitised. See `sanitisedLogger`: what survives
69
- // `logLevel: 'error'` is exactly the message that carries the checked
70
- // project's own text.
71
- customLogger: sanitisedLogger(),
72
- appType: 'custom',
73
- // `ws: false` is load-bearing, and `middlewareMode` is not enough on its
74
- // own: it suppresses the HTTP server but Vite still starts the HMR
75
- // WebSocket server, which binds `::` — every interface, not loopback — on
76
- // port 24678. Every `attest` command that reads a registry by evaluating it
77
- // therefore opened a network port for the length of the run, on a
78
- // developer's machine and on every CI runner, for a channel that does
79
- // nothing here: nothing subscribes to HMR, because nothing is watching.
80
- //
81
- // The visible symptom was the collision. Two attest processes at once —
82
- // ordinary in a CI matrix, and what this repo's own concurrent specs do —
83
- // and the second printed `WebSocket server error: Port is already in use`
84
- // into the middle of a report, through a `logLevel: 'error'` that was
85
- // supposed to have silenced the loader entirely.
86
- //
87
- // `hmr: false` does *not* close it (measured, Vite 5.4): the ws server is
88
- // created before the hmr option is consulted. `ws: false` is the one that
89
- // leaves no listening handle at all.
90
- server: { middlewareMode: true, ws: false },
91
- resolve: { alias: { vitest: stub } },
92
- ssr: { noExternal: ['vitest'] },
93
- optimizeDeps: { noDiscovery: true },
94
- });
63
+ // Everything between creating the directory and returning its owner runs
64
+ // inside this `try`, because until the caller holds the object below, nothing
65
+ // else can call `close` — so a throw here is the one path where the directory
66
+ // outlives the only code that knows about it. That is the same leak the
67
+ // comment on `close` records as already fixed, surviving on the other side of
68
+ // the same function.
69
+ //
70
+ // Stated over the window rather than over `createServer`, which is where it
71
+ // was found: `writeFileSync` is inside it too, and a disk that is full or a
72
+ // temp directory that turns read-only between the two calls reaches it without
73
+ // any project being involved.
74
+ let server;
75
+ try {
76
+ const stub = join(dir, 'vitest-stub.mjs');
77
+ writeFileSync(stub, VITEST_STUB);
78
+ server = await createServer({
79
+ configFile: false,
80
+ logLevel: 'error',
81
+ // Not just quiet sanitised. See `sanitisedLogger`: what survives
82
+ // `logLevel: 'error'` is exactly the message that carries the checked
83
+ // project's own text.
84
+ customLogger: sanitisedLogger(),
85
+ appType: 'custom',
86
+ // `ws: false` is load-bearing, and `middlewareMode` is not enough on its
87
+ // own: it suppresses the HTTP server but Vite still starts the HMR
88
+ // WebSocket server, which binds `::` every interface, not loopback on
89
+ // port 24678. Every `attest` command that reads a registry by evaluating
90
+ // it therefore opened a network port for the length of the run, on a
91
+ // developer's machine and on every CI runner, for a channel that does
92
+ // nothing here: nothing subscribes to HMR, because nothing is watching.
93
+ //
94
+ // The visible symptom was the collision. Two attest processes at once —
95
+ // ordinary in a CI matrix, and what this repo's own concurrent specs do —
96
+ // and the second printed `WebSocket server error: Port is already in use`
97
+ // into the middle of a report, through a `logLevel: 'error'` that was
98
+ // supposed to have silenced the loader entirely.
99
+ //
100
+ // `hmr: false` does *not* close it (measured, Vite 5.4): the ws server is
101
+ // created before the hmr option is consulted. `ws: false` is the one that
102
+ // leaves no listening handle at all.
103
+ server: { middlewareMode: true, ws: false },
104
+ resolve: { alias: { vitest: stub } },
105
+ ssr: { noExternal: ['vitest'] },
106
+ optimizeDeps: { noDiscovery: true },
107
+ });
108
+ }
109
+ catch (err) {
110
+ // The original error is what the caller has to see, so a cleanup that fails
111
+ // must not replace it. `force` already forgives a directory that is not
112
+ // there; this forgives one that will not go.
113
+ await rm(dir, { recursive: true, force: true }).catch(() => { });
114
+ throw err;
115
+ }
95
116
  let closed = false;
96
117
  return {
97
118
  scratchDir: dir,
@@ -111,7 +132,16 @@ export async function createLoader() {
111
132
  await server.close();
112
133
  }
113
134
  finally {
114
- await rm(dir, { recursive: true, force: true });
135
+ // Swallowed for the same reason as the `rm` on the failing path above,
136
+ // and it counts for more here: every caller closes from a `finally`, so
137
+ // a throw from this line replaces whatever the block was doing — the
138
+ // real error on the way out, or a *successful* return turned into a
139
+ // crash about a temp directory. What that reader would then be handed
140
+ // is a misdiagnosis pointing at their own correct files, which this
141
+ // repository already treats as worse than the gap it fills.
142
+ // Given up with it: the one signal a failed cleanup could have raised,
143
+ // which no caller could have acted on anyway.
144
+ await rm(dir, { recursive: true, force: true }).catch(() => { });
115
145
  }
116
146
  },
117
147
  };
@@ -152,9 +152,10 @@ export declare function idPrefix(id: string): string;
152
152
  * development platform could produce (see CHANGELOG.md, `Under consideration`).
153
153
  * The memory here needed no such evidence — it is arithmetic, and portable.
154
154
  */
155
- export declare function parseSpecs(files: string[], displayRoot: string): Promise<AttestPlan>;
156
- /** Parse every `*.spec.ts` under root into one merged plan (file paths shown relative to root). */
157
- 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
+ }>;
158
159
  /**
159
160
  * Spec-shaped files sitting under `root/changes` — the location a change's
160
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 = [];
@@ -309,12 +346,24 @@ export async function parseSpecs(files, displayRoot) {
309
346
  // Indexed rather than appended, so the merge below follows the input order
310
347
  // whatever order the reads finish in.
311
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);
312
352
  let next = 0;
313
353
  const worker = async () => {
314
354
  for (let i = next++; i < files.length; i = next++) {
315
355
  const file = files[i];
316
- const source = await readFile(file, 'utf8');
317
- 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
+ }
318
367
  }
319
368
  };
320
369
  await Promise.all(Array.from({ length: Math.min(PARSE_CONCURRENCY, files.length) }, worker));
@@ -323,11 +372,7 @@ export async function parseSpecs(files, displayRoot) {
323
372
  plan.scenarios.push(...one.scenarios);
324
373
  plan.paramRefs.push(...one.paramRefs);
325
374
  }
326
- return plan;
327
- }
328
- /** Parse every `*.spec.ts` under root into one merged plan (file paths shown relative to root). */
329
- export async function parseAllSpecFiles(root) {
330
- return parseSpecs(await findFiles(root, isSpecFile), root);
375
+ return { plan, issues: failures.filter((i) => i !== undefined) };
331
376
  }
332
377
  /**
333
378
  * Spec-shaped files sitting under `root/changes` — the location a change's
@@ -12,4 +12,20 @@
12
12
  export declare function toPosixPath(path: string, separator?: string): string;
13
13
  /** `path.relative`, in the one spelling the rest of the engine expects. */
14
14
  export declare function relativePath(from: string, to: string): string;
15
+ /**
16
+ * Whether `path` is `root` or sits below it, both given as resolved paths.
17
+ *
18
+ * Asked through `relativePath` rather than by comparing prefixes, because the
19
+ * two ways this goes wrong are both invisible in a `startsWith`. A sibling
20
+ * directory shares the prefix — `/repo-backup` starts with `/repo` — and on
21
+ * Windows a path on another drive has *no* relative spelling at all, so
22
+ * `path.relative` answers with an absolute one rather than a chain of `..`.
23
+ * Testing the relative form catches both: an escape is either `..`-led or
24
+ * absolute, and nothing else is.
25
+ *
26
+ * The caller owes the resolution. Nothing here follows a symbolic link, so a
27
+ * path that is lexically inside can still be physically outside — that is the
28
+ * question `join` cannot answer and this function does not pretend to.
29
+ */
30
+ export declare function isInside(root: string, path: string): boolean;
15
31
  //# sourceMappingURL=paths.d.ts.map
@@ -14,7 +14,7 @@
14
14
  // Normalising here rather than at the glob has a second dividend: a report is
15
15
  // then byte-identical across platforms, so a `--json` consumer diffing two CI
16
16
  // runs is not reading the runner's operating system.
17
- import { relative, sep } from 'node:path';
17
+ import { isAbsolute, relative, sep } from 'node:path';
18
18
  /**
19
19
  * A native path as a POSIX one.
20
20
  *
@@ -33,4 +33,23 @@ export function toPosixPath(path, separator = sep) {
33
33
  export function relativePath(from, to) {
34
34
  return toPosixPath(relative(from, to));
35
35
  }
36
+ /**
37
+ * Whether `path` is `root` or sits below it, both given as resolved paths.
38
+ *
39
+ * Asked through `relativePath` rather than by comparing prefixes, because the
40
+ * two ways this goes wrong are both invisible in a `startsWith`. A sibling
41
+ * directory shares the prefix — `/repo-backup` starts with `/repo` — and on
42
+ * Windows a path on another drive has *no* relative spelling at all, so
43
+ * `path.relative` answers with an absolute one rather than a chain of `..`.
44
+ * Testing the relative form catches both: an escape is either `..`-led or
45
+ * absolute, and nothing else is.
46
+ *
47
+ * The caller owes the resolution. Nothing here follows a symbolic link, so a
48
+ * path that is lexically inside can still be physically outside — that is the
49
+ * question `join` cannot answer and this function does not pretend to.
50
+ */
51
+ export function isInside(root, path) {
52
+ const rel = relativePath(root, path);
53
+ return rel !== '..' && !rel.startsWith('../') && !isAbsolute(rel);
54
+ }
36
55
  //# sourceMappingURL=paths.js.map