@am_shork/attest 0.7.4 → 0.9.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/README.md CHANGED
@@ -125,7 +125,8 @@ attest verify # run tests + coverage + drift, graded report
125
125
  attest cover # which requirements lack a scenario
126
126
  attest render # the requirements as Markdown, for people who don't read TS
127
127
  attest archive <change> # gate a proposed change: green + covered + no drift
128
- attest status <change> # what that gate still wants, without running anything
128
+ attest status <change> # per added id: scenario written? seen red? (part of that
129
+ # gate, without running anything — never a verdict)
129
130
  ```
130
131
 
131
132
  Every command takes the project root as an optional last argument, and `--json`
@@ -166,7 +167,7 @@ Every diagnostic carries a `code`, and every code has a section in
166
167
  ```
167
168
  ERROR registry-not-static (requirements/upload.reqs.ts:5)
168
169
  Value is not a literal.
169
- → https://gitlab.com/Pseudorca/attest/-/blob/v0.7.4/docs/en/troubleshooting.md#registry-not-static
170
+ → https://gitlab.com/Pseudorca/attest/-/blob/v0.9.0/docs/en/troubleshooting.md#registry-not-static
170
171
  ```
171
172
 
172
173
  The anchor **is** the code, so the link cannot point somewhere the section
package/dist/cli/index.js CHANGED
@@ -13,7 +13,6 @@
13
13
  import { Command } from 'commander';
14
14
  import chalk from 'chalk';
15
15
  import { resolve } from 'node:path';
16
- import { writeAtomic } from '../core/write.js';
17
16
  import { runCheck, runVerify, runCover, runArchive, runArchiveApply, runInit, runRender, runRenderCheck, runStatus, DEFAULT_TARGET, TARGET_NAMES, } from '../core/pipeline.js';
18
17
  import { hasError } from '../core/types.js';
19
18
  // The one project-derived value this shell still interpolates itself. `--out`
@@ -135,12 +134,16 @@ program
135
134
  if (opts.json && !opts.out && !opts.check) {
136
135
  cmd.error('error: --json requires --out <file> (stdout cannot carry both the document and the report)');
137
136
  }
138
- const dest = opts.out ? resolve(process.cwd(), opts.out) : undefined;
137
+ // Both spellings of the destination, because both are needed downstream: the
138
+ // resolved one is what the filesystem is given, the typed one is what a
139
+ // diagnostic echoes. Where it may point, and the write itself, are the
140
+ // pipeline's — this shell decides what to print and nothing else.
141
+ const out = opts.out ? { file: resolve(process.cwd(), opts.out), display: opts.out } : undefined;
139
142
  return runAction('render', opts, async () => {
140
143
  if (opts.check) {
141
144
  // The fix hint has to be the command the user can actually re-run from
142
145
  // here: their own --out string, and the [dir] they passed.
143
- const issues = await runRenderCheck(root(dir), dest, {
146
+ const issues = await runRenderCheck(root(dir), out.file, {
144
147
  display: opts.out,
145
148
  command: `attest render${dir ? ` ${dir}` : ''} --out ${opts.out}`,
146
149
  }, { evaluate: opts.eval });
@@ -156,10 +159,10 @@ program
156
159
  },
157
160
  };
158
161
  }
159
- const { markdown, issues } = await runRender(root(dir), { evaluate: opts.eval });
160
- // Never overwrite a good document with the output of a broken registry.
161
- if (!hasError(issues) && dest)
162
- await writeAtomic(dest, markdown);
162
+ const { markdown, issues, wrote } = await runRender(root(dir), {
163
+ evaluate: opts.eval,
164
+ out,
165
+ });
163
166
  return {
164
167
  report: renderReport(VERSION, issues, opts.out),
165
168
  human: () => {
@@ -167,7 +170,7 @@ program
167
170
  console.log(formatIssues(issues));
168
171
  console.log(summarize(issues));
169
172
  }
170
- else if (dest) {
173
+ else if (wrote) {
171
174
  console.log(chalk.green(`✓ Wrote ${inline(opts.out ?? '')}`));
172
175
  }
173
176
  else {
@@ -100,6 +100,31 @@ export declare function statusReport(version: string, result: StatusResult): Jso
100
100
  * empty stdout and a stack trace on stderr.
101
101
  */
102
102
  export declare function errorReport(version: string, command: JsonCommand, err: unknown): JsonReport;
103
- /** Serialize a report for stdout. Pretty-printed: still one parseable document. */
103
+ /**
104
+ * Serialize a report for stdout. Pretty-printed: still one parseable document.
105
+ *
106
+ * Every string in it loses its control characters first (design §9.1). The
107
+ * machine surface was recorded as exempt because `JSON.stringify` escapes every
108
+ * C0 character — true, and narrower than the rule: §9.1's class also holds DEL
109
+ * and the C1 range, and `JSON.stringify` emits those verbatim. `U+009B` is
110
+ * 8-bit CSI, the same introducer as `ESC [` on a terminal that reads
111
+ * UTF-8-decoded C1 as control — so a scenario name or a thrown message can
112
+ * drive the terminal of someone reading the JSON rather than parsing it, from
113
+ * `check --json`, which executes nothing.
114
+ *
115
+ * Applied here rather than at the report builders, and that is §9.1's own
116
+ * distinction rather than a preference: this is a *document*, not a stream. It
117
+ * has one point of assembly, before any of it is seen, so an obligation sitting
118
+ * there covers a field nobody has added yet — as `outFiles` and `docsUrl` both
119
+ * were. The terminal path is sanitised at each write site for the opposite
120
+ * reason, recorded in core/terminal.ts.
121
+ *
122
+ * A replacer rather than a walk over the fields that carry project text —
123
+ * `issues[].message`, `issues[].file`, `change`, `outFile`, `outFiles` today —
124
+ * because enumerating them is the arrangement that leaves the next one out. It
125
+ * costs nothing on the fields that cannot carry a control character: `control`
126
+ * allocates when something matches, and nothing does. Non-mutating, because a
127
+ * caller may render the same report for a human afterwards.
128
+ */
104
129
  export declare function renderJson(report: JsonReport): string;
105
130
  //# sourceMappingURL=json.d.ts.map
package/dist/cli/json.js CHANGED
@@ -6,6 +6,7 @@
6
6
  // and every command emits the same envelope (tool/version/command/ok/summary/
7
7
  // issues) plus command-specific fields.
8
8
  import { docsUrl } from '../core/docs.js';
9
+ import { control } from '../core/terminal.js';
9
10
  import { hasError } from '../core/types.js';
10
11
  /** Bumped whenever the emitted shape changes incompatibly. */
11
12
  export const SCHEMA_VERSION = 1;
@@ -156,8 +157,33 @@ export function errorReport(version, command, err) {
156
157
  const issue = { level: 'ERROR', code: 'internal-error', message };
157
158
  return envelope({ version, command, ok: false, issues: [issue] });
158
159
  }
159
- /** Serialize a report for stdout. Pretty-printed: still one parseable document. */
160
+ /**
161
+ * Serialize a report for stdout. Pretty-printed: still one parseable document.
162
+ *
163
+ * Every string in it loses its control characters first (design §9.1). The
164
+ * machine surface was recorded as exempt because `JSON.stringify` escapes every
165
+ * C0 character — true, and narrower than the rule: §9.1's class also holds DEL
166
+ * and the C1 range, and `JSON.stringify` emits those verbatim. `U+009B` is
167
+ * 8-bit CSI, the same introducer as `ESC [` on a terminal that reads
168
+ * UTF-8-decoded C1 as control — so a scenario name or a thrown message can
169
+ * drive the terminal of someone reading the JSON rather than parsing it, from
170
+ * `check --json`, which executes nothing.
171
+ *
172
+ * Applied here rather than at the report builders, and that is §9.1's own
173
+ * distinction rather than a preference: this is a *document*, not a stream. It
174
+ * has one point of assembly, before any of it is seen, so an obligation sitting
175
+ * there covers a field nobody has added yet — as `outFiles` and `docsUrl` both
176
+ * were. The terminal path is sanitised at each write site for the opposite
177
+ * reason, recorded in core/terminal.ts.
178
+ *
179
+ * A replacer rather than a walk over the fields that carry project text —
180
+ * `issues[].message`, `issues[].file`, `change`, `outFile`, `outFiles` today —
181
+ * because enumerating them is the arrangement that leaves the next one out. It
182
+ * costs nothing on the fields that cannot carry a control character: `control`
183
+ * allocates when something matches, and nothing does. Non-mutating, because a
184
+ * caller may render the same report for a human afterwards.
185
+ */
160
186
  export function renderJson(report) {
161
- return JSON.stringify(report, null, 2);
187
+ return JSON.stringify(report, (_key, value) => (typeof value === 'string' ? control(value) : value), 2);
162
188
  }
163
189
  //# sourceMappingURL=json.js.map
@@ -190,7 +190,15 @@ export function formatStatus(result) {
190
190
  }
191
191
  const { proven } = result.counts;
192
192
  lines.push(chalk.dim(`— ${plural(result.rows.length, 'added requirement')}, ${proven} ready to archive`));
193
- lines.push(chalk.dim(`Not a verdict: run \`attest archive ${inline(result.change)}\` to run the suite.`));
193
+ // Two pointers, because this command answers less than a reader assumes on
194
+ // both sides. `archive` is the verdict it deliberately cannot give. `check` is
195
+ // the half it deliberately does not duplicate: a proposed spec no delta
196
+ // claims, or one whose merged name is taken, is a static fact about this
197
+ // change that `check` already reports — and reporting it here too would put a
198
+ // second answer to one question in the tree, which is what this project takes
199
+ // apart everywhere else. Naming it is the whole cost of not absorbing it.
200
+ lines.push(chalk.dim(`Not a verdict: run \`attest archive ${inline(result.change)}\` to run the suite, ` +
201
+ `\`attest check\` for the proposed specs themselves.`));
194
202
  return lines.join('\n');
