@am_shork/attest 0.5.0 → 0.7.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
@@ -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.5.0/docs/en/troubleshooting.md#registry-not-static
160
+ → https://gitlab.com/Pseudorca/attest/-/blob/v0.7.0/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
@@ -11,12 +11,17 @@ import { Command } from 'commander';
11
11
  import chalk from 'chalk';
12
12
  import { resolve } from 'node:path';
13
13
  import { writeAtomic } from '../core/write.js';
14
- import { runCheck, runVerify, runCover, runArchive, runInit, runRender, runRenderCheck, runStatus, DEFAULT_TARGET, TARGET_NAMES, } from '../core/pipeline.js';
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();
20
+ /** `runArchive` in the shape `--apply` returns, so the two share one call site. */
21
+ const gateOnly = async (dir, change, options) => ({
22
+ issues: await runArchive(dir, change, options),
23
+ written: [],
24
+ });
20
25
  const program = new Command();
21
26
  program
22
27
  .name('attest')
@@ -267,20 +272,17 @@ program
267
272
  .argument('[dir]', 'project root', undefined)
268
273
  .option(JSON_FLAG, JSON_HELP)
269
274
  .option(VITEST_CONFIG_FLAG, VITEST_CONFIG_HELP)
275
+ .option('--apply', 'if the gate passes, merge the change: splice its ADDED requirements into the registry, rename its proposed specs, and move the change folder to archive/')
270
276
  .description('Run the archive gate; merge only if it passes.')
