@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/CHANGELOG.md +1084 -87
- package/README.md +4 -4
- package/dist/cli/index.js +2 -18
- package/dist/cli/json.js +6 -1
- package/dist/cli/report.d.ts +18 -0
- package/dist/cli/report.js +41 -0
- package/dist/core/apply.js +7 -10
- package/dist/core/docs.d.ts +1 -1
- package/dist/core/docs.js +2 -0
- package/dist/core/gate.d.ts +48 -2
- package/dist/core/gate.js +73 -14
- package/dist/core/loader.js +13 -0
- package/dist/core/locate.d.ts +23 -0
- package/dist/core/locate.js +35 -6
- package/dist/core/merge.js +48 -10
- package/dist/core/order.d.ts +17 -0
- package/dist/core/order.js +25 -0
- package/dist/core/pipeline.js +144 -35
- package/dist/core/render.js +174 -19
- package/dist/core/req-suite.d.ts +5 -0
- package/dist/core/req-suite.js +27 -0
- package/dist/core/runner.js +24 -8
- package/dist/core/schema.d.ts +13 -6
- package/dist/core/schema.js +54 -18
- package/dist/core/skill.js +6 -2
- package/dist/core/splice.d.ts +13 -12
- package/dist/core/splice.js +59 -18
- package/dist/core/static-registry.js +6 -0
- package/dist/core/status.js +16 -3
- package/dist/core/terminal.d.ts +12 -3
- package/dist/core/terminal.js +14 -12
- package/dist/core/types.d.ts +1 -1
- package/dist/core/validator.d.ts +6 -1
- package/dist/core/validator.js +60 -2
- package/dist/runtime.d.ts +20 -0
- package/dist/runtime.js +43 -15
- package/package.json +1 -1
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
|
|
16
|
-
|
|
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.
|
|
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
|
-
|
|
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
|
-
|
|
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,
|
package/dist/cli/report.d.ts
CHANGED
|
@@ -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
|
package/dist/cli/report.js
CHANGED
|
@@ -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)');
|
package/dist/core/apply.js
CHANGED
|
@@ -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
|
-
|
|
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
|
package/dist/core/docs.d.ts
CHANGED
|
@@ -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',
|
package/dist/core/gate.d.ts
CHANGED
|
@@ -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.
|
|
89
|
-
*
|
|
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.
|
|
120
|
-
*
|
|
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)
|
|
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 ?? {}));
|
package/dist/core/loader.js
CHANGED
|
@@ -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),
|
package/dist/core/locate.d.ts
CHANGED
|
@@ -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). */
|
package/dist/core/locate.js
CHANGED
|
@@ -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
|
-
|
|
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
|
|
293
|
-
|
|
294
|
-
plan.
|
|
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
|
}
|
package/dist/core/merge.js
CHANGED
|
@@ -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(
|
|
60
|
+
if (await exists(archive)) {
|
|
60
61
|
issues.push({
|
|
61
62
|
level: 'ERROR',
|
|
62
63
|
code: 'apply-unsupported-delta',
|
|
63
|
-
file: relativePath(root,
|
|
64
|
-
message: `${relativePath(root,
|
|
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
|
-
|
|
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
|
-
|
|
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) {
|