195
203
  }
196
204
  /**
@@ -1,5 +1,5 @@
1
1
  import type { RegistryDelta } from './registry.js';
2
- import type { Issue, Registry } from './types.js';
2
+ import type { Issue, Registry, Requirement } from './types.js';
3
3
  export interface ApplyResult {
4
4
  registry: Registry;
5
5
  issues: Issue[];
@@ -12,6 +12,14 @@ export interface ApplyResult {
12
12
  * than off `delta.added`, so the two can never be scoped differently.
13
13
  */
14
14
  export declare function addedIds(d: RegistryDelta): string[];
15
+ /**
16
+ * The ids a delta MODIFIEs — the scope of `--apply`'s field-level write-back.
17
+ *
18
+ * Here for the reason `addedIds` is: two places ask, the merge that writes them
19
+ * and the refusal that checks their prefix is owned, and a delta's operations
20
+ * must not be enumerated twice.
21
+ */
22
+ export declare function modifiedIds(d: RegistryDelta): string[];
15
23
  /**
16
24
  * The ids a delta **claims**: what it adds, renames to, or modifies.
17
25
  *
@@ -33,4 +41,13 @@ export declare function claimedIds(d: RegistryDelta): string[];
33
41
  * base registry is never mutated. Applying the same delta twice is a no-op.
34
42
  */
