@am_shork/attest 0.7.0 → 0.7.2
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 +562 -112
- package/README.md +4 -4
- package/bin/attest.js +0 -0
- package/dist/cli/action.d.ts +48 -0
- package/dist/cli/action.js +100 -0
- package/dist/cli/index.js +11 -32
- package/dist/cli/report.js +9 -1
- 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/locate.d.ts +9 -10
- package/dist/core/locate.js +58 -15
- package/dist/core/merge.js +27 -2
- package/dist/core/order.d.ts +17 -0
- package/dist/core/order.js +25 -0
- package/dist/core/pipeline.js +34 -14
- package/dist/core/render.js +90 -50
- package/dist/core/runner.js +4 -6
- package/dist/core/schema.d.ts +13 -6
- package/dist/core/schema.js +54 -18
- 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 +4 -9
- package/dist/core/terminal.d.ts +9 -23
- package/dist/core/terminal.js +9 -23
- package/dist/core/types.d.ts +1 -1
- package/dist/core/validator.d.ts +1 -0
- package/dist/core/validator.js +37 -0
- package/package.json +29 -17
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.7.
|
|
160
|
+
→ https://gitlab.com/Pseudorca/attest/-/blob/v0.7.2/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/bin/attest.js
CHANGED
|
File without changes
|
|
@@ -0,0 +1,48 @@
|
|
|
1
|
+
import { type JsonCommand, type JsonReport } from './json.js';
|
|
2
|
+
/** What an action produces: the machine report plus how to render it for humans. */
|
|
3
|
+
export interface Rendered {
|
|
4
|
+
report: JsonReport;
|
|
5
|
+
human: () => void;
|
|
6
|
+
}
|
|
7
|
+
export interface ActionOptions {
|
|
8
|
+
json?: boolean;
|
|
9
|
+
}
|
|
10
|
+
/**
|
|
11
|
+
* Send everything written to `process.stdout` to stderr until the returned
|
|
12
|
+
* function is called.
|
|
13
|
+
*
|
|
14
|
+
* This is what keeps the `--json` promise against output Attest does not write
|
|
15
|
+
* — the third obligation on the machine surface in design §9.1, stated there
|
|
16
|
+
* because it belongs to the command shell rather than to any emitter, and
|
|
17
|
+
* attested by ATX-64. The child Vitest run of `verify`/`archive` executes
|
|
18
|
+
* project code,
|
|
19
|
+
* and a bare `process.stdout.write` in a spec reaches this stream — `silent:
|
|
20
|
+
* true` only suppresses Vitest's *console interception*, which such a write
|
|
21
|
+
* never enters. Measured: the child's writes do arrive through the parent's
|
|
22
|
+
* `process.stdout.write` rather than through a separately inherited descriptor,
|
|
23
|
+
* which is why patching it here is sufficient and a change of Vitest pool is
|
|
24
|
+
* the thing that could quietly make it insufficient. The scenario under ATX-64
|
|
25
|
+
* runs a real child run for that reason, rather than asserting on this function
|
|
26
|
+
* with a stub.
|
|
27
|
+
*
|
|
28
|
+
* Diverted, not discarded: a human reading a red pipeline still needs the run
|
|
29
|
+
* output, and stderr is the stream no `--json` consumer parses. It is not
|
|
30
|
+
* sanitised on the way — `verify` executes project code by design, so painting
|
|
31
|
+
* the terminal is not a capability this path lacked (design §9.1 scopes its
|
|
32
|
+
* guarantee to what Attest itself writes).
|
|
33
|
+
*/
|
|
34
|
+
export declare function divertStdout(): () => void;
|
|
35
|
+
/**
|
|
36
|
+
* Run one command's work with a single output + exit-code contract:
|
|
37
|
+
* - success: print the JSON report (`--json`) or the human rendering, and set
|
|
38
|
+
* the exit code from `report.ok` — the one source of the verdict.
|
|
39
|
+
* - failure: under `--json`, still emit one parseable `internal-error`
|
|
40
|
+
* envelope on stdout; otherwise print the stack on stderr. Exit code 1.
|
|
41
|
+
*
|
|
42
|
+
* Under `--json` the action runs with stdout diverted, so the report is the
|
|
43
|
+
* only thing on that stream. The divert is released before either rendering
|
|
44
|
+
* runs — the report has to reach the real stdout, and the human path was never
|
|
45
|
+
* under the promise.
|
|
46
|
+
*/
|
|
47
|
+
export declare function runAction(command: JsonCommand, opts: ActionOptions, action: () => Promise<Rendered>): Promise<void>;
|
|
48
|
+
//# sourceMappingURL=action.d.ts.map
|
|
@@ -0,0 +1,100 @@
|
|
|
1
|
+
// The convergence point every command passes through: one output, one exit
|
|
2
|
+
// code (design §9). It lives here rather than in `cli/index.ts` because that
|
|
3
|
+
// module calls `program.parseAsync()` at import time — importing it to test the
|
|
4
|
+
// contract would run the CLI. The interface is the test surface, so the
|
|
5
|
+
// contract moved to where a scenario can cross the same seam a command does.
|
|
6
|
+
import { renderJson, errorReport } from './json.js';
|
|
7
|
+
import { formatCrash } from './report.js';
|
|
8
|
+
import { packageVersion } from '../core/version.js';
|
|
9
|
+
const VERSION = packageVersion();
|
|
10
|
+
/**
|
|
11
|
+
* Send everything written to `process.stdout` to stderr until the returned
|
|
12
|
+
* function is called.
|
|
13
|
+
*
|
|
14
|
+
* This is what keeps the `--json` promise against output Attest does not write
|
|
15
|
+
* — the third obligation on the machine surface in design §9.1, stated there
|
|
16
|
+
* because it belongs to the command shell rather than to any emitter, and
|
|
17
|
+
* attested by ATX-64. The child Vitest run of `verify`/`archive` executes
|
|
18
|
+
* project code,
|
|
19
|
+
* and a bare `process.stdout.write` in a spec reaches this stream — `silent:
|
|
20
|
+
* true` only suppresses Vitest's *console interception*, which such a write
|
|
21
|
+
* never enters. Measured: the child's writes do arrive through the parent's
|
|
22
|
+
* `process.stdout.write` rather than through a separately inherited descriptor,
|
|
23
|
+
* which is why patching it here is sufficient and a change of Vitest pool is
|
|
24
|
+
* the thing that could quietly make it insufficient. The scenario under ATX-64
|
|
25
|
+
* runs a real child run for that reason, rather than asserting on this function
|
|
26
|
+
* with a stub.
|
|
27
|
+
*
|
|
28
|
+
* Diverted, not discarded: a human reading a red pipeline still needs the run
|
|
29
|
+
* output, and stderr is the stream no `--json` consumer parses. It is not
|
|
30
|
+
* sanitised on the way — `verify` executes project code by design, so painting
|
|
31
|
+
* the terminal is not a capability this path lacked (design §9.1 scopes its
|
|
32
|
+
* guarantee to what Attest itself writes).
|
|
33
|
+
*/
|
|
34
|
+
export function divertStdout() {
|
|
35
|
+
const stream = process.stdout;
|
|
36
|
+
// The prior *state* of the property, not the function it held. `write` is
|
|
37
|
+
// inherited from `Writable.prototype`, so the patch below is a new own
|
|
38
|
+
// property and undoing it means removing that property — assigning the old
|
|
39
|
+
// function back would leave a bound copy shadowing the prototype forever, and
|
|
40
|
+
// would silently swallow anyone else's patch on a nested divert.
|
|
41
|
+
const owned = Object.getOwnPropertyDescriptor(stream, 'write');
|
|
42
|
+
const divert = (chunk, encoding, callback) => typeof encoding === 'function'
|
|
43
|
+
? process.stderr.write(chunk, encoding)
|
|
44
|
+
: process.stderr.write(chunk, encoding, callback);
|
|
45
|
+
stream.write = divert;
|
|
46
|
+
return () => {
|
|
47
|
+
if (owned)
|
|
48
|
+
Object.defineProperty(stream, 'write', owned);
|
|
49
|
+
else
|
|
50
|
+
Reflect.deleteProperty(stream, 'write');
|
|
51
|
+
};
|
|
52
|
+
}
|
|
53
|
+
/**
|
|
54
|
+
* Run one command's work with a single output + exit-code contract:
|
|
55
|
+
* - success: print the JSON report (`--json`) or the human rendering, and set
|
|
56
|
+
* the exit code from `report.ok` — the one source of the verdict.
|
|
57
|
+
* - failure: under `--json`, still emit one parseable `internal-error`
|
|
58
|
+
* envelope on stdout; otherwise print the stack on stderr. Exit code 1.
|
|
59
|
+
*
|
|
60
|
+
* Under `--json` the action runs with stdout diverted, so the report is the
|
|
61
|
+
* only thing on that stream. The divert is released before either rendering
|
|
62
|
+
* runs — the report has to reach the real stdout, and the human path was never
|
|
63
|
+
* under the promise.
|
|
64
|
+
*/
|
|
65
|
+
export async function runAction(command, opts, action) {
|
|
66
|
+
try {
|
|
67
|
+
const { report, human } = await withStdoutDiverted(opts.json === true, action);
|
|
68
|
+
if (opts.json)
|
|
69
|
+
console.log(renderJson(report));
|
|
70
|
+
else
|
|
71
|
+
human();
|
|
72
|
+
process.exitCode = report.ok ? 0 : 1;
|
|
73
|
+
}
|
|
74
|
+
catch (err) {
|
|
75
|
+
if (opts.json) {
|
|
76
|
+
// Unsanitised on purpose: `JSON.stringify` escapes every C0 character,
|
|
77
|
+
// so these bytes cannot carry one out (see report.ts). That is a claim
|
|
78
|
+
// about what this line writes and nothing wider — what the project under
|
|
79
|
+
// test writes is the divert's business, above.
|
|
80
|
+
console.log(renderJson(errorReport(VERSION, command, err)));
|
|
81
|
+
}
|
|
82
|
+
else {
|
|
83
|
+
console.error(formatCrash(err));
|
|
84
|
+
}
|
|
85
|
+
process.exitCode = 1;
|
|
86
|
+
}
|
|
87
|
+
}
|
|
88
|
+
/** Run `action` with stdout diverted when `divert`, restoring on every path. */
|
|
89
|
+
async function withStdoutDiverted(divert, action) {
|
|
90
|
+
if (!divert)
|
|
91
|
+
return action();
|
|
92
|
+
const release = divertStdout();
|
|
93
|
+
try {
|
|
94
|
+
return await action();
|
|
95
|
+
}
|
|
96
|
+
finally {
|
|
97
|
+
release();
|
|
98
|
+
}
|
|
99
|
+
}
|
|
100
|
+
//# sourceMappingURL=action.js.map
|
package/dist/cli/index.js
CHANGED
|
@@ -7,14 +7,22 @@
|
|
|
7
7
|
// The exit code is always derived from that report's `ok`, so the JSON verdict
|
|
8
8
|
// and the process status can never disagree — including on the crash path,
|
|
9
9
|
// where the report is an `internal-error` envelope instead of a bare stack.
|
|
10
|
+
//
|
|
11
|
+
// Both halves of that promise are kept by `runAction` (cli/action.ts), which is
|
|
12
|
+
// where the contract is stated and tested; this file only supplies the work.
|
|
10
13
|
import { Command } from 'commander';
|
|
11
14
|
import chalk from 'chalk';
|
|
12
15
|
import { resolve } from 'node:path';
|
|
13
16
|
import { writeAtomic } from '../core/write.js';
|
|
14
17
|
import { runCheck, runVerify, runCover, runArchive, runArchiveApply, runInit, runRender, runRenderCheck, runStatus, DEFAULT_TARGET, TARGET_NAMES, } from '../core/pipeline.js';
|
|
15
18
|
import { hasError } from '../core/types.js';
|
|
19
|
+
// The one project-derived value this shell still interpolates itself. `--out`
|
|
20
|
+
// is argv, which on a fork MR pipeline is written by the repository's own CI
|
|
21
|
+
// config — so it is the project's text, not the operator's (ATX-37).
|
|
22
|
+
import { inline } from '../core/terminal.js';
|
|
23
|
+
import { runAction } from './action.js';
|
|
16
24
|
import { formatArchiveVerdict, formatCrash, formatIssues, formatScope, summarize, formatCoverage, formatStatus, } from './report.js';
|
|
17
|
-
import { archiveReport, checkReport, coverReport,
|
|
25
|
+
import { archiveReport, checkReport, coverReport, initReport, renderReport, statusReport, verifyReport, } from './json.js';
|
|
18
26
|
import { packageVersion } from '../core/version.js';
|
|
19
27
|
const VERSION = packageVersion();
|
|
20
28
|
/** `runArchive` in the shape `--apply` returns, so the two share one call site. */
|
|
@@ -47,34 +55,6 @@ const VITEST_CONFIG_HELP = 'load this Vitest config in the child run (transforms
|
|
|
47
55
|
function vitestConfig(opts) {
|
|
48
56
|
return opts.vitestConfig ? resolve(process.cwd(), opts.vitestConfig) : undefined;
|
|
49
57
|
}
|
|
50
|
-
/**
|
|
51
|
-
* Run one command's work with a single output + exit-code contract:
|
|
52
|
-
* - success: print the JSON report (`--json`) or the human rendering, and set
|
|
53
|
-
* the exit code from `report.ok` — the one source of the verdict.
|
|
54
|
-
* - failure: under `--json`, still emit one parseable `internal-error`
|
|
55
|
-
* envelope on stdout; otherwise print the stack on stderr. Exit code 1.
|
|
56
|
-
*/
|
|
57
|
-
async function runAction(command, opts, action) {
|
|
58
|
-
try {
|
|
59
|
-
const { report, human } = await action();
|
|
60
|
-
if (opts.json)
|
|
61
|
-
console.log(renderJson(report));
|
|
62
|
-
else
|
|
63
|
-
human();
|
|
64
|
-
process.exitCode = report.ok ? 0 : 1;
|
|
65
|
-
}
|
|
66
|
-
catch (err) {
|
|
67
|
-
if (opts.json) {
|
|
68
|
-
// Unsanitised on purpose: `JSON.stringify` escapes every C0 character, so
|
|
69
|
-
// the machine surface was never the exposed one (see report.ts).
|
|
70
|
-
console.log(renderJson(errorReport(VERSION, command, err)));
|
|
71
|
-
}
|
|
72
|
-
else {
|
|
73
|
-
console.error(formatCrash(err));
|
|
74
|
-
}
|
|
75
|
-
process.exitCode = 1;
|
|
76
|
-
}
|
|
77
|
-
}
|
|
78
58
|
program
|
|
79
59
|
.command('check')
|
|
80
60
|
.description('Static structural validation (fast CI pre-check); runs no project code.')
|
|
@@ -168,7 +148,7 @@ program
|
|
|
168
148
|
report: renderReport(VERSION, issues, opts.out),
|
|
169
149
|
human: () => {
|
|
170
150
|
if (issues.length === 0)
|
|
171
|
-
console.log(chalk.green(`✓ ${opts.out} is up to date.`));
|
|
151
|
+
console.log(chalk.green(`✓ ${inline(opts.out ?? '')} is up to date.`));
|
|
172
152
|
else {
|
|
173
153
|
console.log(formatIssues(issues));
|
|
174
154
|
console.log(summarize(issues));
|
|
@@ -188,7 +168,7 @@ program
|
|
|
188
168
|
console.log(summarize(issues));
|
|
189
169
|
}
|
|
190
170
|
else if (dest) {
|
|
191
|
-
console.log(chalk.green(`✓ Wrote ${opts.out}`));
|
|
171
|
+
console.log(chalk.green(`✓ Wrote ${inline(opts.out ?? '')}`));
|
|
192
172
|
}
|
|
193
173
|
else {
|
|
194
174
|
process.stdout.write(markdown);
|
|
@@ -261,7 +241,6 @@ program
|
|
|
261
241
|
console.log(summarize(result.issues));
|
|
262
242
|
return;
|
|
263
243
|
}
|
|
264
|
-
console.log(chalk.bold(`Change: ${result.change}`));
|
|
265
244
|
console.log(formatStatus(result));
|
|
266
245
|
},
|
|
267
246
|
};
|
package/dist/cli/report.js
CHANGED
|
@@ -166,7 +166,15 @@ const STATE_MARK = {
|
|
|
166
166
|
* the argument that keeps `render --check` comparing documents rather than bytes.
|
|
167
167
|
*/
|
|
168
168
|
export function formatStatus(result) {
|
|
169
|
-
|
|
169
|
+
// The header is built here rather than in the CLI action, and that is a fix
|
|
170
|
+
// rather than a tidy-up. It was interpolated and printed in `cli/index.ts`,
|
|
171
|
+
// which put it past the sanitiser — three lines above a closing line that
|
|
172
|
+
// puts the same value through `inline`. `ATX-37` covers "everything the CLI
|
|
173
|
+
// writes to a terminal", and a write that lives in the shell can only be
|
|
174
|
+
// attested by spawning a process, because `cli/index.ts` runs the CLI at
|
|
175
|
+
// import. Moving the line to the module that already owns this report makes
|
|
176
|
+
// the obligation reachable by a scenario — the interface is the test surface.
|
|
177
|
+
const lines = [chalk.bold(`Change: ${inline(result.change)}`)];
|
|
170
178
|
if (result.rows.length === 0) {
|
|
171
179
|
lines.push(chalk.dim('(this change adds no requirements)'));
|
|
172
180
|
}
|
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", "spec-load-failed", "stale-spec-doc", "tests-red", "unbound-param", "uncovered-requirement", "unknown-target"];
|
|
11
|
+
export declare const ISSUE_CODES: readonly ["add-conflict", "add-invalid", "added-id-unmerged", "apply-no-prefix-owner", "apply-unsupported-delta", "change-not-found", "compiler-unsupported", "declared-not-run", "duplicate-prefix", "duplicate-requirement", "empty-spec", "internal-error", "invalid-change-name", "missing-spec-doc", "modify-invalid", "modify-missing", "never-red", "non-scalar-interpolation", "orphan-test", "possible-drift", "proposed-spec-name-taken", "proposed-spec-unclaimed", "rationale-placeholder", "registry-invalid", "registry-no-default", "registry-not-static", "rename-source-missing", "rename-target-exists", "rename-target-invalid", "spec-in-change-dir", "spec-load-failed", "stale-spec-doc", "tests-red", "unbound-param", "uncovered-requirement", "unknown-target", "unreadable-file"];
|
|
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',
|
|
@@ -54,6 +55,7 @@ export const ISSUE_CODES = [
|
|
|
54
55
|
'unbound-param',
|
|
55
56
|
'uncovered-requirement',
|
|
56
57
|
'unknown-target',
|
|
58
|
+
'unreadable-file',
|
|
57
59
|
];
|
|
58
60
|
const DOCUMENTED = new Set(ISSUE_CODES);
|
|
59
61
|
const REPO = 'https://gitlab.com/Pseudorca/attest/-/blob';
|
package/dist/core/locate.d.ts
CHANGED
|
@@ -141,22 +141,21 @@ export declare function idPrefix(id: string): string;
|
|
|
141
141
|
* displayed, they become the child run's `include` globs, where a Windows
|
|
142
142
|
* separator would silently match nothing.
|
|
143
143
|
*
|
|
144
|
-
* Each source is parsed as it arrives rather than after all of them
|
|
145
|
-
*
|
|
146
|
-
*
|
|
147
|
-
*
|
|
148
|
-
* people to run first on an untrusted fork MR.
|
|
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.
|
|
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]`.
|
|
151
149
|
*
|
|
152
150
|
* `findFiles` above is deliberately left unbounded: its fan-out is real, but the
|
|
153
151
|
* failure it invites is descriptor exhaustion, which no measurement on either
|
|
154
152
|
* development platform could produce (see CHANGELOG.md, `Under consideration`).
|
|
155
153
|
* The memory here needed no such evidence — it is arithmetic, and portable.
|
|
156
154
|
*/
|
|
157
|
-
export declare function parseSpecs(files: string[], displayRoot: string): Promise<
|
|
158
|
-
|
|
159
|
-
|
|
155
|
+
export declare function parseSpecs(files: string[], displayRoot: string): Promise<{
|
|
156
|
+
plan: AttestPlan;
|
|
157
|
+
issues: Issue[];
|
|
158
|
+
}>;
|
|
160
159
|
/**
|
|
161
160
|
* Spec-shaped files sitting under `root/changes` — the location a change's
|
|
162
161
|
* specs used to live at, and which nothing walks any more (design §7).
|
package/dist/core/locate.js
CHANGED
|
@@ -95,6 +95,43 @@ export async function scanProject(root) {
|
|
|
95
95
|
}
|
|
96
96
|
return { reqsFiles, specFiles, proposedSpecFiles };
|
|
97
97
|
}
|
|
98
|
+
/**
|
|
99
|
+
* The message an unreadable file gets, in one place because two call sites
|
|
100
|
+
* raise it — a registry and a spec — and they must not drift into two
|
|
101
|
+
* descriptions of one condition (ATX-65).
|
|
102
|
+
*/
|
|
103
|
+
function unreadableIssue(err) {
|
|
104
|
+
// `RangeError` in practice, from the call stack running out inside
|
|
105
|
+
// TypeScript's recursive-descent parser. Caught as `unknown` rather than
|
|
106
|
+
// narrowed to it: the guard's promise is that *no* throw from one file ends
|
|
107
|
+
// the run, and narrowing would make that promise true only of the one trigger
|
|
108
|
+
// that has been measured.
|
|
109
|
+
const detail = err instanceof Error ? err.message : String(err);
|
|
110
|
+
return {
|
|
111
|
+
level: 'ERROR',
|
|
112
|
+
code: 'unreadable-file',
|
|
113
|
+
message: `Could not be read, so nothing in it was checked: ${detail}. ` +
|
|
114
|
+
`Everything else in this run was still reported.`,
|
|
115
|
+
};
|
|
116
|
+
}
|
|
117
|
+
/**
|
|
118
|
+
* `reader.read`, with a throw turned into an issue about that file.
|
|
119
|
+
*
|
|
120
|
+
* At the loop rather than inside either reader, because what is being kept is a
|
|
121
|
+
* property of the *run* — one file's failure is not the run's failure — and
|
|
122
|
+
* both readers need it. `readRegistry` folds these concurrently, so an
|
|
123
|
+
* uncaught throw here rejects the whole `Promise.all` and ends the command:
|
|
124
|
+
* measured, a 20,000-deep literal in one `params` value reduced `attest check`
|
|
125
|
+
* to a single `internal-error` (design §5.1, ATX-65).
|
|
126
|
+
*/
|
|
127
|
+
async function readGuarded(reader, file) {
|
|
128
|
+
try {
|
|
129
|
+
return await reader.read(file);
|
|
130
|
+
}
|
|
131
|
+
catch (err) {
|
|
132
|
+
return { issue: unreadableIssue(err) };
|
|
133
|
+
}
|
|
134
|
+
}
|
|
98
135
|
/**
|
|
99
136
|
* Read registries by **executing** the module through the Vite loader.
|
|
100
137
|
*
|
|
@@ -207,7 +244,7 @@ export async function loadRegistry(root, reader, files) {
|
|
|
207
244
|
const paths = files ?? (await scanProject(root)).reqsFiles;
|
|
208
245
|
// Read the files concurrently, then fold the results in sorted file order:
|
|
209
246
|
// the issue list stays deterministic regardless of which one finished first.
|
|
210
|
-
const loaded = await Promise.all(paths.map(async (file) => ({ file, outcome: await reader
|
|
247
|
+
const loaded = await Promise.all(paths.map(async (file) => ({ file, outcome: await readGuarded(reader, file) })));
|
|
211
248
|
const registry = {};
|
|
212
249
|
const issues = [];
|
|
213
250
|
const unreadableFiles = [];
|
|
@@ -294,13 +331,11 @@ const PARSE_CONCURRENCY = 32;
|
|
|
294
331
|
* displayed, they become the child run's `include` globs, where a Windows
|
|
295
332
|
* separator would silently match nothing.
|
|
296
333
|
*
|
|
297
|
-
* Each source is parsed as it arrives rather than after all of them
|
|
298
|
-
*
|
|
299
|
-
*
|
|
300
|
-
*
|
|
301
|
-
* people to run first on an untrusted fork MR.
|
|
302
|
-
* makes the peak `PARSE_CONCURRENCY` sources instead of `files.length`, and the
|
|
303
|
-
* plan is the only thing that still grows with the tree.
|
|
334
|
+
* Each source is parsed as it arrives rather than after all of them, so the peak
|
|
335
|
+
* is `PARSE_CONCURRENCY` sources rather than `files.length` and the plan is the
|
|
336
|
+
* only thing still growing with the tree. Reading them all first is the shape to
|
|
337
|
+
* avoid: input size is not ours to choose here, `check` being what this project
|
|
338
|
+
* tells people to run first on an untrusted fork MR. Measured in `[0.7.0]`.
|
|
304
339
|
*
|
|
305
340
|
* `findFiles` above is deliberately left unbounded: its fan-out is real, but the
|
|
306
341
|
* failure it invites is descriptor exhaustion, which no measurement on either
|
|
@@ -311,12 +346,24 @@ export async function parseSpecs(files, displayRoot) {
|
|
|
311
346
|
// Indexed rather than appended, so the merge below follows the input order
|
|
312
347
|
// whatever order the reads finish in.
|
|
313
348
|
const parsed = new Array(files.length);
|
|
349
|
+
// Same index space, so a file contributes either a parse or an issue and the
|
|
350
|
+
// two lists cannot disagree about which file is which.
|
|
351
|
+
const failures = new Array(files.length);
|
|
314
352
|
let next = 0;
|
|
315
353
|
const worker = async () => {
|
|
316
354
|
for (let i = next++; i < files.length; i = next++) {
|
|
317
355
|
const file = files[i];
|
|
318
|
-
const
|
|
319
|
-
|
|
356
|
+
const display = relativePath(displayRoot, file);
|
|
357
|
+
// Per file, for the reason `readGuarded` exists above: these run
|
|
358
|
+
// concurrently, so one throw rejects the whole `Promise.all` and takes
|
|
359
|
+
// the command with it. A hostile spec scraps only itself (ATX-65).
|
|
360
|
+
try {
|
|
361
|
+
parsed[i] = parseSpecFile(display, await readFile(file, 'utf8'));
|
|
362
|
+
}
|
|
363
|
+
catch (err) {
|
|
364
|
+
parsed[i] = { scenarios: [], paramRefs: [] };
|
|
365
|
+
failures[i] = { ...unreadableIssue(err), file: display };
|
|
366
|
+
}
|
|
320
367
|
}
|
|
321
368
|
};
|
|
322
369
|
await Promise.all(Array.from({ length: Math.min(PARSE_CONCURRENCY, files.length) }, worker));
|
|
@@ -325,11 +372,7 @@ export async function parseSpecs(files, displayRoot) {
|
|
|
325
372
|
plan.scenarios.push(...one.scenarios);
|
|
326
373
|
plan.paramRefs.push(...one.paramRefs);
|
|
327
374
|
}
|
|
328
|
-
return plan;
|
|
329
|
-
}
|
|
330
|
-
/** Parse every `*.spec.ts` under root into one merged plan (file paths shown relative to root). */
|
|
331
|
-
export async function parseAllSpecFiles(root) {
|
|
332
|
-
return parseSpecs(await findFiles(root, isSpecFile), root);
|
|
375
|
+
return { plan, issues: failures.filter((i) => i !== undefined) };
|
|
333
376
|
}
|
|
334
377
|
/**
|
|
335
378
|
* Spec-shaped files sitting under `root/changes` — the location a change's
|
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';
|
|
@@ -178,7 +178,32 @@ export async function applyMerge(input) {
|
|
|
178
178
|
}
|
|
179
179
|
for (const file of [...byFile.keys()].sort(byCodeUnit)) {
|
|
180
180
|
const source = await readFile(file, 'utf8');
|
|
181
|
-
|
|
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
|
+
}
|
|
182
207
|
if (spliced === undefined) {
|
|
183
208
|
// Unreachable through the command — the gate read this file as a literal
|
|
184
209
|
// moments ago — so it is reported as the internal inconsistency it is
|
package/dist/core/order.d.ts
CHANGED
|
@@ -1,3 +1,20 @@
|
|
|
1
1
|
/** Compare by UTF-16 code unit — the same order as a bare `Array#sort()`. */
|
|
2
2
|
export declare function byCodeUnit(a: string, b: string): number;
|
|
3
|
+
/**
|
|
4
|
+
* A JSON value with every object's keys in code-unit order, at every depth.
|
|
5
|
+
*
|
|
6
|
+
* `JSON.stringify` writes object keys in insertion order, which is *how the
|
|
7
|
+
* source happened to be written* — the thing this module exists to keep out of
|
|
8
|
+
* anything compared or committed. Two callers need the same guarantee for the
|
|
9
|
+
* same reason, one depth apart:
|
|
10
|
+
*
|
|
11
|
+
* - `apply.ts` canonicalises a requirement to decide `add-conflict`, so an
|
|
12
|
+
* identical copy written with its keys in another order must not read as a
|
|
13
|
+
* conflict with itself.
|
|
14
|
+
* - `render.ts` emits a structured param as JSON, and ATX-10 holds the same
|
|
15
|
+
* registry to the same bytes.
|
|
16
|
+
*
|
|
17
|
+
* Arrays keep their order: an array is data whose order is part of the value.
|
|
18
|
+
*/
|
|
19
|
+
export declare function sortDeep<T>(value: T): T;
|
|
3
20
|
//# sourceMappingURL=order.d.ts.map
|
package/dist/core/order.js
CHANGED
|
@@ -10,4 +10,29 @@
|
|
|
10
10
|
export function byCodeUnit(a, b) {
|
|
11
11
|
return a < b ? -1 : a > b ? 1 : 0;
|
|
12
12
|
}
|
|
13
|
+
/**
|
|
14
|
+
* A JSON value with every object's keys in code-unit order, at every depth.
|
|
15
|
+
*
|
|
16
|
+
* `JSON.stringify` writes object keys in insertion order, which is *how the
|
|
17
|
+
* source happened to be written* — the thing this module exists to keep out of
|
|
18
|
+
* anything compared or committed. Two callers need the same guarantee for the
|
|
19
|
+
* same reason, one depth apart:
|
|
20
|
+
*
|
|
21
|
+
* - `apply.ts` canonicalises a requirement to decide `add-conflict`, so an
|
|
22
|
+
* identical copy written with its keys in another order must not read as a
|
|
23
|
+
* conflict with itself.
|
|
24
|
+
* - `render.ts` emits a structured param as JSON, and ATX-10 holds the same
|
|
25
|
+
* registry to the same bytes.
|
|
26
|
+
*
|
|
27
|
+
* Arrays keep their order: an array is data whose order is part of the value.
|
|
28
|
+
*/
|
|
29
|
+
export function sortDeep(value) {
|
|
30
|
+
if (Array.isArray(value))
|
|
31
|
+
return value.map(sortDeep);
|
|
32
|
+
// `typeof null === 'object'`, and a null param is a value like any other.
|
|
33
|
+
if (typeof value !== 'object' || value === null)
|
|
34
|
+
return value;
|
|
35
|
+
const entries = Object.entries(value).sort(([a], [b]) => byCodeUnit(a, b));
|
|
36
|
+
return Object.fromEntries(entries.map(([k, v]) => [k, sortDeep(v)]));
|
|
37
|
+
}
|
|
13
38
|
//# sourceMappingURL=order.js.map
|