@am_shork/attest 0.7.1 → 0.7.3
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 +597 -129
- package/README.md +10 -1
- 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/json.d.ts +3 -2
- package/dist/cli/json.js +3 -2
- package/dist/cli/report.js +9 -1
- package/dist/core/docs.d.ts +1 -1
- package/dist/core/docs.js +2 -0
- package/dist/core/loader.js +63 -33
- package/dist/core/locate.d.ts +4 -3
- package/dist/core/locate.js +53 -8
- package/dist/core/paths.d.ts +16 -0
- package/dist/core/paths.js +20 -1
- package/dist/core/pipeline.js +100 -14
- package/dist/core/render.js +190 -21
- package/dist/core/skill.js +19 -0
- package/package.json +1 -1
package/README.md
CHANGED
|
@@ -29,6 +29,15 @@ together and the test stays green through any edit. Pin the expectation to
|
|
|
29
29
|
something that does not move with the param — a fixture, a literal in the test,
|
|
30
30
|
or a second independently derived value.
|
|
31
31
|
|
|
32
|
+
A composite param has a second failure of its own, and it runs the other way. When
|
|
33
|
+
a test **loops over** a list it read from `params`, that list is the set of cases
|
|
34
|
+
the run covers: drop a member and every assertion inside the loop still passes
|
|
35
|
+
over what is left, so the suite quietly tests less with nothing to show for it.
|
|
36
|
+
Pin the extent beside the loop — the members against a literal when their identity
|
|
37
|
+
is the promise, the length when the size is. That literal is not the copy the
|
|
38
|
+
single source exists to prevent: it is not what the system is measured against,
|
|
39
|
+
it is what the intent claimed to cover.
|
|
40
|
+
|
|
32
41
|
## Prerequisites
|
|
33
42
|
|
|
34
43
|
- Node ≥ 20.19
|
|
@@ -157,7 +166,7 @@ Every diagnostic carries a `code`, and every code has a section in
|
|
|
157
166
|
```
|
|
158
167
|
ERROR registry-not-static (requirements/upload.reqs.ts:5)
|
|
159
168
|
Value is not a literal.
|
|
160
|
-
→ https://gitlab.com/Pseudorca/attest/-/blob/v0.7.
|
|
169
|
+
→ https://gitlab.com/Pseudorca/attest/-/blob/v0.7.3/docs/en/troubleshooting.md#registry-not-static
|
|
161
170
|
```
|
|
162
171
|
|
|
163
172
|
The anchor **is** the code, so the link cannot point somewhere the section
|
|
@@ -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/json.d.ts
CHANGED
|
@@ -74,8 +74,9 @@ export declare function renderReport(version: string, issues: Issue[], outFile?:
|
|
|
74
74
|
/**
|
|
75
75
|
* `init`. Carries the paths written so a consumer learns where the instructions
|
|
76
76
|
* went without hardcoding the convention. `issues` is empty on the success path
|
|
77
|
-
* — `init` writes or throws — apart from
|
|
78
|
-
*
|
|
77
|
+
* — `init` writes or throws — apart from the two ERRORs it refuses on,
|
|
78
|
+
* `unknown-target` and `unsafe-target-path`. Both are decided before anything is
|
|
79
|
+
* written and so report no files at all.
|
|
79
80
|
*
|
|
80
81
|
* `outFile` survives beside `outFiles` for the one-file run, which is what the
|
|
81
82
|
* default invocation still is: dropping it would break the consumer the field
|
package/dist/cli/json.js
CHANGED
|
@@ -92,8 +92,9 @@ export function renderReport(version, issues, outFile) {
|
|
|
92
92
|
/**
|
|
93
93
|
* `init`. Carries the paths written so a consumer learns where the instructions
|
|
94
94
|
* went without hardcoding the convention. `issues` is empty on the success path
|
|
95
|
-
* — `init` writes or throws — apart from
|
|
96
|
-
*
|
|
95
|
+
* — `init` writes or throws — apart from the two ERRORs it refuses on,
|
|
96
|
+
* `unknown-target` and `unsafe-target-path`. Both are decided before anything is
|
|
97
|
+
* written and so report no files at all.
|
|
97
98
|
*
|
|
98
99
|
* `outFile` survives beside `outFiles` for the one-file run, which is what the
|
|
99
100
|
* default invocation still is: dropping it would break the consumer the field
|
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/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", "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"];
|
|
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", "unsafe-target-path"];
|
|
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
package/dist/core/loader.js
CHANGED
|
@@ -60,38 +60,59 @@ const VITEST_STUB = 'export const describe=()=>{};export const it=()=>{};export
|
|
|
60
60
|
'export default {};';
|
|
61
61
|
export async function createLoader() {
|
|
62
62
|
const dir = mkdtempSync(join(tmpdir(), 'attest-loader-'));
|
|
63
|
-
|
|
64
|
-
|
|
65
|
-
|
|
66
|
-
|
|
67
|
-
|
|
68
|
-
|
|
69
|
-
|
|
70
|
-
|
|
71
|
-
|
|
72
|
-
|
|
73
|
-
|
|
74
|
-
|
|
75
|
-
|
|
76
|
-
|
|
77
|
-
|
|
78
|
-
|
|
79
|
-
|
|
80
|
-
|
|
81
|
-
|
|
82
|
-
|
|
83
|
-
|
|
84
|
-
|
|
85
|
-
|
|
86
|
-
|
|
87
|
-
|
|
88
|
-
|
|
89
|
-
|
|
90
|
-
|
|
91
|
-
|
|
92
|
-
|
|
93
|
-
|
|
94
|
-
|
|
63
|
+
// Everything between creating the directory and returning its owner runs
|
|
64
|
+
// inside this `try`, because until the caller holds the object below, nothing
|
|
65
|
+
// else can call `close` — so a throw here is the one path where the directory
|
|
66
|
+
// outlives the only code that knows about it. That is the same leak the
|
|
67
|
+
// comment on `close` records as already fixed, surviving on the other side of
|
|
68
|
+
// the same function.
|
|
69
|
+
//
|
|
70
|
+
// Stated over the window rather than over `createServer`, which is where it
|
|
71
|
+
// was found: `writeFileSync` is inside it too, and a disk that is full or a
|
|
72
|
+
// temp directory that turns read-only between the two calls reaches it without
|
|
73
|
+
// any project being involved.
|
|
74
|
+
let server;
|
|
75
|
+
try {
|
|
76
|
+
const stub = join(dir, 'vitest-stub.mjs');
|
|
77
|
+
writeFileSync(stub, VITEST_STUB);
|
|
78
|
+
server = await createServer({
|
|
79
|
+
configFile: false,
|
|
80
|
+
logLevel: 'error',
|
|
81
|
+
// Not just quiet — sanitised. See `sanitisedLogger`: what survives
|
|
82
|
+
// `logLevel: 'error'` is exactly the message that carries the checked
|
|
83
|
+
// project's own text.
|
|
84
|
+
customLogger: sanitisedLogger(),
|
|
85
|
+
appType: 'custom',
|
|
86
|
+
// `ws: false` is load-bearing, and `middlewareMode` is not enough on its
|
|
87
|
+
// own: it suppresses the HTTP server but Vite still starts the HMR
|
|
88
|
+
// WebSocket server, which binds `::` — every interface, not loopback — on
|
|
89
|
+
// port 24678. Every `attest` command that reads a registry by evaluating
|
|
90
|
+
// it therefore opened a network port for the length of the run, on a
|
|
91
|
+
// developer's machine and on every CI runner, for a channel that does
|
|
92
|
+
// nothing here: nothing subscribes to HMR, because nothing is watching.
|
|
93
|
+
//
|
|
94
|
+
// The visible symptom was the collision. Two attest processes at once —
|
|
95
|
+
// ordinary in a CI matrix, and what this repo's own concurrent specs do —
|
|
96
|
+
// and the second printed `WebSocket server error: Port is already in use`
|
|
97
|
+
// into the middle of a report, through a `logLevel: 'error'` that was
|
|
98
|
+
// supposed to have silenced the loader entirely.
|
|
99
|
+
//
|
|
100
|
+
// `hmr: false` does *not* close it (measured, Vite 5.4): the ws server is
|
|
101
|
+
// created before the hmr option is consulted. `ws: false` is the one that
|
|
102
|
+
// leaves no listening handle at all.
|
|
103
|
+
server: { middlewareMode: true, ws: false },
|
|
104
|
+
resolve: { alias: { vitest: stub } },
|
|
105
|
+
ssr: { noExternal: ['vitest'] },
|
|
106
|
+
optimizeDeps: { noDiscovery: true },
|
|
107
|
+
});
|
|
108
|
+
}
|
|
109
|
+
catch (err) {
|
|
110
|
+
// The original error is what the caller has to see, so a cleanup that fails
|
|
111
|
+
// must not replace it. `force` already forgives a directory that is not
|
|
112
|
+
// there; this forgives one that will not go.
|
|
113
|
+
await rm(dir, { recursive: true, force: true }).catch(() => { });
|
|
114
|
+
throw err;
|
|
115
|
+
}
|
|
95
116
|
let closed = false;
|
|
96
117
|
return {
|
|
97
118
|
scratchDir: dir,
|
|
@@ -111,7 +132,16 @@ export async function createLoader() {
|
|
|
111
132
|
await server.close();
|
|
112
133
|
}
|
|
113
134
|
finally {
|
|
114
|
-
|
|
135
|
+
// Swallowed for the same reason as the `rm` on the failing path above,
|
|
136
|
+
// and it counts for more here: every caller closes from a `finally`, so
|
|
137
|
+
// a throw from this line replaces whatever the block was doing — the
|
|
138
|
+
// real error on the way out, or a *successful* return turned into a
|
|
139
|
+
// crash about a temp directory. What that reader would then be handed
|
|
140
|
+
// is a misdiagnosis pointing at their own correct files, which this
|
|
141
|
+
// repository already treats as worse than the gap it fills.
|
|
142
|
+
// Given up with it: the one signal a failed cleanup could have raised,
|
|
143
|
+
// which no caller could have acted on anyway.
|
|
144
|
+
await rm(dir, { recursive: true, force: true }).catch(() => { });
|
|
115
145
|
}
|
|
116
146
|
},
|
|
117
147
|
};
|
package/dist/core/locate.d.ts
CHANGED
|
@@ -152,9 +152,10 @@ export declare function idPrefix(id: string): string;
|
|
|
152
152
|
* development platform could produce (see CHANGELOG.md, `Under consideration`).
|
|
153
153
|
* The memory here needed no such evidence — it is arithmetic, and portable.
|
|
154
154
|
*/
|
|
155
|
-
export declare function parseSpecs(files: string[], displayRoot: string): Promise<
|
|
156
|
-
|
|
157
|
-
|
|
155
|
+
export declare function parseSpecs(files: string[], displayRoot: string): Promise<{
|
|
156
|
+
plan: AttestPlan;
|
|
157
|
+
issues: Issue[];
|
|
158
|
+
}>;
|
|
158
159
|
/**
|
|
159
160
|
* Spec-shaped files sitting under `root/changes` — the location a change's
|
|
160
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 = [];
|
|
@@ -309,12 +346,24 @@ export async function parseSpecs(files, displayRoot) {
|
|
|
309
346
|
// Indexed rather than appended, so the merge below follows the input order
|
|
310
347
|
// whatever order the reads finish in.
|
|
311
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);
|
|
312
352
|
let next = 0;
|
|
313
353
|
const worker = async () => {
|
|
314
354
|
for (let i = next++; i < files.length; i = next++) {
|
|
315
355
|
const file = files[i];
|
|
316
|
-
const
|
|
317
|
-
|
|
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
|
+
}
|
|
318
367
|
}
|
|
319
368
|
};
|
|
320
369
|
await Promise.all(Array.from({ length: Math.min(PARSE_CONCURRENCY, files.length) }, worker));
|
|
@@ -323,11 +372,7 @@ export async function parseSpecs(files, displayRoot) {
|
|
|
323
372
|
plan.scenarios.push(...one.scenarios);
|
|
324
373
|
plan.paramRefs.push(...one.paramRefs);
|
|
325
374
|
}
|
|
326
|
-
return plan;
|
|
327
|
-
}
|
|
328
|
-
/** Parse every `*.spec.ts` under root into one merged plan (file paths shown relative to root). */
|
|
329
|
-
export async function parseAllSpecFiles(root) {
|
|
330
|
-
return parseSpecs(await findFiles(root, isSpecFile), root);
|
|
375
|
+
return { plan, issues: failures.filter((i) => i !== undefined) };
|
|
331
376
|
}
|
|
332
377
|
/**
|
|
333
378
|
* Spec-shaped files sitting under `root/changes` — the location a change's
|
package/dist/core/paths.d.ts
CHANGED
|
@@ -12,4 +12,20 @@
|
|
|
12
12
|
export declare function toPosixPath(path: string, separator?: string): string;
|
|
13
13
|
/** `path.relative`, in the one spelling the rest of the engine expects. */
|
|
14
14
|
export declare function relativePath(from: string, to: string): string;
|
|
15
|
+
/**
|
|
16
|
+
* Whether `path` is `root` or sits below it, both given as resolved paths.
|
|
17
|
+
*
|
|
18
|
+
* Asked through `relativePath` rather than by comparing prefixes, because the
|
|
19
|
+
* two ways this goes wrong are both invisible in a `startsWith`. A sibling
|
|
20
|
+
* directory shares the prefix — `/repo-backup` starts with `/repo` — and on
|
|
21
|
+
* Windows a path on another drive has *no* relative spelling at all, so
|
|
22
|
+
* `path.relative` answers with an absolute one rather than a chain of `..`.
|
|
23
|
+
* Testing the relative form catches both: an escape is either `..`-led or
|
|
24
|
+
* absolute, and nothing else is.
|
|
25
|
+
*
|
|
26
|
+
* The caller owes the resolution. Nothing here follows a symbolic link, so a
|
|
27
|
+
* path that is lexically inside can still be physically outside — that is the
|
|
28
|
+
* question `join` cannot answer and this function does not pretend to.
|
|
29
|
+
*/
|
|
30
|
+
export declare function isInside(root: string, path: string): boolean;
|
|
15
31
|
//# sourceMappingURL=paths.d.ts.map
|
package/dist/core/paths.js
CHANGED
|
@@ -14,7 +14,7 @@
|
|
|
14
14
|
// Normalising here rather than at the glob has a second dividend: a report is
|
|
15
15
|
// then byte-identical across platforms, so a `--json` consumer diffing two CI
|
|
16
16
|
// runs is not reading the runner's operating system.
|
|
17
|
-
import { relative, sep } from 'node:path';
|
|
17
|
+
import { isAbsolute, relative, sep } from 'node:path';
|
|
18
18
|
/**
|
|
19
19
|
* A native path as a POSIX one.
|
|
20
20
|
*
|
|
@@ -33,4 +33,23 @@ export function toPosixPath(path, separator = sep) {
|
|
|
33
33
|
export function relativePath(from, to) {
|
|
34
34
|
return toPosixPath(relative(from, to));
|
|
35
35
|
}
|
|
36
|
+
/**
|
|
37
|
+
* Whether `path` is `root` or sits below it, both given as resolved paths.
|
|
38
|
+
*
|
|
39
|
+
* Asked through `relativePath` rather than by comparing prefixes, because the
|
|
40
|
+
* two ways this goes wrong are both invisible in a `startsWith`. A sibling
|
|
41
|
+
* directory shares the prefix — `/repo-backup` starts with `/repo` — and on
|
|
42
|
+
* Windows a path on another drive has *no* relative spelling at all, so
|
|
43
|
+
* `path.relative` answers with an absolute one rather than a chain of `..`.
|
|
44
|
+
* Testing the relative form catches both: an escape is either `..`-led or
|
|
45
|
+
* absolute, and nothing else is.
|
|
46
|
+
*
|
|
47
|
+
* The caller owes the resolution. Nothing here follows a symbolic link, so a
|
|
48
|
+
* path that is lexically inside can still be physically outside — that is the
|
|
49
|
+
* question `join` cannot answer and this function does not pretend to.
|
|
50
|
+
*/
|
|
51
|
+
export function isInside(root, path) {
|
|
52
|
+
const rel = relativePath(root, path);
|
|
53
|
+
return rel !== '..' && !rel.startsWith('../') && !isAbsolute(rel);
|
|
54
|
+
}
|
|
36
55
|
//# sourceMappingURL=paths.js.map
|