@am_shork/attest 0.6.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.
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.6.0/docs/en/troubleshooting.md#registry-not-static
160
+ → https://gitlab.com/Pseudorca/attest/-/blob/v0.7.1/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/dist/cli/index.js CHANGED
@@ -13,7 +13,7 @@ import { resolve } from 'node:path';
13
13
  import { writeAtomic } from '../core/write.js';
14
14
  import { runCheck, runVerify, runCover, runArchive, runArchiveApply, runInit, runRender, runRenderCheck, runStatus, DEFAULT_TARGET, TARGET_NAMES, } from '../core/pipeline.js';
15
15
  import { hasError } from '../core/types.js';
16
- import { formatCrash, formatIssues, formatScope, summarize, formatCoverage, formatStatus, } from './report.js';
16
+ import { formatArchiveVerdict, formatCrash, formatIssues, formatScope, summarize, formatCoverage, formatStatus, } from './report.js';
17
17
  import { archiveReport, checkReport, coverReport, errorReport, initReport, renderJson, renderReport, statusReport, verifyReport, } from './json.js';
18
18
  import { packageVersion } from '../core/version.js';
19
19
  const VERSION = packageVersion();
@@ -282,23 +282,7 @@ program
282
282
  return {
283
283
  report: archiveReport(VERSION, change, blocking),
284
284
  human: () => {
285
- if (blocking.length === 0) {
286
- console.log(chalk.green(opts.apply
287
- ? `✓ Gate passed and merged: change "${change}".`
288
- : `✓ Gate passed: change "${change}" can be archived.`));
289
- // Every path the merge touched, because this is the command that
290
- // edits files the user cannot regenerate and they need to know which
291
- // ones to read before committing. It is also what a working-tree
292
- // check would only have approximated, and it works for someone not
293
- // using version control at all.
294
- for (const path of written)
295
- console.log(chalk.dim(` ${path}`));
296
- }
297
- else {
298
- console.log(chalk.red(`✗ Gate failed: change "${change}"`));
299
- console.log(formatIssues(blocking));
300
- console.log(summarize(blocking));
301
- }
285
+ console.log(formatArchiveVerdict(change, blocking, { applied: opts.apply === true, written }));
302
286
  },
303
287
  };
304
288
  }));