271
277
  .action((change, dir, opts) => runAction('archive', opts, async () => {
272
- const blocking = await runArchive(root(dir), change, { vitestConfig: vitestConfig(opts) });
278
+ const run = opts.apply ? runArchiveApply : gateOnly;
279
+ const { issues: blocking, written } = await run(root(dir), change, {
280
+ vitestConfig: vitestConfig(opts),
281
+ });
273
282
  return {
274
283
  report: archiveReport(VERSION, change, blocking),
275
284
  human: () => {
276
- if (blocking.length === 0) {
277
- console.log(chalk.green(`✓ Gate passed: change "${change}" can be archived.`));
278
- }
279
- else {
280
- console.log(chalk.red(`✗ Gate failed: change "${change}"`));
281
- console.log(formatIssues(blocking));
282
- console.log(summarize(blocking));
283
- }
285
+ console.log(formatArchiveVerdict(change, blocking, { applied: opts.apply === true, written }));
284
286
  },
285
287
  };
286
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)');
@@ -0,0 +1,40 @@
1
+ import type { Issue } from './types.js';
2
+ /**
3
+ * The range the readers are written against.
4
+ *
5
+ * Stated here rather than read from the manifest at runtime, because the
6
+ * manifest is not on disk beside a bundled build and a diagnostic that cannot
7
+ * name the range is most of the diagnostic gone. `ATX-56` keeps the two honest:
8
+ * its `params` carry this string, and its scenario compares them with
9
+ * `package.json`, so the constant cannot drift from the dependency it describes.
10
+ */
11
+ export declare const SUPPORTED_TYPESCRIPT = "^5.5.0 || ^6.0.0";
12
+ /**
13
+ * The part of the compiler surface every AST reader in this project touches.
14
+ *
15
+ * Deliberately the two members that are reached *first* rather than an
16
+ * exhaustive list: `createSourceFile` and `ScriptTarget` are what
17
+ * `parser.ts`, `static-registry.ts` and `splice.ts` all open with, so a compiler
18
+ * that has them has the shape this check is about, and one that lacks either
19
+ * cannot get far enough for a longer list to matter.
20
+ */
21
+ interface CompilerSurface {
22
+ version?: unknown;
23
+ createSourceFile?: unknown;
24
+ ScriptTarget?: unknown;
25
+ }
26
+ /**
27
+ * An ERROR when the resolved compiler has no AST API, or `undefined` when it
28
+ * does.
29
+ *
30
+ * Takes the compiler rather than reaching for the import directly, so the
31
+ * refusal can be attested against a stand-in: the condition only exists on a
32
+ * compiler this repository cannot install alongside the one it builds with, and
33
+ * a check nothing can make fail is the shape `never-red` exists to catch.
34
+ *
35
+ * No `reqId` and no `file`: the finding is about the toolchain, and every
36
+ * requirement and every file in the project is equally unreadable because of it.
37
+ */
38
+ export declare function compilerIssue(compiler?: CompilerSurface): Issue | undefined;
39
+ export {};
40
+ //# sourceMappingURL=compiler.d.ts.map
@@ -0,0 +1,64 @@
1
+ // Whether the resolved TypeScript can be read from at all (design §5.1).
2
+ //
3
+ // Both registry readers and the spec parser walk the AST with the compiler API,
4
+ // which makes `typescript` the one dependency this tool cannot survive the
5
+ // absence of — and "absent" is not the shape it arrives in. `typescript@7` is
6
+ // the native port: its package resolves, imports cleanly, and answers `version`
7
+ // and `versionMajorMinor`, while `createSourceFile` and every `ts.isX` guard
8
+ // moved behind `typescript/unstable/ast*`. So the members are `undefined` rather
9
+ // than missing, and the first thing to touch one throws.
10
+ //
11
+ // Measured: `attest check` on it died with
12
+ // `TypeError: Cannot read properties of undefined (reading 'Latest')` and a raw
13
+ // stack — no issue code, no fix hint, and nothing saying which compiler it
14
+ // wanted, on the crash path `[0.4.2]` had just finished sanitising.
15
+ //
16
+ // **The declared range excludes 7, and that is not the same as being safe from
17
+ // it.** A caret is a request, not a constraint the adopter cannot override:
18
+ // `pnpm.overrides` and Yarn `resolutions` both pin a transitive dependency
19
+ // across a whole tree, and a repo standardising on one compiler is exactly the
20
+ // repo that uses them — this one has an `overrides` block of its own. So the
21
+ // reachable case is a deliberate, reasonable act by an adopter, answered with a
22
+ // stack trace.
23
+ //
24
+ // What this cannot do is make Attest work on TypeScript 7. That is a migration
25
+ // to `typescript/unstable/ast`, against an API whose own name says not to depend
26
+ // on it yet. The honest answer for now is that the supported range has a ceiling
27
+ // and says so.
28
+ import ts from 'typescript';
29
+ /**
30
+ * The range the readers are written against.
31
+ *
32
+ * Stated here rather than read from the manifest at runtime, because the
33
+ * manifest is not on disk beside a bundled build and a diagnostic that cannot
34
+ * name the range is most of the diagnostic gone. `ATX-56` keeps the two honest:
35
+ * its `params` carry this string, and its scenario compares them with
36
+ * `package.json`, so the constant cannot drift from the dependency it describes.
37
+ */
38
+ export const SUPPORTED_TYPESCRIPT = '^5.5.0 || ^6.0.0';
39
+ /**
40
+ * An ERROR when the resolved compiler has no AST API, or `undefined` when it
41
+ * does.
42
+ *
43
+ * Takes the compiler rather than reaching for the import directly, so the
44
+ * refusal can be attested against a stand-in: the condition only exists on a
45
+ * compiler this repository cannot install alongside the one it builds with, and
46
+ * a check nothing can make fail is the shape `never-red` exists to catch.
47
+ *
48
+ * No `reqId` and no `file`: the finding is about the toolchain, and every
49
+ * requirement and every file in the project is equally unreadable because of it.
50
+ */
51
+ export function compilerIssue(compiler = ts) {
52
+ if (typeof compiler.createSourceFile === 'function' && compiler.ScriptTarget !== undefined) {
53
+ return undefined;
54
+ }
55
+ const version = typeof compiler.version === 'string' ? compiler.version : 'unknown';
56
+ return {
57
+ level: 'ERROR',
58
+ code: 'compiler-unsupported',
59
+ message: `The resolved TypeScript (${version}) does not expose the AST API Attest reads registries and specs with. ` +
60
+ `Attest supports ${SUPPORTED_TYPESCRIPT}. ` +
61
+ `If a resolution or override pins the compiler for the whole tree, exclude Attest from it or move that pin back into the supported range.`,
62
+ };
63
+ }
64
+ //# sourceMappingURL=compiler.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", "change-not-found", "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-unclaimed", "rationale-placeholder", "registry-invalid", "registry-no-default", "registry-not-static", "rename-source-missing", "rename-target-exists", "rename-target-invalid", "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", "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
@@ -22,7 +22,10 @@ export const ISSUE_CODES = [
22
22
  'add-conflict',
23
23
  'add-invalid',
24
24
  'added-id-unmerged',
25
+ 'apply-no-prefix-owner',
26
+ 'apply-unsupported-delta',
25
27
  'change-not-found',
28
+ 'compiler-unsupported',
26
29
  'declared-not-run',
27
30
  'duplicate-prefix',
28
31
  'duplicate-requirement',
@@ -35,6 +38,7 @@ export const ISSUE_CODES = [
35
38
  'never-red',
36
39
  'orphan-test',
37
40
  'possible-drift',
41
+ 'proposed-spec-name-taken',
38
42
  'proposed-spec-unclaimed',
39
43
  'rationale-placeholder',
40
44
  'registry-invalid',
@@ -43,6 +47,8 @@ export const ISSUE_CODES = [
43
47
  'rename-source-missing',
44
48
  'rename-target-exists',
45
49
  'rename-target-invalid',
50
+ 'spec-in-change-dir',
51
+ 'spec-load-failed',
46
52
  'stale-spec-doc',
47
53
  'tests-red',
48
54
  '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),
@@ -98,11 +98,40 @@ export declare function staticReader(): RegistryReader;
98
98
  * prefix a duplicate-prefix ERROR.
99
99
  *
100
100
  * Pass `files` to reuse a {@link scanProject} result instead of re-walking.
101
+ *
102
+ * `prefixOwners` comes back with the registry because the rule deciding it —
103
+ * first claim wins, in sorted file order — must have exactly one spelling. It is
104
+ * what `duplicate-prefix` is computed from here, and what tells `--apply` which
105
+ * file an ADDED id belongs in (design §7); a second walk arriving at its own
106
+ * answer would be a second rule the moment either was edited.
101
107
  */
102
108
  export declare function loadRegistry(root: string, reader: RegistryReader, files?: string[]): Promise<{
103
109
  registry: Registry;
104
110
  issues: Issue[];
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[];
105
124
  }>;
125
+ /**
126
+ * The part of an id that names the space it lives in — `AUTH` of `AUTH-3`.
127
+ *
128
+ * The prefix is the only unit above the requirement (design §11) and two things
129
+ * now depend on agreeing about it: `duplicate-prefix`, and which file `--apply`
130
+ * writes an ADDED requirement into. An id with no dash is its own prefix, which
131
+ * cannot arise from `RequirementIdSchema` and is handled anyway because this
132
+ * also runs over ids a delta proposed.
133
+ */
134
+ export declare function idPrefix(id: string): string;
106
135
  /**
107
136
  * Parse the given spec files into one merged plan (paths shown relative to
108
137
  * `displayRoot`). Files are read concurrently; the merge follows the input
@@ -111,10 +140,43 @@ export declare function loadRegistry(root: string, reader: RegistryReader, files
111
140
  * The paths are POSIX on every platform (see `paths.ts`): they are not only
112
141
  * displayed, they become the child run's `include` globs, where a Windows
113
142
  * separator would silently match nothing.
143
+ *
144
+ * Each source is parsed as it arrives rather than after all of them. The
145
+ * previous `Promise.all(files.map(readFile))` held every spec file in memory at
146
+ * once — measured at ~47 MiB on a synthetic tree of 6000 files — for input size
147
+ * that is not ours to choose, since `check` is the command this project tells
148
+ * people to run first on an untrusted fork MR. Parsing at the point of arrival
149
+ * makes the peak `PARSE_CONCURRENCY` sources instead of `files.length`, and the
150
+ * plan is the only thing that still grows with the tree.
151
+ *
152
+ * `findFiles` above is deliberately left unbounded: its fan-out is real, but the
153
+ * failure it invites is descriptor exhaustion, which no measurement on either
154
+ * development platform could produce (see CHANGELOG.md, `Under consideration`).
155
+ * The memory here needed no such evidence — it is arithmetic, and portable.
114
156
  */
115
157
  export declare function parseSpecs(files: string[], displayRoot: string): Promise<AttestPlan>;
116
158
  /** Parse every `*.spec.ts` under root into one merged plan (file paths shown relative to root). */
117
159
  export declare function parseAllSpecFiles(root: string): Promise<AttestPlan>;
160
+ /**
161
+ * Spec-shaped files sitting under `root/changes` — the location a change's
162
+ * specs used to live at, and which nothing walks any more (design §7).
163
+ *
164
+ * Two correct decisions compose into a blind spot. `changes` is in `SKIP_DIRS`,
165
+ * so `scanProject` cannot reach these; and the explicit include `attest archive`
166
+ * once applied to `changes/<name>/specs/` went away with `changeExcludeGlobs`
167
+ * when specs moved to their merged location. A spec left here therefore executes
168
+ * in no suite and no gate, and the only command that says anything is the gate,
169
+ * blaming coverage for a file it cannot see.
170
+ *
171
+ * Walks through `findFiles` rather than a second walker: `SKIP_DIRS` is consulted
172
+ * for *sub*directories only, so starting the walk at `changes` itself both
173
+ * reaches these files and keeps skipping `node_modules` beneath them.
174
+ *
175
+ * Both spellings are wrong here and both are returned. A `*.spec.ts` is the
176
+ * pre-move layout; a `*.proposed.spec.ts` is the right marker at the wrong path,
177
+ * which is the half-done migration and no more visible than the other.
178
+ */
179
+ export declare function findChangeDirSpecs(root: string): Promise<string[]>;
118
180
  /** List the names of proposed changes under `root/changes`. */
119
181
  export declare function listChangeNames(root: string): Promise<string[]>;
120
182
  //# sourceMappingURL=locate.d.ts.map