35
43
  export declare function applyDelta(base: Registry, d: RegistryDelta): ApplyResult;
44
+ /**
45
+ * Content equality via canonical JSON (params key order does not matter).
46
+ *
47
+ * Exported because `--apply` asks the same question twice — whether a modified
48
+ * entry is already what the end state says, and whether the bytes it wrote read
49
+ * back as it — and a second spelling of "the same requirement" is a second
50
+ * answer the moment `Requirement` gains a field.
51
+ */
52
+ export declare function sameRequirement(a: Requirement, b: Requirement): boolean;
36
53
  //# sourceMappingURL=apply.d.ts.map
@@ -14,6 +14,16 @@ import { byCodeUnit, sortDeep } from './order.js';
14
14
  export function addedIds(d) {
15
15
  return Object.keys(d.added ?? {});
16
16
  }
17
+ /**
18
+ * The ids a delta MODIFIEs — the scope of `--apply`'s field-level write-back.
19
+ *
20
+ * Here for the reason `addedIds` is: two places ask, the merge that writes them
21
+ * and the refusal that checks their prefix is owned, and a delta's operations
22
+ * must not be enumerated twice.
23
+ */
24
+ export function modifiedIds(d) {
25
+ return Object.keys(d.modified ?? {}).sort(byCodeUnit);
26
+ }
17
27
  /**
18
28
  * The ids a delta **claims**: what it adds, renames to, or modifies.
19
29
  *
@@ -177,8 +187,15 @@ function firstMessage(error) {
177
187
  const path = first.path.map(String).join('.');
178
188
  return path ? `${path}: ${first.message}` : first.message;
179
189
  }
180
- /** Content equality via canonical JSON (params key order does not matter). */
181
- function sameRequirement(a, b) {
190
+ /**
191
+ * Content equality via canonical JSON (params key order does not matter).
192
+ *
193
+ * Exported because `--apply` asks the same question twice — whether a modified
194
+ * entry is already what the end state says, and whether the bytes it wrote read
195
+ * back as it — and a second spelling of "the same requirement" is a second
196
+ * answer the moment `Requirement` gains a field.
197
+ */
198
+ export function sameRequirement(a, b) {
182
199
  return canonical(a) === canonical(b);
183
200
  }
184
201
  function canonical(req) {
package/dist/core/gate.js CHANGED
@@ -16,7 +16,7 @@ import { RED_RECORD_FILE, hasRecordedRed, recordedOutcome, } from './red-record.
16
16
  export function declaredNotRunIssues(plan, run) {
17
17
  const issues = [];
18
18
  for (const s of plan.scenarios) {
19
- if (!run.runtimeCoverage.get(s.reqId)?.has(s.name)) {
19
+ if (!run.runtimeCoverage.get(s.reqId)?.get(s.file)?.has(s.name)) {
20
20
  issues.push({
21
21
  level: 'ERROR',
22
22
  code: 'declared-not-run',
@@ -155,9 +155,9 @@ export function neverRedIssues(plan, addedIds, firstRun) {
155
155
  for (const s of plan.scenarios) {
156
156
  if (!added.has(s.reqId))
157
157
  continue;
158
- if (hasRecordedRed(firstRun, s.reqId, s.name))
158
+ if (hasRecordedRed(firstRun, s))
159
159
  continue;
160
- const outcome = recordedOutcome(firstRun, s.reqId, s.name);
160
+ const outcome = recordedOutcome(firstRun, s);
161
161
  issues.push({
162
162
  level: 'ERROR',
163
163
  code: 'never-red',
@@ -27,6 +27,20 @@ export declare const isSpecFile: (name: string) => boolean;
27
27
  * serial `for await` spent one filesystem round-trip per directory, which is
28
28
  * the dominant cost of `check` on a large repo. The result is sorted, so the
29
29
  * order never depends on which `readdir` happened to resolve first.
30
+ *
31
+ * Concurrency is bounded, for the reason `parseSpecs` bounds its own: the input
32
+ * size is not ours to choose, `check` being what this project tells people to
33
+ * run first on an untrusted fork MR. What must not come back is a fan-out whose
34
+ * peak is the shape of the tree rather than a constant — which is what
35
+ * recursing through `Promise.all(subdirs)` gave, and what
36
+ * `tests/locate-fanout.spec.ts` pins.
37
+ *
38
+ * A level at a time, rather than one pool over a queue that grows as directories
39
+ * are discovered: the pool would have to keep workers alive while the queue is
40
+ * momentarily empty but another worker may still push to it, and that
41
+ * termination condition is the part worth not owning. The cost is a barrier per
42
+ * depth, which is paid in tree *depth* — small, and bounded by the filesystem —
43
+ * while the fan-out being bounded is paid in tree *width*, which is not.
30
44
  */
31
45
  export declare function findFiles(root: string, match: (name: string) => boolean): Promise<string[]>;
32
46
  /** The file sets every command needs, collected in one pass. */
@@ -167,10 +181,11 @@ export declare function idPrefix(id: string): string;
167
181
  * avoid: input size is not ours to choose here, `check` being what this project
168
182
  * tells people to run first on an untrusted fork MR. Measured in `[0.7.0]`.
169
183
  *
170
- * `findFiles` above is deliberately left unbounded: its fan-out is real, but the
171
- * failure it invites is descriptor exhaustion, which no measurement on either
172
- * development platform could produce (see CHANGELOG.md, `Under consideration`).
173
- * The memory here needed no such evidence it is arithmetic, and portable.
184
+ * `findFiles` above bounds its own fan-out the same way and through the same
185
+ * helper. It was left unbounded when this one was capped, on the grounds that
186
+ * the failure it invited — descriptor exhaustion — could not be produced on
187
+ * either development platform; what closed it is that the peak itself is
188
+ * portable arithmetic, which is the standard this half was accepted on.
174
189
  */
175
190
  export declare function parseSpecs(files: string[], displayRoot: string): Promise<{
176
191
  plan: AttestPlan;
@@ -5,7 +5,7 @@ import { basename, join } from 'node:path';
5
5
  import { relativePath } from './paths.js';
6
6
  import { parseSpecFile } from './parser.js';
7
7
  import { declaredIdsFromSource, readRegistrySource } from './static-registry.js';
8
- import { RegistryValidationError } from './registry.js';
8
+ import { registryValidationMessage } from './registry-issues.js';
9
9
  import { RegistrySchema } from './schema.js';
10
10
  import { byCodeUnit } from './order.js';
11
11
  import { SourceNotCompiled } from './compiler.js';
@@ -40,6 +40,44 @@ export const isProposedSpecFile = (name) => name.endsWith('.proposed.spec.ts');
40
40
  * of them until its gate passes.
41
41
  */
42
42
  export const isSpecFile = (name) => name.endsWith('.spec.ts') && !isProposedSpecFile(name);
43
+ /**
44
+ * Run `fn` over every item with at most `limit` of them in flight.
45
+ *
46
+ * Extracted at the second call site rather than the first: this file has two
47
+ * unbounded fan-outs to close, one over directories and one over files, and
48
+ * a shape written twice is a shape one of the two copies will eventually be
49
+ * fixed without.
50
+ *
51
+ * The order `fn` is *called* in is the input order; the order it *completes* in
52
+ * is not, so a caller that needs a stable result either indexes into a
53
+ * preallocated array by `index` or sorts afterwards. Both callers here do one of
54
+ * those, deliberately.
55
+ *
56
+ * No result is collected and none is needed — both callers write into something
57
+ * they already own, and a version returning `T[]` would have to choose an
58
+ * ordering on their behalf. A throw from `fn` propagates and abandons the rest,
59
+ * which is the existing behaviour at both sites: `parseSpecs` catches per file
60
+ * so that one hostile spec scraps only itself (ATX-65), and a failed `readdir`
61
+ * really does end the walk.
62
+ */
63
+ async function forEachBounded(items, limit, fn) {
64
+ let cursor = 0;
65
+ const worker = async () => {
66
+ for (let i = cursor++; i < items.length; i = cursor++) {
67
+ await fn(items[i], i);
68
+ }
69
+ };
70
+ await Promise.all(Array.from({ length: Math.min(limit, items.length) }, worker));
71
+ }
72
+ /**
73
+ * How many directories are open at once during the walk. The same figure as
74
+ * `PARSE_CONCURRENCY` and deliberately not the same constant: the two bound
75
+ * different resources, and sharing one would make either impossible to tune
76
+ * without moving the other. High enough that the walk stays I/O bound on any
77
+ * real project, low enough that what is in flight is a constant rather than the
78
+ * shape of the input.
79
+ */
80
+ const WALK_CONCURRENCY = 32;
43
81
  /**
44
82
  * Recursively find files under root whose basename matches `match`.
45
83
  *
@@ -47,25 +85,41 @@ export const isSpecFile = (name) => name.endsWith('.spec.ts') && !isProposedSpec
47
85
  * serial `for await` spent one filesystem round-trip per directory, which is
48
86
  * the dominant cost of `check` on a large repo. The result is sorted, so the
49
87
  * order never depends on which `readdir` happened to resolve first.
88
+ *
89
+ * Concurrency is bounded, for the reason `parseSpecs` bounds its own: the input
90
+ * size is not ours to choose, `check` being what this project tells people to
91
+ * run first on an untrusted fork MR. What must not come back is a fan-out whose
92
+ * peak is the shape of the tree rather than a constant — which is what
93
+ * recursing through `Promise.all(subdirs)` gave, and what
94
+ * `tests/locate-fanout.spec.ts` pins.
95
+ *
96
+ * A level at a time, rather than one pool over a queue that grows as directories
97
+ * are discovered: the pool would have to keep workers alive while the queue is
98
+ * momentarily empty but another worker may still push to it, and that
99
+ * termination condition is the part worth not owning. The cost is a barrier per
100
+ * depth, which is paid in tree *depth* — small, and bounded by the filesystem —
101
+ * while the fan-out being bounded is paid in tree *width*, which is not.
50
102
  */
51
103
  export async function findFiles(root, match) {
52
104
  const out = [];
53
- const walk = async (dir) => {
54
- const entries = await readdir(dir, { withFileTypes: true });
55
- const subdirs = [];
56
- for (const e of entries) {
57
- const full = join(dir, e.name);
58
- if (e.isDirectory()) {
59
- if (!SKIP_DIRS.has(e.name))
60
- subdirs.push(full);
61
- }
62
- else if (e.isFile() && match(e.name)) {
63
- out.push(full);
105
+ let level = [root];
106
+ while (level.length > 0) {
107
+ const next = [];
108
+ await forEachBounded(level, WALK_CONCURRENCY, async (dir) => {
109
+ const entries = await readdir(dir, { withFileTypes: true });
110
+ for (const e of entries) {
111
+ const full = join(dir, e.name);
112
+ if (e.isDirectory()) {
113
+ if (!SKIP_DIRS.has(e.name))
114
+ next.push(full);
115
+ }
116
+ else if (e.isFile() && match(e.name)) {
117
+ out.push(full);
118
+ }
64
119
  }
65
- }
66
- await Promise.all(subdirs.map(walk));
67
- };
68
- await walk(root);
120
+ });
121
+ level = next;
122
+ }
69
123
  return out.sort(byCodeUnit);
70
124
  }
71
125
  /**
@@ -191,13 +245,13 @@ export function evalReader(loader) {
191
245
  const parsed = RegistrySchema.safeParse(exported);
192
246
  if (!parsed.success) {
193
247
  // The same code, the same prefix and the same rendering of the field
194
- // errors as `readRegistrySource` — going through RegistryValidationError
195
- // keeps the message identical rather than merely similar.
248
+ // errors as `readRegistrySource` — sharing one formatter keeps the
249
+ // message identical rather than merely similar.
196
250
  return {
197
251
  issue: {
198
252
  level: 'ERROR',
199
253
  code: 'registry-invalid',
200
- message: `Failed to load registry: ${new RegistryValidationError(parsed.error.issues).message}`,
254
+ message: `Failed to load registry: ${registryValidationMessage(parsed.error.issues)}`,
201
255
  },
202
256
  };
203
257
  }
@@ -362,10 +416,11 @@ const PARSE_CONCURRENCY = 32;
362
416
  * avoid: input size is not ours to choose here, `check` being what this project
363
417
  * tells people to run first on an untrusted fork MR. Measured in `[0.7.0]`.
364
418
  *
365
- * `findFiles` above is deliberately left unbounded: its fan-out is real, but the
366
- * failure it invites is descriptor exhaustion, which no measurement on either
367
- * development platform could produce (see CHANGELOG.md, `Under consideration`).
368
- * The memory here needed no such evidence it is arithmetic, and portable.
419
+ * `findFiles` above bounds its own fan-out the same way and through the same
420
+ * helper. It was left unbounded when this one was capped, on the grounds that
421
+ * the failure it invited — descriptor exhaustion — could not be produced on
422
+ * either development platform; what closed it is that the peak itself is
423
+ * portable arithmetic, which is the standard this half was accepted on.
369
424
  */
370
425
  export async function parseSpecs(files, displayRoot) {
371
426
  // Indexed rather than appended, so the merge below follows the input order
@@ -374,24 +429,19 @@ export async function parseSpecs(files, displayRoot) {
374
429
  // Same index space, so a file contributes either a parse or an issue and the
375
430
  // two lists cannot disagree about which file is which.
376
431
  const failures = new Array(files.length);
377
- let next = 0;
378
- const worker = async () => {
379
- for (let i = next++; i < files.length; i = next++) {
380
- const file = files[i];
381
- const display = relativePath(displayRoot, file);
382
- // Per file, for the reason `readGuarded` exists above: these run
383
- // concurrently, so one throw rejects the whole `Promise.all` and takes
384
- // the command with it. A hostile spec scraps only itself (ATX-65).
385
- try {
386
- parsed[i] = parseSpecFile(display, await readFile(file, 'utf8'));
387
- }
388
- catch (err) {
389
- parsed[i] = { scenarios: [], paramRefs: [] };
390
- failures[i] = { ...unreadableIssue(err), file: display };
391
- }
432
+ await forEachBounded(files, PARSE_CONCURRENCY, async (file, i) => {
433
+ const display = relativePath(displayRoot, file);
434
+ // Per file, for the reason `readGuarded` exists above: these run
435
+ // concurrently, so one throw would abandon the rest and take the command
436
+ // with it. A hostile spec scraps only itself (ATX-65).
437
+ try {
438
+ parsed[i] = parseSpecFile(display, await readFile(file, 'utf8'));
392
439
  }
393
- };
394
- await Promise.all(Array.from({ length: Math.min(PARSE_CONCURRENCY, files.length) }, worker));
440
+ catch (err) {
441
+ parsed[i] = { scenarios: [], paramRefs: [] };
442
+ failures[i] = { ...unreadableIssue(err), file: display };
443
+ }
444
+ });
395
445
  const plan = { scenarios: [], paramRefs: [] };
396
446
  for (const one of parsed) {
397
447
  plan.scenarios.push(...one.scenarios);