package/dist/cli/json.js CHANGED
@@ -112,7 +112,12 @@ export function archiveReport(version, change, blocking) {
112
112
  ...envelope({
113
113
  version,
114
114
  command: 'archive',
115
- ok: blocking.length === 0,
115
+ // `hasError`, not `blocking.length === 0` (ATX-60). The gate deliberately
116
+ // keeps its non-blocking output through `--apply`, so a WARNING in here
117
+ // is planned for — and counting one as a failure made this command answer
118
+ // not-ok, exit 1, and print a failed gate *after* the merge had written
119
+ // the files. Every other command already asks the question this way.
120
+ ok: !hasError(blocking),
116
121
  issues: blocking,
117
122
  }),
118
123
  change,
@@ -57,5 +57,23 @@ export declare function formatScope(scope: VerifyCounts): string;
57
57
  * the argument that keeps `render --check` comparing documents rather than bytes.
58
58
  */
59
59
  export declare function formatStatus(result: StatusResult): string;
60
+ /**
61
+ * The verdict `attest archive` prints, and the lines under it.
62
+ *
63
+ * Here rather than inline in the CLI action for two reasons, and the second is
64
+ * the one that matters. The shell is a thin veneer that renders what the core
65
+ * decided; and while this lived in a closure it was the *only* copy of the
66
+ * verdict nothing could test — so when it asked `blocking.length === 0` of its
67
+ * own accord, no scenario could see that the line above the issues could call
68
+ * the gate failed while the JSON beside it called the command ok (ATX-60).
69
+ *
70
+ * `hasError`, the same predicate `archiveReport` uses. Not a second reading of
71
+ * the same array so much as the same question asked once and rendered twice —
72
+ * which is what the requirement is about.
73
+ */
74
+ export declare function formatArchiveVerdict(change: string, blocking: Issue[], merge: {
75
+ applied: boolean;
76
+ written: readonly string[];
77
+ }): string;
60
78
  export declare function formatCoverage(rows: CoverageRow[]): string;
61
79
  //# sourceMappingURL=report.d.ts.map
@@ -4,6 +4,7 @@ import chalk from 'chalk';
4
4
  import { docsUrl } from '../core/docs.js';
5
5
  import { byCodeUnit } from '../core/order.js';
6
6
  import { INDENT, block, inline } from '../core/terminal.js';
7
+ import { hasError } from '../core/types.js';
7
8
  const LEVEL_TAG = {
8
9
  ERROR: (s) => chalk.red.bold(s),
9
10
  WARNING: (s) => chalk.yellow.bold(s),
@@ -184,6 +185,46 @@ export function formatStatus(result) {
184
185
  lines.push(chalk.dim(`Not a verdict: run \`attest archive ${inline(result.change)}\` to run the suite.`));
185
186
  return lines.join('\n');
186
187
  }
188
+ /**
189
+ * The verdict `attest archive` prints, and the lines under it.
190
+ *
191
+ * Here rather than inline in the CLI action for two reasons, and the second is
192
+ * the one that matters. The shell is a thin veneer that renders what the core
193
+ * decided; and while this lived in a closure it was the *only* copy of the
194
+ * verdict nothing could test — so when it asked `blocking.length === 0` of its
195
+ * own accord, no scenario could see that the line above the issues could call
196
+ * the gate failed while the JSON beside it called the command ok (ATX-60).
197
+ *
198
+ * `hasError`, the same predicate `archiveReport` uses. Not a second reading of
199
+ * the same array so much as the same question asked once and rendered twice —
200
+ * which is what the requirement is about.
201
+ */
202
+ export function formatArchiveVerdict(change, blocking, merge) {
203
+ if (hasError(blocking)) {
204
+ return [
205
+ chalk.red(`✗ Gate failed: change "${inline(change)}"`),
206
+ formatIssues(blocking),
207
+ summarize(blocking),
208
+ ].join('\n');
209
+ }
210
+ const lines = [
211
+ chalk.green(merge.applied
212
+ ? `✓ Gate passed and merged: change "${inline(change)}".`
213
+ : `✓ Gate passed: change "${inline(change)}" can be archived.`),
214
+ ];
215
+ // Every path the merge touched, because this is the command that edits files
216
+ // the user cannot regenerate and they need to know which ones to read before
217
+ // committing. It is also what a working-tree check would only have
218
+ // approximated, and it works for someone not using version control at all.
219
+ for (const path of merge.written)
220
+ lines.push(chalk.dim(` ${inline(path)}`));
221
+ // A WARNING that did not fail the gate still has to be shown, or `--apply`
222
+ // would be quieter than the same command without it — the property
223
+ // `runArchiveApply` keeps its non-blocking output for.
224
+ if (blocking.length > 0)
225
+ lines.push(formatIssues(blocking), summarize(blocking));
226
+ return lines.join('\n');
227
+ }
187
228
  export function formatCoverage(rows) {
188
229
  if (rows.length === 0)
189
230
  return chalk.dim('(the registry contains no requirements)');
@@ -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", "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"];
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',
@@ -48,6 +49,7 @@ export const ISSUE_CODES = [
48
49
  'rename-target-exists',
49
50
  'rename-target-invalid',
50
51
  'spec-in-change-dir',
52
+ 'spec-load-failed',
51
53
  'stale-spec-doc',
52
54
  'tests-red',
53
55
  'unbound-param',
@@ -56,6 +56,51 @@ export declare function declaredNotRunIssues(plan: AttestPlan, run: RunResult):
56
56
  * rather than asserting a cause.
57
57
  */
58
58
  export declare function unmergedIdIssues(run: RunResult, unmergedAddedIds: readonly string[]): Issue[];
59
+ /**
60
+ * A spec file the runtime could not load, with no more specific cause known
61
+ * (design §5.4).
62
+ *
63
+ * The general case of `unmergedIdIssues`, and it exists because that one is a
64
+ * conjunction: it speaks only when the change also adds an id the registry on
65
+ * disk lacks. Every other way a spec file fails to import — a typo in a
66
+ * specifier, a module that throws at load, a dependency that is not installed —
67
+ * produced no finding naming the file at all. What the reader got instead was a
68
+ * `declared-not-run` per scenario in it, saying "skipped, or excluded by an
69
+ * `.only`?", which is a guess at a cause and the wrong one: it sends someone to
70
+ * audit a file for a `skip` that is not there, while the import error that
71
+ * explains everything is named nowhere in the report.
72
+ *
73
+ * `already` is the files a more specific diagnosis has claimed. One file gets
74
+ * one finding, and the specific one wins, because `added-id-unmerged` carries a
75
+ * fix and this carries only a fact. That is also why this could not simply be
76
+ * `declared-not-run` suppression, which is what it looked like from a distance:
77
+ * suppressing with nothing to put in its place trades a wrong message for
78
+ * silence, and a wrong message beats silence. The replacement had to come first.
79
+ *
80
+ * No `reqId`. A file that never loaded has no established relationship to any
81
+ * requirement — the ids it *would* have attested are readable from the static
82
+ * plan, but stating one here would name a requirement as implicated when the
83
+ * failure may have nothing to do with it, and a file is what did or did not
84
+ * load.
85
+ */
86
+ export declare function specLoadFailedIssues(run: RunResult, already?: ReadonlySet<string>): Issue[];
87
+ /**
88
+ * Everything a report says about scenarios that did not run: the files that
89
+ * failed to load, then the absences those files do not already explain.
90
+ *
91
+ * One function because two commands ask, and the answer has an *order* in it —
92
+ * the cause before the absences it caused, and never the absences on their own.
93
+ * `verify` and the gate each spelling that out is how the two came to differ in
94
+ * the first place: the gate learned to withdraw a misleading line and `verify`
95
+ * did not, so the same run produced a different diagnosis depending on which
96
+ * command asked. That is the clerical agreement `declaredNotRunIssues` was
97
+ * extracted to stop, arriving one level up.
98
+ *
99
+ * `specific` is any more precise diagnosis the caller has already emitted for
100
+ * these files — `added-id-unmerged` on the gate path, nothing on `verify`'s.
101
+ * Those files are left alone here, so one file carries one finding.
102
+ */
103
+ export declare function notRunIssues(plan: AttestPlan, run: RunResult, specific?: readonly Issue[]): Issue[];
59
104
  /**
60
105
  * Never-red: a scenario attesting a requirement this change ADDs, whose first
61
106
  * observed run did not fail (design §6, mechanism 2).
@@ -85,8 +130,9 @@ export declare function neverRedIssues(plan: AttestPlan, addedIds: readonly stri
85
130
  * 2. Executable: all tests green.
86
131
  * 3. A spec file that failed to load while the change adds an id the registry
87
132
  * on disk lacks — the reason, named before the absences it causes.
88
- * 4. Static coverage vs runtime coverage: every declared scenario actually
89
- * ran (catches skip/only false coverage).
133
+ * 4. Everything else about what did not run, from `notRunIssues`: the load
134
+ * failures step 3 did not claim, then static-vs-runtime coverage for the
135
+ * files that did load (catches skip/only false coverage).
90
136
  * 5. Never-red: every scenario of an ADDED requirement failed on its first
91
137
  * recorded run (design §6, mechanism 2).
92
138
  */
package/dist/core/gate.js CHANGED
@@ -66,6 +66,71 @@ export function unmergedIdIssues(run, unmergedAddedIds) {
66
66
  `Merge the added requirement into the registry and run the gate again: applying a delta whose ADDED entry already exists with identical content is a no-op, so the change still documents the intent.`,
67
67
  }));
68
68
  }
69
+ /**
70
+ * A spec file the runtime could not load, with no more specific cause known
71
+ * (design §5.4).
72
+ *
73
+ * The general case of `unmergedIdIssues`, and it exists because that one is a
74
+ * conjunction: it speaks only when the change also adds an id the registry on
75
+ * disk lacks. Every other way a spec file fails to import — a typo in a
76
+ * specifier, a module that throws at load, a dependency that is not installed —
77
+ * produced no finding naming the file at all. What the reader got instead was a
78
+ * `declared-not-run` per scenario in it, saying "skipped, or excluded by an
79
+ * `.only`?", which is a guess at a cause and the wrong one: it sends someone to
80
+ * audit a file for a `skip` that is not there, while the import error that
81
+ * explains everything is named nowhere in the report.
82
+ *
83
+ * `already` is the files a more specific diagnosis has claimed. One file gets
84
+ * one finding, and the specific one wins, because `added-id-unmerged` carries a
85
+ * fix and this carries only a fact. That is also why this could not simply be
86
+ * `declared-not-run` suppression, which is what it looked like from a distance:
87
+ * suppressing with nothing to put in its place trades a wrong message for
88
+ * silence, and a wrong message beats silence. The replacement had to come first.
89
+ *
90
+ * No `reqId`. A file that never loaded has no established relationship to any
91
+ * requirement — the ids it *would* have attested are readable from the static
92
+ * plan, but stating one here would name a requirement as implicated when the
93
+ * failure may have nothing to do with it, and a file is what did or did not
94
+ * load.
95
+ */
96
+ export function specLoadFailedIssues(run, already = new Set()) {
97
+ return run.unloadedFiles
98
+ .filter((file) => !already.has(file))
99
+ .map((file) => ({
100
+ level: 'ERROR',
101
+ code: 'spec-load-failed',
102
+ file,
103
+ message: `${file} failed to load, so none of the scenarios in it ran. ` +
104
+ `The run output above carries the import error itself; this reports which file it stopped, because a file that never loaded reports no failures of its own. ` +
105
+ `Fix the import and run again — the scenarios in this file are neither passing nor failing until it loads.`,
106
+ }));
107
+ }
108
+ /**
109
+ * Everything a report says about scenarios that did not run: the files that
110
+ * failed to load, then the absences those files do not already explain.
111
+ *
112
+ * One function because two commands ask, and the answer has an *order* in it —
113
+ * the cause before the absences it caused, and never the absences on their own.
114
+ * `verify` and the gate each spelling that out is how the two came to differ in
115
+ * the first place: the gate learned to withdraw a misleading line and `verify`
116
+ * did not, so the same run produced a different diagnosis depending on which
117
+ * command asked. That is the clerical agreement `declaredNotRunIssues` was
118
+ * extracted to stop, arriving one level up.
119
+ *
120
+ * `specific` is any more precise diagnosis the caller has already emitted for
121
+ * these files — `added-id-unmerged` on the gate path, nothing on `verify`'s.
122
+ * Those files are left alone here, so one file carries one finding.
123
+ */
124
+ export function notRunIssues(plan, run, specific = []) {
125
+ const named = new Set(specific.flatMap((i) => (i.file ? [i.file] : [])));
126
+ // Every unloaded file now carries a diagnosis: `specific` covers `named`, and
127
+ // the call below covers the rest by construction.
128
+ const diagnosed = new Set(run.unloadedFiles);
129
+ return [
130
+ ...specLoadFailedIssues(run, named),
131
+ ...declaredNotRunIssues(plan, run).filter((i) => !i.file || !diagnosed.has(i.file)),
132
+ ];
133
+ }
69
134
  /**
70
135
  * Never-red: a scenario attesting a requirement this change ADDs, whose first
71
136
  * observed run did not fail (design §6, mechanism 2).
@@ -116,8 +181,9 @@ export function neverRedIssues(plan, addedIds, firstRun) {
116
181
  * 2. Executable: all tests green.
117
182
  * 3. A spec file that failed to load while the change adds an id the registry
118
183
  * on disk lacks — the reason, named before the absences it causes.
119
- * 4. Static coverage vs runtime coverage: every declared scenario actually
120
- * ran (catches skip/only false coverage).
184
+ * 4. Everything else about what did not run, from `notRunIssues`: the load
185
+ * failures step 3 did not claim, then static-vs-runtime coverage for the
186
+ * files that did load (catches skip/only false coverage).
121
187
  * 5. Never-red: every scenario of an ADDED requirement failed on its first
122
188
  * recorded run (design §6, mechanism 2).
123
189
  */
@@ -133,19 +199,12 @@ export function evaluateGate({ registry, plan, run, addedIds, unmergedAddedIds,
133
199
  message: 'Some tests are failing; the change cannot be archived.',
134
200
  });
135
201
  }
