@am_shork/attest 0.7.4 → 0.9.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CHANGELOG.md +1449 -157
- package/README.md +3 -2
- package/dist/cli/index.js +11 -8
- package/dist/cli/json.d.ts +26 -1
- package/dist/cli/json.js +28 -2
- package/dist/cli/report.js +9 -1
- package/dist/core/apply.d.ts +18 -1
- package/dist/core/apply.js +19 -2
- package/dist/core/gate.js +3 -3
- package/dist/core/locate.d.ts +19 -4
- package/dist/core/locate.js +90 -40
- package/dist/core/merge.js +218 -72
- package/dist/core/pipeline.d.ts +17 -1
- package/dist/core/pipeline.js +149 -35
- package/dist/core/red-record.d.ts +14 -5
- package/dist/core/red-record.js +82 -24
- package/dist/core/registry-issues.d.ts +30 -0
- package/dist/core/registry-issues.js +26 -0
- package/dist/core/registry.d.ts +12 -5
- package/dist/core/registry.js +10 -8
- package/dist/core/runner.js +21 -9
- package/dist/core/schema.d.ts +32 -27
- package/dist/core/schema.js +33 -5
- package/dist/core/skill.js +101 -27
- package/dist/core/splice.d.ts +62 -3
- package/dist/core/splice.js +297 -27
- package/dist/core/static-registry.d.ts +42 -0
- package/dist/core/static-registry.js +136 -21
- package/dist/core/status.js +2 -2
- package/dist/core/terminal.js +5 -2
- package/dist/core/types.d.ts +39 -10
- package/dist/core/validator.d.ts +1 -0
- package/dist/core/validator.js +22 -0
- package/package.json +2 -2
package/README.md
CHANGED
|
@@ -125,7 +125,8 @@ attest verify # run tests + coverage + drift, graded report
|
|
|
125
125
|
attest cover # which requirements lack a scenario
|
|
126
126
|
attest render # the requirements as Markdown, for people who don't read TS
|
|
127
127
|
attest archive <change> # gate a proposed change: green + covered + no drift
|
|
128
|
-
attest status <change> #
|
|
128
|
+
attest status <change> # per added id: scenario written? seen red? (part of that
|
|
129
|
+
# gate, without running anything — never a verdict)
|
|
129
130
|
```
|
|
130
131
|
|
|
131
132
|
Every command takes the project root as an optional last argument, and `--json`
|
|
@@ -166,7 +167,7 @@ Every diagnostic carries a `code`, and every code has a section in
|
|
|
166
167
|
```
|
|
167
168
|
ERROR registry-not-static (requirements/upload.reqs.ts:5)
|
|
168
169
|
Value is not a literal.
|
|
169
|
-
→ https://gitlab.com/Pseudorca/attest/-/blob/v0.
|
|
170
|
+
→ https://gitlab.com/Pseudorca/attest/-/blob/v0.9.0/docs/en/troubleshooting.md#registry-not-static
|
|
170
171
|
```
|
|
171
172
|
|
|
172
173
|
The anchor **is** the code, so the link cannot point somewhere the section
|
package/dist/cli/index.js
CHANGED
|
@@ -13,7 +13,6 @@
|
|
|
13
13
|
import { Command } from 'commander';
|
|
14
14
|
import chalk from 'chalk';
|
|
15
15
|
import { resolve } from 'node:path';
|
|
16
|
-
import { writeAtomic } from '../core/write.js';
|
|
17
16
|
import { runCheck, runVerify, runCover, runArchive, runArchiveApply, runInit, runRender, runRenderCheck, runStatus, DEFAULT_TARGET, TARGET_NAMES, } from '../core/pipeline.js';
|
|
18
17
|
import { hasError } from '../core/types.js';
|
|
19
18
|
// The one project-derived value this shell still interpolates itself. `--out`
|
|
@@ -135,12 +134,16 @@ program
|
|
|
135
134
|
if (opts.json && !opts.out && !opts.check) {
|
|
136
135
|
cmd.error('error: --json requires --out <file> (stdout cannot carry both the document and the report)');
|
|
137
136
|
}
|
|
138
|
-
|
|
137
|
+
// Both spellings of the destination, because both are needed downstream: the
|
|
138
|
+
// resolved one is what the filesystem is given, the typed one is what a
|
|
139
|
+
// diagnostic echoes. Where it may point, and the write itself, are the
|
|
140
|
+
// pipeline's — this shell decides what to print and nothing else.
|
|
141
|
+
const out = opts.out ? { file: resolve(process.cwd(), opts.out), display: opts.out } : undefined;
|
|
139
142
|
return runAction('render', opts, async () => {
|
|
140
143
|
if (opts.check) {
|
|
141
144
|
// The fix hint has to be the command the user can actually re-run from
|
|
142
145
|
// here: their own --out string, and the [dir] they passed.
|
|
143
|
-
const issues = await runRenderCheck(root(dir),
|
|
146
|
+
const issues = await runRenderCheck(root(dir), out.file, {
|
|
144
147
|
display: opts.out,
|
|
145
148
|
command: `attest render${dir ? ` ${dir}` : ''} --out ${opts.out}`,
|
|
146
149
|
}, { evaluate: opts.eval });
|
|
@@ -156,10 +159,10 @@ program
|
|
|
156
159
|
},
|
|
157
160
|
};
|
|
158
161
|
}
|
|
159
|
-
const { markdown, issues } = await runRender(root(dir), {
|
|
160
|
-
|
|
161
|
-
|
|
162
|
-
|
|
162
|
+
const { markdown, issues, wrote } = await runRender(root(dir), {
|
|
163
|
+
evaluate: opts.eval,
|
|
164
|
+
out,
|
|
165
|
+
});
|
|
163
166
|
return {
|
|
164
167
|
report: renderReport(VERSION, issues, opts.out),
|
|
165
168
|
human: () => {
|
|
@@ -167,7 +170,7 @@ program
|
|
|
167
170
|
console.log(formatIssues(issues));
|
|
168
171
|
console.log(summarize(issues));
|
|
169
172
|
}
|
|
170
|
-
else if (
|
|
173
|
+
else if (wrote) {
|
|
171
174
|
console.log(chalk.green(`✓ Wrote ${inline(opts.out ?? '')}`));
|
|
172
175
|
}
|
|
173
176
|
else {
|
package/dist/cli/json.d.ts
CHANGED
|
@@ -100,6 +100,31 @@ export declare function statusReport(version: string, result: StatusResult): Jso
|
|
|
100
100
|
* empty stdout and a stack trace on stderr.
|
|
101
101
|
*/
|
|
102
102
|
export declare function errorReport(version: string, command: JsonCommand, err: unknown): JsonReport;
|
|
103
|
-
/**
|
|
103
|
+
/**
|
|
104
|
+
* Serialize a report for stdout. Pretty-printed: still one parseable document.
|
|
105
|
+
*
|
|
106
|
+
* Every string in it loses its control characters first (design §9.1). The
|
|
107
|
+
* machine surface was recorded as exempt because `JSON.stringify` escapes every
|
|
108
|
+
* C0 character — true, and narrower than the rule: §9.1's class also holds DEL
|
|
109
|
+
* and the C1 range, and `JSON.stringify` emits those verbatim. `U+009B` is
|
|
110
|
+
* 8-bit CSI, the same introducer as `ESC [` on a terminal that reads
|
|
111
|
+
* UTF-8-decoded C1 as control — so a scenario name or a thrown message can
|
|
112
|
+
* drive the terminal of someone reading the JSON rather than parsing it, from
|
|
113
|
+
* `check --json`, which executes nothing.
|
|
114
|
+
*
|
|
115
|
+
* Applied here rather than at the report builders, and that is §9.1's own
|
|
116
|
+
* distinction rather than a preference: this is a *document*, not a stream. It
|
|
117
|
+
* has one point of assembly, before any of it is seen, so an obligation sitting
|
|
118
|
+
* there covers a field nobody has added yet — as `outFiles` and `docsUrl` both
|
|
119
|
+
* were. The terminal path is sanitised at each write site for the opposite
|
|
120
|
+
* reason, recorded in core/terminal.ts.
|
|
121
|
+
*
|
|
122
|
+
* A replacer rather than a walk over the fields that carry project text —
|
|
123
|
+
* `issues[].message`, `issues[].file`, `change`, `outFile`, `outFiles` today —
|
|
124
|
+
* because enumerating them is the arrangement that leaves the next one out. It
|
|
125
|
+
* costs nothing on the fields that cannot carry a control character: `control`
|
|
126
|
+
* allocates when something matches, and nothing does. Non-mutating, because a
|
|
127
|
+
* caller may render the same report for a human afterwards.
|
|
128
|
+
*/
|
|
104
129
|
export declare function renderJson(report: JsonReport): string;
|
|
105
130
|
//# sourceMappingURL=json.d.ts.map
|
package/dist/cli/json.js
CHANGED
|
@@ -6,6 +6,7 @@
|
|
|
6
6
|
// and every command emits the same envelope (tool/version/command/ok/summary/
|
|
7
7
|
// issues) plus command-specific fields.
|
|
8
8
|
import { docsUrl } from '../core/docs.js';
|
|
9
|
+
import { control } from '../core/terminal.js';
|
|
9
10
|
import { hasError } from '../core/types.js';
|
|
10
11
|
/** Bumped whenever the emitted shape changes incompatibly. */
|
|
11
12
|
export const SCHEMA_VERSION = 1;
|
|
@@ -156,8 +157,33 @@ export function errorReport(version, command, err) {
|
|
|
156
157
|
const issue = { level: 'ERROR', code: 'internal-error', message };
|
|
157
158
|
return envelope({ version, command, ok: false, issues: [issue] });
|
|
158
159
|
}
|
|
159
|
-
/**
|
|
160
|
+
/**
|
|
161
|
+
* Serialize a report for stdout. Pretty-printed: still one parseable document.
|
|
162
|
+
*
|
|
163
|
+
* Every string in it loses its control characters first (design §9.1). The
|
|
164
|
+
* machine surface was recorded as exempt because `JSON.stringify` escapes every
|
|
165
|
+
* C0 character — true, and narrower than the rule: §9.1's class also holds DEL
|
|
166
|
+
* and the C1 range, and `JSON.stringify` emits those verbatim. `U+009B` is
|
|
167
|
+
* 8-bit CSI, the same introducer as `ESC [` on a terminal that reads
|
|
168
|
+
* UTF-8-decoded C1 as control — so a scenario name or a thrown message can
|
|
169
|
+
* drive the terminal of someone reading the JSON rather than parsing it, from
|
|
170
|
+
* `check --json`, which executes nothing.
|
|
171
|
+
*
|
|
172
|
+
* Applied here rather than at the report builders, and that is §9.1's own
|
|
173
|
+
* distinction rather than a preference: this is a *document*, not a stream. It
|
|
174
|
+
* has one point of assembly, before any of it is seen, so an obligation sitting
|
|
175
|
+
* there covers a field nobody has added yet — as `outFiles` and `docsUrl` both
|
|
176
|
+
* were. The terminal path is sanitised at each write site for the opposite
|
|
177
|
+
* reason, recorded in core/terminal.ts.
|
|
178
|
+
*
|
|
179
|
+
* A replacer rather than a walk over the fields that carry project text —
|
|
180
|
+
* `issues[].message`, `issues[].file`, `change`, `outFile`, `outFiles` today —
|
|
181
|
+
* because enumerating them is the arrangement that leaves the next one out. It
|
|
182
|
+
* costs nothing on the fields that cannot carry a control character: `control`
|
|
183
|
+
* allocates when something matches, and nothing does. Non-mutating, because a
|
|
184
|
+
* caller may render the same report for a human afterwards.
|
|
185
|
+
*/
|
|
160
186
|
export function renderJson(report) {
|
|
161
|
-
return JSON.stringify(report,
|
|
187
|
+
return JSON.stringify(report, (_key, value) => (typeof value === 'string' ? control(value) : value), 2);
|
|
162
188
|
}
|
|
163
189
|
//# sourceMappingURL=json.js.map
|
package/dist/cli/report.js
CHANGED
|
@@ -190,7 +190,15 @@ export function formatStatus(result) {
|
|
|
190
190
|
}
|
|
191
191
|
const { proven } = result.counts;
|
|
192
192
|
lines.push(chalk.dim(`— ${plural(result.rows.length, 'added requirement')}, ${proven} ready to archive`));
|
|
193
|
-
|
|
193
|
+
// Two pointers, because this command answers less than a reader assumes on
|
|
194
|
+
// both sides. `archive` is the verdict it deliberately cannot give. `check` is
|
|
195
|
+
// the half it deliberately does not duplicate: a proposed spec no delta
|
|
196
|
+
// claims, or one whose merged name is taken, is a static fact about this
|
|
197
|
+
// change that `check` already reports — and reporting it here too would put a
|
|
198
|
+
// second answer to one question in the tree, which is what this project takes
|
|
199
|
+
// apart everywhere else. Naming it is the whole cost of not absorbing it.
|
|
200
|
+
lines.push(chalk.dim(`Not a verdict: run \`attest archive ${inline(result.change)}\` to run the suite, ` +
|
|
201
|
+
`\`attest check\` for the proposed specs themselves.`));
|
|
194
202
|
return lines.join('\n');
|
|
195
203
|
}
|
|
196
204
|
/**
|
package/dist/core/apply.d.ts
CHANGED
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
import type { RegistryDelta } from './registry.js';
|
|
2
|
-
import type { Issue, Registry } from './types.js';
|
|
2
|
+
import type { Issue, Registry, Requirement } from './types.js';
|
|
3
3
|
export interface ApplyResult {
|
|
4
4
|
registry: Registry;
|
|
5
5
|
issues: Issue[];
|
|
@@ -12,6 +12,14 @@ export interface ApplyResult {
|
|
|
12
12
|
* than off `delta.added`, so the two can never be scoped differently.
|
|
13
13
|
*/
|
|
14
14
|
export declare function addedIds(d: RegistryDelta): string[];
|
|
15
|
+
/**
|
|
16
|
+
* The ids a delta MODIFIEs — the scope of `--apply`'s field-level write-back.
|
|
17
|
+
*
|
|
18
|
+
* Here for the reason `addedIds` is: two places ask, the merge that writes them
|
|
19
|
+
* and the refusal that checks their prefix is owned, and a delta's operations
|
|
20
|
+
* must not be enumerated twice.
|
|
21
|
+
*/
|
|
22
|
+
export declare function modifiedIds(d: RegistryDelta): string[];
|
|
15
23
|
/**
|
|
16
24
|
* The ids a delta **claims**: what it adds, renames to, or modifies.
|
|
17
25
|
*
|
|
@@ -33,4 +41,13 @@ export declare function claimedIds(d: RegistryDelta): string[];
|
|
|
33
41
|
* base registry is never mutated. Applying the same delta twice is a no-op.
|
|
34
42
|
*/
|
|
35
43
|
export declare function applyDelta(base: Registry, d: RegistryDelta): ApplyResult;
|
|
44
|
+
/**
|
|
45
|
+
* Content equality via canonical JSON (params key order does not matter).
|
|
46
|
+
*
|
|
47
|
+
* Exported because `--apply` asks the same question twice — whether a modified
|
|
48
|
+
* entry is already what the end state says, and whether the bytes it wrote read
|
|
49
|
+
* back as it — and a second spelling of "the same requirement" is a second
|
|
50
|
+
* answer the moment `Requirement` gains a field.
|
|
51
|
+
*/
|
|
52
|
+
export declare function sameRequirement(a: Requirement, b: Requirement): boolean;
|
|
36
53
|
//# sourceMappingURL=apply.d.ts.map
|
package/dist/core/apply.js
CHANGED
|
@@ -14,6 +14,16 @@ import { byCodeUnit, sortDeep } from './order.js';
|
|
|
14
14
|
export function addedIds(d) {
|
|
15
15
|
return Object.keys(d.added ?? {});
|
|
16
16
|
}
|
|
17
|
+
/**
|
|
18
|
+
* The ids a delta MODIFIEs — the scope of `--apply`'s field-level write-back.
|
|
19
|
+
*
|
|
20
|
+
* Here for the reason `addedIds` is: two places ask, the merge that writes them
|
|
21
|
+
* and the refusal that checks their prefix is owned, and a delta's operations
|
|
22
|
+
* must not be enumerated twice.
|
|
23
|
+
*/
|
|
24
|
+
export function modifiedIds(d) {
|
|
25
|
+
return Object.keys(d.modified ?? {}).sort(byCodeUnit);
|
|
26
|
+
}
|
|
17
27
|
/**
|
|
18
28
|
* The ids a delta **claims**: what it adds, renames to, or modifies.
|
|
19
29
|
*
|
|
@@ -177,8 +187,15 @@ function firstMessage(error) {
|
|
|
177
187
|
const path = first.path.map(String).join('.');
|
|
178
188
|
return path ? `${path}: ${first.message}` : first.message;
|
|
179
189
|
}
|
|
180
|
-
/**
|
|
181
|
-
|
|
190
|
+
/**
|
|
191
|
+
* Content equality via canonical JSON (params key order does not matter).
|
|
192
|
+
*
|
|
193
|
+
* Exported because `--apply` asks the same question twice — whether a modified
|
|
194
|
+
* entry is already what the end state says, and whether the bytes it wrote read
|
|
195
|
+
* back as it — and a second spelling of "the same requirement" is a second
|
|
196
|
+
* answer the moment `Requirement` gains a field.
|
|
197
|
+
*/
|
|
198
|
+
export function sameRequirement(a, b) {
|
|
182
199
|
return canonical(a) === canonical(b);
|
|
183
200
|
}
|
|
184
201
|
function canonical(req) {
|
package/dist/core/gate.js
CHANGED
|
@@ -16,7 +16,7 @@ import { RED_RECORD_FILE, hasRecordedRed, recordedOutcome, } from './red-record.
|
|
|
16
16
|
export function declaredNotRunIssues(plan, run) {
|
|
17
17
|
const issues = [];
|
|
18
18
|
for (const s of plan.scenarios) {
|
|
19
|
-
if (!run.runtimeCoverage.get(s.reqId)?.has(s.name)) {
|
|
19
|
+
if (!run.runtimeCoverage.get(s.reqId)?.get(s.file)?.has(s.name)) {
|
|
20
20
|
issues.push({
|
|
21
21
|
level: 'ERROR',
|
|
22
22
|
code: 'declared-not-run',
|
|
@@ -155,9 +155,9 @@ export function neverRedIssues(plan, addedIds, firstRun) {
|
|
|
155
155
|
for (const s of plan.scenarios) {
|
|
156
156
|
if (!added.has(s.reqId))
|
|
157
157
|
continue;
|
|
158
|
-
if (hasRecordedRed(firstRun, s
|
|
158
|
+
if (hasRecordedRed(firstRun, s))
|
|
159
159
|
continue;
|
|
160
|
-
const outcome = recordedOutcome(firstRun, s
|
|
160
|
+
const outcome = recordedOutcome(firstRun, s);
|
|
161
161
|
issues.push({
|
|
162
162
|
level: 'ERROR',
|
|
163
163
|
code: 'never-red',
|
package/dist/core/locate.d.ts
CHANGED
|
@@ -27,6 +27,20 @@ export declare const isSpecFile: (name: string) => boolean;
|
|
|
27
27
|
* serial `for await` spent one filesystem round-trip per directory, which is
|
|
28
28
|
* the dominant cost of `check` on a large repo. The result is sorted, so the
|
|
29
29
|
* order never depends on which `readdir` happened to resolve first.
|
|
30
|
+
*
|
|
31
|
+
* Concurrency is bounded, for the reason `parseSpecs` bounds its own: the input
|
|
32
|
+
* size is not ours to choose, `check` being what this project tells people to
|
|
33
|
+
* run first on an untrusted fork MR. What must not come back is a fan-out whose
|
|
34
|
+
* peak is the shape of the tree rather than a constant — which is what
|
|
35
|
+
* recursing through `Promise.all(subdirs)` gave, and what
|
|
36
|
+
* `tests/locate-fanout.spec.ts` pins.
|
|
37
|
+
*
|
|
38
|
+
* A level at a time, rather than one pool over a queue that grows as directories
|
|
39
|
+
* are discovered: the pool would have to keep workers alive while the queue is
|
|
40
|
+
* momentarily empty but another worker may still push to it, and that
|
|
41
|
+
* termination condition is the part worth not owning. The cost is a barrier per
|
|
42
|
+
* depth, which is paid in tree *depth* — small, and bounded by the filesystem —
|
|
43
|
+
* while the fan-out being bounded is paid in tree *width*, which is not.
|
|
30
44
|
*/
|
|
31
45
|
export declare function findFiles(root: string, match: (name: string) => boolean): Promise<string[]>;
|
|
32
46
|
/** The file sets every command needs, collected in one pass. */
|
|
@@ -167,10 +181,11 @@ export declare function idPrefix(id: string): string;
|
|
|
167
181
|
* avoid: input size is not ours to choose here, `check` being what this project
|
|
168
182
|
* tells people to run first on an untrusted fork MR. Measured in `[0.7.0]`.
|
|
169
183
|
*
|
|
170
|
-
* `findFiles` above
|
|
171
|
-
*
|
|
172
|
-
*
|
|
173
|
-
*
|
|
184
|
+
* `findFiles` above bounds its own fan-out the same way and through the same
|
|
185
|
+
* helper. It was left unbounded when this one was capped, on the grounds that
|
|
186
|
+
* the failure it invited — descriptor exhaustion — could not be produced on
|
|
187
|
+
* either development platform; what closed it is that the peak itself is
|
|
188
|
+
* portable arithmetic, which is the standard this half was accepted on.
|
|
174
189
|
*/
|
|
175
190
|
export declare function parseSpecs(files: string[], displayRoot: string): Promise<{
|
|
176
191
|
plan: AttestPlan;
|
package/dist/core/locate.js
CHANGED
|
@@ -5,7 +5,7 @@ import { basename, join } from 'node:path';
|
|
|
5
5
|
import { relativePath } from './paths.js';
|
|
6
6
|
import { parseSpecFile } from './parser.js';
|
|
7
7
|
import { declaredIdsFromSource, readRegistrySource } from './static-registry.js';
|
|
8
|
-
import {
|
|
8
|
+
import { registryValidationMessage } from './registry-issues.js';
|
|
9
9
|
import { RegistrySchema } from './schema.js';
|
|
10
10
|
import { byCodeUnit } from './order.js';
|
|
11
11
|
import { SourceNotCompiled } from './compiler.js';
|
|
@@ -40,6 +40,44 @@ export const isProposedSpecFile = (name) => name.endsWith('.proposed.spec.ts');
|
|
|
40
40
|
* of them until its gate passes.
|
|
41
41
|
*/
|
|
42
42
|
export const isSpecFile = (name) => name.endsWith('.spec.ts') && !isProposedSpecFile(name);
|
|
43
|
+
/**
|
|
44
|
+
* Run `fn` over every item with at most `limit` of them in flight.
|
|
45
|
+
*
|
|
46
|
+
* Extracted at the second call site rather than the first: this file has two
|
|
47
|
+
* unbounded fan-outs to close, one over directories and one over files, and
|
|
48
|
+
* a shape written twice is a shape one of the two copies will eventually be
|
|
49
|
+
* fixed without.
|
|
50
|
+
*
|
|
51
|
+
* The order `fn` is *called* in is the input order; the order it *completes* in
|
|
52
|
+
* is not, so a caller that needs a stable result either indexes into a
|
|
53
|
+
* preallocated array by `index` or sorts afterwards. Both callers here do one of
|
|
54
|
+
* those, deliberately.
|
|
55
|
+
*
|
|
56
|
+
* No result is collected and none is needed — both callers write into something
|
|
57
|
+
* they already own, and a version returning `T[]` would have to choose an
|
|
58
|
+
* ordering on their behalf. A throw from `fn` propagates and abandons the rest,
|
|
59
|
+
* which is the existing behaviour at both sites: `parseSpecs` catches per file
|
|
60
|
+
* so that one hostile spec scraps only itself (ATX-65), and a failed `readdir`
|
|
61
|
+
* really does end the walk.
|
|
62
|
+
*/
|
|
63
|
+
async function forEachBounded(items, limit, fn) {
|
|
64
|
+
let cursor = 0;
|
|
65
|
+
const worker = async () => {
|
|
66
|
+
for (let i = cursor++; i < items.length; i = cursor++) {
|
|
67
|
+
await fn(items[i], i);
|
|
68
|
+
}
|
|
69
|
+
};
|
|
70
|
+
await Promise.all(Array.from({ length: Math.min(limit, items.length) }, worker));
|
|
71
|
+
}
|
|
72
|
+
/**
|
|
73
|
+
* How many directories are open at once during the walk. The same figure as
|
|
74
|
+
* `PARSE_CONCURRENCY` and deliberately not the same constant: the two bound
|
|
75
|
+
* different resources, and sharing one would make either impossible to tune
|
|
76
|
+
* without moving the other. High enough that the walk stays I/O bound on any
|
|
77
|
+
* real project, low enough that what is in flight is a constant rather than the
|
|
78
|
+
* shape of the input.
|
|
79
|
+
*/
|
|
80
|
+
const WALK_CONCURRENCY = 32;
|
|
43
81
|
/**
|
|
44
82
|
* Recursively find files under root whose basename matches `match`.
|
|
45
83
|
*
|
|
@@ -47,25 +85,41 @@ export const isSpecFile = (name) => name.endsWith('.spec.ts') && !isProposedSpec
|
|
|
47
85
|
* serial `for await` spent one filesystem round-trip per directory, which is
|
|
48
86
|
* the dominant cost of `check` on a large repo. The result is sorted, so the
|
|
49
87
|
* order never depends on which `readdir` happened to resolve first.
|
|
88
|
+
*
|
|
89
|
+
* Concurrency is bounded, for the reason `parseSpecs` bounds its own: the input
|
|
90
|
+
* size is not ours to choose, `check` being what this project tells people to
|
|
91
|
+
* run first on an untrusted fork MR. What must not come back is a fan-out whose
|
|
92
|
+
* peak is the shape of the tree rather than a constant — which is what
|
|
93
|
+
* recursing through `Promise.all(subdirs)` gave, and what
|
|
94
|
+
* `tests/locate-fanout.spec.ts` pins.
|
|
95
|
+
*
|
|
96
|
+
* A level at a time, rather than one pool over a queue that grows as directories
|
|
97
|
+
* are discovered: the pool would have to keep workers alive while the queue is
|
|
98
|
+
* momentarily empty but another worker may still push to it, and that
|
|
99
|
+
* termination condition is the part worth not owning. The cost is a barrier per
|
|
100
|
+
* depth, which is paid in tree *depth* — small, and bounded by the filesystem —
|
|
101
|
+
* while the fan-out being bounded is paid in tree *width*, which is not.
|
|
50
102
|
*/
|
|
51
103
|
export async function findFiles(root, match) {
|
|
52
104
|
const out = [];
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
const
|
|
56
|
-
|
|
57
|
-
const
|
|
58
|
-
|
|
59
|
-
|
|
60
|
-
|
|
61
|
-
|
|
62
|
-
|
|
63
|
-
|
|
105
|
+
let level = [root];
|
|
106
|
+
while (level.length > 0) {
|
|
107
|
+
const next = [];
|
|
108
|
+
await forEachBounded(level, WALK_CONCURRENCY, async (dir) => {
|
|
109
|
+
const entries = await readdir(dir, { withFileTypes: true });
|
|
110
|
+
for (const e of entries) {
|
|
111
|
+
const full = join(dir, e.name);
|
|
112
|
+
if (e.isDirectory()) {
|
|
113
|
+
if (!SKIP_DIRS.has(e.name))
|
|
114
|
+
next.push(full);
|
|
115
|
+
}
|
|
116
|
+
else if (e.isFile() && match(e.name)) {
|
|
117
|
+
out.push(full);
|
|
118
|
+
}
|
|
64
119
|
}
|
|
65
|
-
}
|
|
66
|
-
|
|
67
|
-
}
|
|
68
|
-
await walk(root);
|
|
120
|
+
});
|
|
121
|
+
level = next;
|
|
122
|
+
}
|
|
69
123
|
return out.sort(byCodeUnit);
|
|
70
124
|
}
|
|
71
125
|
/**
|
|
@@ -191,13 +245,13 @@ export function evalReader(loader) {
|
|
|
191
245
|
const parsed = RegistrySchema.safeParse(exported);
|
|
192
246
|
if (!parsed.success) {
|
|
193
247
|
// The same code, the same prefix and the same rendering of the field
|
|
194
|
-
// errors as `readRegistrySource` —
|
|
195
|
-
//
|
|
248
|
+
// errors as `readRegistrySource` — sharing one formatter keeps the
|
|
249
|
+
// message identical rather than merely similar.
|
|
196
250
|
return {
|
|
197
251
|
issue: {
|
|
198
252
|
level: 'ERROR',
|
|
199
253
|
code: 'registry-invalid',
|
|
200
|
-
message: `Failed to load registry: ${
|
|
254
|
+
message: `Failed to load registry: ${registryValidationMessage(parsed.error.issues)}`,
|
|
201
255
|
},
|
|
202
256
|
};
|
|
203
257
|
}
|
|
@@ -362,10 +416,11 @@ const PARSE_CONCURRENCY = 32;
|
|
|
362
416
|
* avoid: input size is not ours to choose here, `check` being what this project
|
|
363
417
|
* tells people to run first on an untrusted fork MR. Measured in `[0.7.0]`.
|
|
364
418
|
*
|
|
365
|
-
* `findFiles` above
|
|
366
|
-
*
|
|
367
|
-
*
|
|
368
|
-
*
|
|
419
|
+
* `findFiles` above bounds its own fan-out the same way and through the same
|
|
420
|
+
* helper. It was left unbounded when this one was capped, on the grounds that
|
|
421
|
+
* the failure it invited — descriptor exhaustion — could not be produced on
|
|
422
|
+
* either development platform; what closed it is that the peak itself is
|
|
423
|
+
* portable arithmetic, which is the standard this half was accepted on.
|
|
369
424
|
*/
|
|
370
425
|
export async function parseSpecs(files, displayRoot) {
|
|
371
426
|
// Indexed rather than appended, so the merge below follows the input order
|
|
@@ -374,24 +429,19 @@ export async function parseSpecs(files, displayRoot) {
|
|
|
374
429
|
// Same index space, so a file contributes either a parse or an issue and the
|
|
375
430
|
// two lists cannot disagree about which file is which.
|
|
376
431
|
const failures = new Array(files.length);
|
|
377
|
-
|
|
378
|
-
|
|
379
|
-
|
|
380
|
-
|
|
381
|
-
|
|
382
|
-
|
|
383
|
-
|
|
384
|
-
// the command with it. A hostile spec scraps only itself (ATX-65).
|
|
385
|
-
try {
|
|
386
|
-
parsed[i] = parseSpecFile(display, await readFile(file, 'utf8'));
|
|
387
|
-
}
|
|
388
|
-
catch (err) {
|
|
389
|
-
parsed[i] = { scenarios: [], paramRefs: [] };
|
|
390
|
-
failures[i] = { ...unreadableIssue(err), file: display };
|
|
391
|
-
}
|
|
432
|
+
await forEachBounded(files, PARSE_CONCURRENCY, async (file, i) => {
|
|
433
|
+
const display = relativePath(displayRoot, file);
|
|
434
|
+
// Per file, for the reason `readGuarded` exists above: these run
|
|
435
|
+
// concurrently, so one throw would abandon the rest and take the command
|
|
436
|
+
// with it. A hostile spec scraps only itself (ATX-65).
|
|
437
|
+
try {
|
|
438
|
+
parsed[i] = parseSpecFile(display, await readFile(file, 'utf8'));
|
|
392
439
|
}
|
|
393
|
-
|
|
394
|
-
|
|
440
|
+
catch (err) {
|
|
441
|
+
parsed[i] = { scenarios: [], paramRefs: [] };
|
|
442
|
+
failures[i] = { ...unreadableIssue(err), file: display };
|
|
443
|
+
}
|
|
444
|
+
});
|
|
395
445
|
const plan = { scenarios: [], paramRefs: [] };
|
|
396
446
|
for (const one of parsed) {
|
|
397
447
|
plan.scenarios.push(...one.scenarios);
|