136
- // 3) A file that failed to load, named before the absences it produces.
202
+ // 3) and 4) in one push, because the order between them is not this
203
+ // function's to choose: the gate's own diagnosis — the one with a cause and a
204
+ // fix attached — and then everything `notRunIssues` says about the rest. The
205
+ // gate's only extra input is having that more specific diagnosis to offer.
137
206
  const unmerged = unmergedIdIssues(run, unmergedAddedIds ?? []);
138
- blocking.push(...unmerged);
139
- // 4) Declared-not-run: static coverage claimed, runtime never executed it.
140
- //
141
- // Scenarios in a file diagnosed just above are left out. Not because they
142
- // ran — they could not have — but because "declared but never executed
143
- // (skipped, or excluded by an .only?)" is one fact restated as a guess about
144
- // a cause the line above has already established. Only when that line was
145
- // emitted: with no diagnosis to replace it, a wrong message still beats
146
- // silence, which is why `verify` keeps reporting them.
147
- const diagnosed = new Set(unmerged.map((i) => i.file));
148
- blocking.push(...declaredNotRunIssues(plan, run).filter((i) => !diagnosed.has(i.file)));
207
+ blocking.push(...unmerged, ...notRunIssues(plan, run, unmerged));
149
208
  // 5) Never-red: the added scenarios have to have discriminated once.
150
209
  if (addedIds && addedIds.length > 0) {
151
210
  blocking.push(...neverRedIssues(plan, addedIds, firstRun ?? {}));
@@ -35,6 +35,19 @@ import { block } from './terminal.js';
35
35
  export function sanitisedLogger(base = createLogger('error')) {
36
36
  return {
37
37
  ...base,
38
+ // `hasWarned` is the one member of `Logger` that is state rather than
39
+ // behaviour, and the spread above copies it *by value* — so the wrapper was
40
+ // born `false` and stayed `false` however many warnings went through it,
41
+ // while the base it delegates to flipped to `true`. Measured: after one
42
+ // `warn`, base `true` / wrapper `false`. An accessor pair makes the wrapper
43
+ // a view of the base rather than a snapshot of it, which is what every
44
+ // other member already is.
45
+ get hasWarned() {
46
+ return base.hasWarned;
47
+ },
48
+ set hasWarned(value) {
49
+ base.hasWarned = value;
50
+ },
38
51
  info: (msg, opts) => base.info(block(msg), opts),
39
52
  warn: (msg, opts) => base.warn(block(msg), opts),
40
53
  warnOnce: (msg, opts) => base.warnOnce(block(msg), opts),
@@ -109,6 +109,18 @@ export declare function loadRegistry(root: string, reader: RegistryReader, files
109
109
  registry: Registry;
110
110
  issues: Issue[];
111
111
  prefixOwners: Record<string, string>;
112
+ /**
113
+ * Registry files that contributed **no ids**, relative to `root`.
114
+ *
115
+ * Not the same question as "did loading produce an ERROR": `duplicate-prefix`
116
+ * and `duplicate-requirement` are ERRORs raised *after* a successful read, and
117
+ * the ids they are about are present. Only a file whose outcome carried an
118
+ * issue instead of a registry is missing from the result, and a caller that
119
+ * re-derived that from the issue codes would be maintaining a second answer
120
+ * to a question this loop already knows — the mistake `prefixOwners` is here
121
+ * to avoid one shape of.
122
+ */
123
+ unreadableFiles: string[];
112
124
  }>;
113
125
  /**
114
126
  * The part of an id that names the space it lives in — `AUTH` of `AUTH-3`.
@@ -128,6 +140,17 @@ export declare function idPrefix(id: string): string;
128
140
  * The paths are POSIX on every platform (see `paths.ts`): they are not only
129
141
  * displayed, they become the child run's `include` globs, where a Windows
130
142
  * separator would silently match nothing.
143
+ *
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]`.
149
+ *
150
+ * `findFiles` above is deliberately left unbounded: its fan-out is real, but the
151
+ * failure it invites is descriptor exhaustion, which no measurement on either
152
+ * development platform could produce (see CHANGELOG.md, `Under consideration`).
153
+ * The memory here needed no such evidence — it is arithmetic, and portable.
131
154
  */
132
155
  export declare function parseSpecs(files: string[], displayRoot: string): Promise<AttestPlan>;
133
156
  /** Parse every `*.spec.ts` under root into one merged plan (file paths shown relative to root). */
@@ -210,6 +210,7 @@ export async function loadRegistry(root, reader, files) {
210
210
  const loaded = await Promise.all(paths.map(async (file) => ({ file, outcome: await reader.read(file) })));
211
211
  const registry = {};
212
212
  const issues = [];
213
+ const unreadableFiles = [];
213
214
  // Which file first claimed each id prefix. The prefix is the only unit above
214
215
  // the requirement (design §11) and nothing allocates it, so two files
215
216
  // claiming one is the collision no command would otherwise report — the ids
@@ -225,6 +226,7 @@ export async function loadRegistry(root, reader, files) {
225
226
  const { outcome } = entry;
226
227
  if ('issue' in outcome) {
227
228
  issues.push({ ...outcome.issue, file: display });
229
+ unreadableFiles.push(display);
228
230
  continue;
229
231
  }
230
232
  // One issue per colliding prefix rather than per requirement: the fact is
@@ -262,7 +264,7 @@ export async function loadRegistry(root, reader, files) {
262
264
  }
263
265
  }
264
266
  }
265
- return { registry, issues, prefixOwners: Object.fromEntries(prefixOwner) };
267
+ return { registry, issues, prefixOwners: Object.fromEntries(prefixOwner), unreadableFiles };
266
268
  }
267
269
  /**
268
270
  * The part of an id that names the space it lives in — `AUTH` of `AUTH-3`.
@@ -277,6 +279,12 @@ export function idPrefix(id) {
277
279
  const dash = id.indexOf('-');
278
280
  return dash === -1 ? id : id.slice(0, dash);
279
281
  }
282
+ /**
283
+ * How many spec files are read at once. High enough that the walk stays I/O
284
+ * bound on any real project, low enough that the number of sources alive is a
285
+ * constant rather than the size of the input.
286
+ */
287
+ const PARSE_CONCURRENCY = 32;
280
288
  /**
281
289
  * Parse the given spec files into one merged plan (paths shown relative to
282
290
  * `displayRoot`). Files are read concurrently; the merge follows the input
@@ -285,14 +293,35 @@ export function idPrefix(id) {
285
293
  * The paths are POSIX on every platform (see `paths.ts`): they are not only
286
294
  * displayed, they become the child run's `include` globs, where a Windows
287
295
  * separator would silently match nothing.
296
+ *
297
+ * Each source is parsed as it arrives rather than after all of them, so the peak
298
+ * is `PARSE_CONCURRENCY` sources rather than `files.length` and the plan is the
299
+ * only thing still growing with the tree. Reading them all first is the shape to
300
+ * avoid: input size is not ours to choose here, `check` being what this project
301
+ * tells people to run first on an untrusted fork MR. Measured in `[0.7.0]`.
302
+ *
303
+ * `findFiles` above is deliberately left unbounded: its fan-out is real, but the
304
+ * failure it invites is descriptor exhaustion, which no measurement on either
305
+ * development platform could produce (see CHANGELOG.md, `Under consideration`).
306
+ * The memory here needed no such evidence — it is arithmetic, and portable.
288
307
  */
289
308
  export async function parseSpecs(files, displayRoot) {
290
- const sources = await Promise.all(files.map((file) => readFile(file, 'utf8')));
309
+ // Indexed rather than appended, so the merge below follows the input order
310
+ // whatever order the reads finish in.
311
+ const parsed = new Array(files.length);
312
+ let next = 0;
313
+ const worker = async () => {
314
+ for (let i = next++; i < files.length; i = next++) {
315
+ const file = files[i];
316
+ const source = await readFile(file, 'utf8');
317
+ parsed[i] = parseSpecFile(relativePath(displayRoot, file), source);
318
+ }
319
+ };
320
+ await Promise.all(Array.from({ length: Math.min(PARSE_CONCURRENCY, files.length) }, worker));
291
321
  const plan = { scenarios: [], paramRefs: [] };
292
- for (const [i, file] of files.entries()) {
293
- const parsed = parseSpecFile(relativePath(displayRoot, file), sources[i]);
294
- plan.scenarios.push(...parsed.scenarios);
295
- plan.paramRefs.push(...parsed.paramRefs);
322
+ for (const one of parsed) {
323
+ plan.scenarios.push(...one.scenarios);
324
+ plan.paramRefs.push(...one.paramRefs);
296
325
  }
297
326
  return plan;
298
327
  }
@@ -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';
@@ -48,20 +48,21 @@ export function mergedSpecPath(proposed) {
48
48
  * acting on it, and a merge that did the half it understood would put the file
49
49
  * into a state no verdict describes.
50
50
  */
51
- async function refusals(input) {
51
+ async function refusals(input, stamp) {
52
52
  const issues = [];
53
53
  const { delta, root, changeName } = input;
54
+ const archive = archivePath(input, stamp);
54
55
  // 0) The destination already exists. Checked here, before anything is
55
56
  // written, rather than at the move it is about: every refusal in this function
56
57
  // leaves the tree untouched, and one that fired after two steps had landed
57
58
  // would make "refused whole" a claim with an exception in it — which is the
58
59
  // kind of claim nobody can rely on.
59
- if (await exists(archivePath(input))) {
60
+ if (await exists(archive)) {
60
61
  issues.push({
61
62
  level: 'ERROR',
62
63
  code: 'apply-unsupported-delta',
63
- file: relativePath(root, archivePath(input)),
64
- message: `${relativePath(root, archivePath(input))} already exists, so this change cannot be archived there. ` +
64
+ file: relativePath(root, archive),
65
+ message: `${relativePath(root, archive)} already exists, so this change cannot be archived there. ` +
65
66
  `Move or remove that directory, then run this command again.`,
66
67
  });
67
68
  }
@@ -154,7 +155,15 @@ function unmergedAdded(input) {
154
155
  * claim.
155
156
  */
156
157
  export async function applyMerge(input) {
157
- const refused = await refusals(input);
158
+ // Read once, here, and passed down. The comment on `archivePath` has always
159
+ // claimed the refusal and the move cannot disagree about the date; until this
160
+ // it computed a fresh `new Date()` on every call, four times per merge, so
161
+ // the property it named was precisely the one not provided. A merge that
162
+ // straddles midnight would have refused against one directory and written to
163
+ // another — rare, and silent when it happens, which is the combination this
164
+ // repository treats as worth the line.
165
+ const stamp = new Date().toISOString().slice(0, 10);
166
+ const refused = await refusals(input, stamp);
158
167
  if (refused.length > 0)
159
168
  return { issues: refused, written: [] };
160
169
  const { root } = input;
@@ -169,7 +178,32 @@ export async function applyMerge(input) {
169
178
  }
170
179
  for (const file of [...byFile.keys()].sort(byCodeUnit)) {
171
180
  const source = await readFile(file, 'utf8');
172
- 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
+ }
173
207
  if (spliced === undefined) {
174
208
  // Unreachable through the command — the gate read this file as a literal
175
209
  // moments ago — so it is reported as the internal inconsistency it is
@@ -215,7 +249,7 @@ export async function applyMerge(input) {
215
249
  }
216
250
  // --- 3) Move the change folder, last.
217
251
  const from = join(root, 'changes', input.changeName);
218
- const to = archivePath(input);
252
+ const to = archivePath(input, stamp);
219
253
  if (await exists(from)) {
220
254
  await mkdir(dirname(to), { recursive: true });
221
255
  await rename(from, to);
@@ -227,9 +261,13 @@ export async function applyMerge(input) {
227
261
  * Where a change is archived to. One function because two places ask — the
228
262
  * refusal that checks it is free, and the move that performs it — and a date
229
263
  * computed twice could straddle midnight and disagree with itself.
264
+ *
265
+ * So the date is not computed here: `stamp` comes from `applyMerge`, which
266
+ * reads it once for the whole operation. A pure function of its arguments is
267
+ * what makes "the two places cannot disagree" a property of the code rather
268
+ * than of how fast it ran.
230
269
  */
231
- function archivePath(input) {
232
- const stamp = new Date().toISOString().slice(0, 10);
270
+ function archivePath(input, stamp) {
233
271
  return join(input.root, 'archive', `${stamp}-${input.changeName}`);
234
272
  }
235
273
  async function exists(path) {