@specific.dev/spectest 0.86.2 → 0.87.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/dist/daemon.js +45 -15
- package/dist/harness/coverage.d.ts +9 -3
- package/dist/harness/coverage.js +21 -3
- package/dist/harness/reach.d.ts +29 -2
- package/dist/harness/reach.js +110 -8
- package/dist/harness/wrapper-rules.d.ts +13 -0
- package/dist/harness/wrapper-rules.js +37 -0
- package/package.json +1 -1
- package/src/daemon.ts +47 -15
- package/src/harness/coverage.ts +20 -3
- package/src/harness/reach.test.ts +67 -1
- package/src/harness/reach.ts +113 -8
- package/src/harness/wrapper-rules.test.ts +26 -0
- package/src/harness/wrapper-rules.ts +39 -0
package/dist/daemon.js
CHANGED
|
@@ -24,7 +24,7 @@ import path from "node:path";
|
|
|
24
24
|
import { pathToFileURL } from "node:url";
|
|
25
25
|
import { ENVIRONMENT_CA_PATH as CA_PATH } from "./environment-ca.js";
|
|
26
26
|
import { assert, expect, expectRaw, lowerIngress, dnsName as makeDnsDecl, isWildcard, validateServiceImage, proxy as makeProxyDecl, } from "./index.js";
|
|
27
|
-
import { COVERAGE_CONTAINER_DIR, coverageBundleRef, coverageHostDir, encodeCoverageBundle, isEmptyReport, readCoverageDir, applyCoverageDelta, walkProjectFiles, ENVIRONMENT_SERVICE, REACH_REPORT, } from "./harness/coverage.js";
|
|
27
|
+
import { COVERAGE_CONTAINER_DIR, coverageBundleRef, coverageHostDir, encodeCoverageBundle, isEmptyReport, readCoverageDir, applyCoverageDelta, walkProjectFiles, ENVIRONMENT_SERVICE, REACH_REPORT, READS_REPORT, } from "./harness/coverage.js";
|
|
28
28
|
import { configureBrowserCoverage } from "./browser-coverage.js";
|
|
29
29
|
import { INVENTORY_REPORT, NODE_COVERAGE_MARKERS_SUBDIR, NODE_COVERAGE_SCRIPTS_SUBDIR, applyCoverageAdapters, coverageAdapters, coverageReportsMode, validateCoverage, } from "./coverage.js";
|
|
30
30
|
import { serviceForHost as hostToService } from "./harness/browser-coverage.js";
|
|
@@ -41,10 +41,10 @@ import { summarizeBuildKit } from "./harness/buildkit-progress.js";
|
|
|
41
41
|
import { LOG_DELTA_MAX_BYTES, capMiddle, streamDelta } from "./harness/log-delta.js";
|
|
42
42
|
import { resolveHostPath as resolveVolumeHostPath, sanitizeSegment, } from "./harness/volume-paths.js";
|
|
43
43
|
import { pollUntilReady } from "./harness/ready-poll.js";
|
|
44
|
-
import { runWrapperRules } from "./harness/wrapper-rules.js";
|
|
44
|
+
import { fsUsageDiagnostics, runWrapperRules } from "./harness/wrapper-rules.js";
|
|
45
45
|
import { cpus } from "node:os";
|
|
46
46
|
import { APP_DIR, WORKSPACE, resolveExistingProjectPath, resolveProjectPath, takeProjectReads } from "./project-files.js";
|
|
47
|
-
import { projectReach,
|
|
47
|
+
import { compactReach, projectReach, renderReachRules, renderReadsLcov } from "./harness/reach.js";
|
|
48
48
|
import { installFetchWrapper, isTransportError } from "./harness/fetch.js";
|
|
49
49
|
import { bindInstrumentationScope, createInstrumentationScope, runInstrumented, runUninstrumented, } from "./harness/instrumentation-scope.js";
|
|
50
50
|
import { encodeRegistry } from "./harness/names-registry.js";
|
|
@@ -3584,6 +3584,7 @@ async function computeProjectReachInner(all) {
|
|
|
3584
3584
|
const sources = [];
|
|
3585
3585
|
for (const svc of all) {
|
|
3586
3586
|
const src = {
|
|
3587
|
+
service: svc.name,
|
|
3587
3588
|
volumeSources: (svc.volumes ?? []).flatMap((v) => (v.source ? [v.source] : [])),
|
|
3588
3589
|
};
|
|
3589
3590
|
if (svc.image.type === "dockerfile") {
|
|
@@ -3603,29 +3604,33 @@ async function computeProjectReachInner(all) {
|
|
|
3603
3604
|
}
|
|
3604
3605
|
sources.push(src);
|
|
3605
3606
|
}
|
|
3606
|
-
|
|
3607
|
+
const reach = projectReach(files, sources);
|
|
3608
|
+
return { version: 1, sources, prefixes: compactReach(files, reach) };
|
|
3607
3609
|
}
|
|
3608
3610
|
/**
|
|
3609
3611
|
* The synthetic `__environment__` capture: what the environment itself
|
|
3610
|
-
* knows — the project's reach (bring-up only) and the project
|
|
3611
|
-
* harness read through the SDK since the previous capture, as
|
|
3612
|
-
* lcov records. Present
|
|
3613
|
-
* the
|
|
3612
|
+
* knows — the project's reach as rules (bring-up only) and the project
|
|
3613
|
+
* files the harness read through the SDK since the previous capture, as
|
|
3614
|
+
* whole lcov records. Present on every capture of every project, coverage
|
|
3615
|
+
* adapters or not: the reach is a property of the environment config,
|
|
3616
|
+
* and it is what lets the control plane tell that a change to `site/**`
|
|
3617
|
+
* can affect no test at all. When services opted in, the two ride the
|
|
3618
|
+
* same bundle as their reports.
|
|
3614
3619
|
*/
|
|
3615
3620
|
async function captureEnvironmentCoverage(all, baseline) {
|
|
3616
3621
|
const reports = [];
|
|
3617
3622
|
if (baseline) {
|
|
3618
3623
|
try {
|
|
3619
|
-
const
|
|
3620
|
-
reports.push({ name: REACH_REPORT, content:
|
|
3621
|
-
console.log(`[coverage] project reach: ${
|
|
3624
|
+
const rules = await computeProjectReach(all);
|
|
3625
|
+
reports.push({ name: REACH_REPORT, content: renderReachRules(rules) });
|
|
3626
|
+
console.log(`[coverage] project reach: ${rules.sources.length} source(s), ${rules.prefixes.length} prefix(es) some build or volume can carry`);
|
|
3622
3627
|
}
|
|
3623
3628
|
catch (err) {
|
|
3624
3629
|
console.warn(`[coverage] project reach not computed: ${err instanceof Error ? err.message : String(err)}`);
|
|
3625
3630
|
}
|
|
3626
3631
|
}
|
|
3627
3632
|
const reads = takeProjectReads();
|
|
3628
|
-
reports.push({ name:
|
|
3633
|
+
reports.push({ name: READS_REPORT, content: renderReadsLcov(reads) });
|
|
3629
3634
|
return { service: ENVIRONMENT_SERVICE, reports };
|
|
3630
3635
|
}
|
|
3631
3636
|
async function captureServiceCoverage(baseline = false) {
|
|
@@ -3639,8 +3644,8 @@ async function captureServiceCoverage(baseline = false) {
|
|
|
3639
3644
|
byName.set(name, s);
|
|
3640
3645
|
const all = [...byName.values()];
|
|
3641
3646
|
const services = all.filter((s) => s.coverage !== undefined);
|
|
3642
|
-
|
|
3643
|
-
|
|
3647
|
+
// The environment's own capture is unconditional (see above); the
|
|
3648
|
+
// services' come from the ones that opted in, none included.
|
|
3644
3649
|
const environment = captureEnvironmentCoverage(all, baseline);
|
|
3645
3650
|
const captures = await Promise.all(services.map(async (svc) => {
|
|
3646
3651
|
const { error, deltaReports } = await runCoverageAdapters(svc);
|
|
@@ -5352,7 +5357,7 @@ async function runTypecheck() {
|
|
|
5352
5357
|
return { status: "failed", errors: [], totalErrors: 0, durationMs, detail: "typecheck timed out" };
|
|
5353
5358
|
}
|
|
5354
5359
|
const { errors, total, suppressed } = parseTscOutput(res.stdout + res.stderr);
|
|
5355
|
-
const wrapper = await runWrapperRulesReport(config);
|
|
5360
|
+
const wrapper = [...(await runWrapperRulesReport(config)), ...(await rawFsReport())];
|
|
5356
5361
|
const strip = ({ file, line, column, code, message }) => ({
|
|
5357
5362
|
file,
|
|
5358
5363
|
line,
|
|
@@ -5389,6 +5394,31 @@ async function runTypecheck() {
|
|
|
5389
5394
|
...(blocking.length > 0 ? { blocking } : {}),
|
|
5390
5395
|
};
|
|
5391
5396
|
}
|
|
5397
|
+
/**
|
|
5398
|
+
* The raw-fs rule (`SPECTEST2003`, advisory) over the test tree: a test
|
|
5399
|
+
* that opens a project file through `node:fs` hides a dependence from the
|
|
5400
|
+
* subset selector. Textual, so it needs no compiler; best-effort.
|
|
5401
|
+
*/
|
|
5402
|
+
async function rawFsReport() {
|
|
5403
|
+
const root = path.join(APP_DIR, "tests");
|
|
5404
|
+
if (!existsSync(root))
|
|
5405
|
+
return [];
|
|
5406
|
+
try {
|
|
5407
|
+
const files = await walkProjectFiles(root, ["node_modules"]);
|
|
5408
|
+
const out = [];
|
|
5409
|
+
for (const rel of files) {
|
|
5410
|
+
if (!/\.(ts|tsx|js|jsx|mts|cts)$/.test(rel))
|
|
5411
|
+
continue;
|
|
5412
|
+
const text = await fs.readFile(path.join(root, rel), "utf8");
|
|
5413
|
+
out.push(...fsUsageDiagnostics(path.join("tests", rel), text));
|
|
5414
|
+
}
|
|
5415
|
+
return out;
|
|
5416
|
+
}
|
|
5417
|
+
catch (err) {
|
|
5418
|
+
console.warn(`[typecheck] raw fs rule threw: ${err?.message ?? err}`);
|
|
5419
|
+
return [];
|
|
5420
|
+
}
|
|
5421
|
+
}
|
|
5392
5422
|
/**
|
|
5393
5423
|
* The blocking rules, run against the BAKED compiler whatever the project
|
|
5394
5424
|
* pins. Best-effort in every direction: an install that predates the API, a
|
|
@@ -3,9 +3,12 @@ export declare const COVERAGE_CONTAINER_DIR = "/spectest/coverage";
|
|
|
3
3
|
/** The synthetic service the environment's own reports ride under: the
|
|
4
4
|
* project's reach (bring-up) and the project files the harness read. */
|
|
5
5
|
export declare const ENVIRONMENT_SERVICE = "__environment__";
|
|
6
|
-
/** The bring-up report
|
|
7
|
-
*
|
|
8
|
-
export declare const REACH_REPORT = "reach.
|
|
6
|
+
/** The bring-up report carrying the project's reach as rules plus a
|
|
7
|
+
* compacted prefix list (`harness/reach.ts`, `ReachRules`). */
|
|
8
|
+
export declare const REACH_REPORT = "reach.json";
|
|
9
|
+
/** The per-capture report naming the project files the harness read
|
|
10
|
+
* through the SDK since the previous capture, as whole lcov records. */
|
|
11
|
+
export declare const READS_REPORT = "reads.lcov";
|
|
9
12
|
/** Raw bytes of reports one service may ship per capture. Reports are
|
|
10
13
|
* bounded by code size times chain depth, so anything past this is a
|
|
11
14
|
* runaway (a tool writing a new file per request), not coverage. */
|
|
@@ -82,6 +85,9 @@ export declare function isUnitsReport(text: string): boolean;
|
|
|
82
85
|
/** True when `text` is a report spectest stores: lcov, V8 JSON, a node()
|
|
83
86
|
* script record, or (by name) a `.units` list. */
|
|
84
87
|
export declare function isWellFormedReport(text: string, name?: string): boolean;
|
|
88
|
+
/** True when `text` is a `reach.json` document: a JSON object with a
|
|
89
|
+
* `sources` array and a `prefixes` array. */
|
|
90
|
+
export declare function isReachRules(text: string): boolean;
|
|
85
91
|
/** True when a well-formed report names no source file at all (an lcov
|
|
86
92
|
* with no `SF:`, a V8 document with an empty `result`, a units list
|
|
87
93
|
* with no unit). */
|
package/dist/harness/coverage.js
CHANGED
|
@@ -36,9 +36,12 @@ export const COVERAGE_CONTAINER_DIR = "/spectest/coverage";
|
|
|
36
36
|
/** The synthetic service the environment's own reports ride under: the
|
|
37
37
|
* project's reach (bring-up) and the project files the harness read. */
|
|
38
38
|
export const ENVIRONMENT_SERVICE = "__environment__";
|
|
39
|
-
/** The bring-up report
|
|
40
|
-
*
|
|
41
|
-
export const REACH_REPORT = "reach.
|
|
39
|
+
/** The bring-up report carrying the project's reach as rules plus a
|
|
40
|
+
* compacted prefix list (`harness/reach.ts`, `ReachRules`). */
|
|
41
|
+
export const REACH_REPORT = "reach.json";
|
|
42
|
+
/** The per-capture report naming the project files the harness read
|
|
43
|
+
* through the SDK since the previous capture, as whole lcov records. */
|
|
44
|
+
export const READS_REPORT = "reads.lcov";
|
|
42
45
|
/** Raw bytes of reports one service may ship per capture. Reports are
|
|
43
46
|
* bounded by code size times chain depth, so anything past this is a
|
|
44
47
|
* runaway (a tool writing a new file per request), not coverage. */
|
|
@@ -89,12 +92,27 @@ export function isUnitsReport(text) {
|
|
|
89
92
|
export function isWellFormedReport(text, name = "") {
|
|
90
93
|
if (name.endsWith(".units"))
|
|
91
94
|
return isUnitsReport(text);
|
|
95
|
+
if (name === REACH_REPORT)
|
|
96
|
+
return isReachRules(text);
|
|
92
97
|
return isLcovShaped(text) || isV8CoverageJson(text) || isSpectestScriptRecord(text);
|
|
93
98
|
}
|
|
99
|
+
/** True when `text` is a `reach.json` document: a JSON object with a
|
|
100
|
+
* `sources` array and a `prefixes` array. */
|
|
101
|
+
export function isReachRules(text) {
|
|
102
|
+
try {
|
|
103
|
+
const v = JSON.parse(text);
|
|
104
|
+
return typeof v === "object" && v !== null && Array.isArray(v.sources) && Array.isArray(v.prefixes);
|
|
105
|
+
}
|
|
106
|
+
catch {
|
|
107
|
+
return false;
|
|
108
|
+
}
|
|
109
|
+
}
|
|
94
110
|
/** True when a well-formed report names no source file at all (an lcov
|
|
95
111
|
* with no `SF:`, a V8 document with an empty `result`, a units list
|
|
96
112
|
* with no unit). */
|
|
97
113
|
export function isEmptyReport(text) {
|
|
114
|
+
if (isReachRules(text))
|
|
115
|
+
return false;
|
|
98
116
|
if (isV8CoverageJson(text))
|
|
99
117
|
return /"result"\s*:\s*\[\s*\]/.test(text);
|
|
100
118
|
if (isSpectestScriptRecord(text))
|
package/dist/harness/reach.d.ts
CHANGED
|
@@ -12,6 +12,8 @@ export declare function compileDockerignore(text: string): Pattern[];
|
|
|
12
12
|
export declare function isExcluded(patterns: Pattern[], rel: string): boolean;
|
|
13
13
|
/** One service's contribution to the reach. */
|
|
14
14
|
export interface ReachSource {
|
|
15
|
+
/** The service the source belongs to (for the shadow page's verdicts). */
|
|
16
|
+
service?: string;
|
|
15
17
|
/** Build context directory, project-relative (`.` for the root); absent
|
|
16
18
|
* for a registry image. */
|
|
17
19
|
context?: string;
|
|
@@ -30,8 +32,33 @@ export interface ReachSource {
|
|
|
30
32
|
* Dockerfiles the builds read. Sorted, unique.
|
|
31
33
|
*/
|
|
32
34
|
export declare function projectReach(files: readonly string[], sources: readonly ReachSource[]): string[];
|
|
33
|
-
/** The `reach.
|
|
34
|
-
export
|
|
35
|
+
/** The `reach.json` report. Mirrors `reach::ReachRules` on the server. */
|
|
36
|
+
export interface ReachRules {
|
|
37
|
+
version: 1;
|
|
38
|
+
/** Every service's contribution, as rules. */
|
|
39
|
+
sources: ReachSource[];
|
|
40
|
+
/** The current file reach, compacted: `dir/` for a directory every
|
|
41
|
+
* project file of which is in the reach, a bare path for a file on its
|
|
42
|
+
* own, `.` when the whole project is. */
|
|
43
|
+
prefixes: string[];
|
|
44
|
+
}
|
|
45
|
+
/**
|
|
46
|
+
* Compact a file reach against the project's file list: a directory
|
|
47
|
+
* whose every file is in the reach becomes one `dir/` prefix (the root
|
|
48
|
+
* becomes `.`), and a file whose directory is not wholly reached stays a
|
|
49
|
+
* path. Only directories that hold a project file exist here; an empty
|
|
50
|
+
* directory has nothing to be in or out of the reach.
|
|
51
|
+
*/
|
|
52
|
+
export declare function compactReach(files: readonly string[], reach: readonly string[]): string[];
|
|
53
|
+
/** Is `path` (project-relative) in the reach the rules describe? The
|
|
54
|
+
* compacted prefixes answer first; a path outside them is evaluated
|
|
55
|
+
* against every source, so a file that did not exist when the prefixes
|
|
56
|
+
* were computed gets the same answer the build would give. */
|
|
57
|
+
export declare function reachContains(rules: ReachRules, path: string): boolean;
|
|
58
|
+
/** The first source whose rules carry `path`, or `undefined`. */
|
|
59
|
+
export declare function sourceOf(sources: readonly ReachSource[], path: string): ReachSource | undefined;
|
|
60
|
+
/** The `reach.json` report text. */
|
|
61
|
+
export declare function renderReachRules(rules: ReachRules): string;
|
|
35
62
|
/** An lcov document that names `paths` as files with no line data —
|
|
36
63
|
* "whole" records, which the index reads as every line unknown, so any
|
|
37
64
|
* change to the file selects the case that read it. */
|
package/dist/harness/reach.js
CHANGED
|
@@ -4,14 +4,24 @@
|
|
|
4
4
|
// a service's **build context** (so an image may carry it), it is the
|
|
5
5
|
// **source of a volume** a service mounts, or a test **reads** it through
|
|
6
6
|
// the SDK. The first two are known from the environment config before any
|
|
7
|
-
// test runs; the harness
|
|
8
|
-
// **
|
|
9
|
-
//
|
|
10
|
-
//
|
|
11
|
-
//
|
|
7
|
+
// test runs; the harness ships them with the bring-up capture as
|
|
8
|
+
// **rules** (`reach.json`, under the synthetic `__environment__`
|
|
9
|
+
// service): per service its context directory, the composed
|
|
10
|
+
// `.dockerignore` text, the Dockerfile it reads and the volume sources it
|
|
11
|
+
// mounts. Rules, not a file list: the control plane evaluates any changed
|
|
12
|
+
// path against them, a file that did not exist at the base included, and
|
|
13
|
+
// a project of ten thousand files ships a few lines. Beside the rules
|
|
14
|
+
// rides a **compacted** view of the current file reach — a directory
|
|
15
|
+
// every file of which is in the reach as one `dir/` prefix, the odd file
|
|
16
|
+
// alone — which the control plane uses as a fast path and the shadow page
|
|
17
|
+
// as the summary. The subset selector then treats a changed file outside
|
|
18
|
+
// the reach as one that cannot affect the suite: `site/**` behind a
|
|
12
19
|
// `.dockerignore`, `.github/**` outside every context. The third door is
|
|
13
20
|
// recorded as it happens (`project-files.ts`, `takeProjectReads`).
|
|
14
21
|
//
|
|
22
|
+
// The rules are shipped for every project, coverage adapters or not: the
|
|
23
|
+
// reach is a property of the environment config alone.
|
|
24
|
+
//
|
|
15
25
|
// The context of a build is what `docker build` would send: every file
|
|
16
26
|
// under the context directory minus what the composed `.dockerignore`
|
|
17
27
|
// (the project's own rules plus the service's `exclude`) matches, with
|
|
@@ -144,9 +154,101 @@ export function projectReach(files, sources) {
|
|
|
144
154
|
}
|
|
145
155
|
return [...out].sort();
|
|
146
156
|
}
|
|
147
|
-
/**
|
|
148
|
-
|
|
149
|
-
|
|
157
|
+
/**
|
|
158
|
+
* Compact a file reach against the project's file list: a directory
|
|
159
|
+
* whose every file is in the reach becomes one `dir/` prefix (the root
|
|
160
|
+
* becomes `.`), and a file whose directory is not wholly reached stays a
|
|
161
|
+
* path. Only directories that hold a project file exist here; an empty
|
|
162
|
+
* directory has nothing to be in or out of the reach.
|
|
163
|
+
*/
|
|
164
|
+
export function compactReach(files, reach) {
|
|
165
|
+
const inReach = new Set(reach);
|
|
166
|
+
// Per directory: how many project files live below it, how many of
|
|
167
|
+
// those the reach holds. "" is the root.
|
|
168
|
+
const total = new Map();
|
|
169
|
+
const hit = new Map();
|
|
170
|
+
const children = new Map();
|
|
171
|
+
const filesIn = new Map();
|
|
172
|
+
for (const f of files) {
|
|
173
|
+
const parts = f.split("/");
|
|
174
|
+
let dir = "";
|
|
175
|
+
for (let i = 0; i < parts.length; i += 1) {
|
|
176
|
+
total.set(dir, (total.get(dir) ?? 0) + 1);
|
|
177
|
+
if (inReach.has(f))
|
|
178
|
+
hit.set(dir, (hit.get(dir) ?? 0) + 1);
|
|
179
|
+
if (i === parts.length - 1) {
|
|
180
|
+
let list = filesIn.get(dir);
|
|
181
|
+
if (!list)
|
|
182
|
+
filesIn.set(dir, (list = []));
|
|
183
|
+
list.push(f);
|
|
184
|
+
}
|
|
185
|
+
else {
|
|
186
|
+
const child = dir === "" ? parts[i] : `${dir}/${parts[i]}`;
|
|
187
|
+
let set = children.get(dir);
|
|
188
|
+
if (!set)
|
|
189
|
+
children.set(dir, (set = new Set()));
|
|
190
|
+
set.add(child);
|
|
191
|
+
dir = child;
|
|
192
|
+
}
|
|
193
|
+
}
|
|
194
|
+
}
|
|
195
|
+
const out = [];
|
|
196
|
+
const walk = (dir) => {
|
|
197
|
+
const n = total.get(dir) ?? 0;
|
|
198
|
+
if (n > 0 && (hit.get(dir) ?? 0) === n) {
|
|
199
|
+
out.push(dir === "" ? "." : `${dir}/`);
|
|
200
|
+
return;
|
|
201
|
+
}
|
|
202
|
+
for (const f of filesIn.get(dir) ?? [])
|
|
203
|
+
if (inReach.has(f))
|
|
204
|
+
out.push(f);
|
|
205
|
+
for (const c of children.get(dir) ?? [])
|
|
206
|
+
walk(c);
|
|
207
|
+
};
|
|
208
|
+
walk("");
|
|
209
|
+
return out.sort();
|
|
210
|
+
}
|
|
211
|
+
/** Is `path` (project-relative) in the reach the rules describe? The
|
|
212
|
+
* compacted prefixes answer first; a path outside them is evaluated
|
|
213
|
+
* against every source, so a file that did not exist when the prefixes
|
|
214
|
+
* were computed gets the same answer the build would give. */
|
|
215
|
+
export function reachContains(rules, path) {
|
|
216
|
+
const p = normRel(path);
|
|
217
|
+
for (const pre of rules.prefixes) {
|
|
218
|
+
if (pre === "." || pre === p || (pre.endsWith("/") && p.startsWith(pre)))
|
|
219
|
+
return true;
|
|
220
|
+
}
|
|
221
|
+
return sourceOf(rules.sources, p) !== undefined;
|
|
222
|
+
}
|
|
223
|
+
/** The first source whose rules carry `path`, or `undefined`. */
|
|
224
|
+
export function sourceOf(sources, path) {
|
|
225
|
+
const p = normRel(path);
|
|
226
|
+
for (const s of sources) {
|
|
227
|
+
if (s.context !== undefined) {
|
|
228
|
+
const ctx = normRel(s.context);
|
|
229
|
+
const prefix = ctx === "" ? "" : `${ctx}/`;
|
|
230
|
+
if (!prefix || p.startsWith(prefix)) {
|
|
231
|
+
const rel = p.slice(prefix.length);
|
|
232
|
+
if (rel === ".dockerignore" || rel === "Dockerfile" || !isExcluded(compileDockerignore(s.ignore ?? ""), rel))
|
|
233
|
+
return s;
|
|
234
|
+
}
|
|
235
|
+
}
|
|
236
|
+
for (const e of s.extraFiles ?? [])
|
|
237
|
+
if (normRel(e) === p)
|
|
238
|
+
return s;
|
|
239
|
+
for (const v of s.volumeSources ?? []) {
|
|
240
|
+
if (v.startsWith("/"))
|
|
241
|
+
continue;
|
|
242
|
+
const src = normRel(v);
|
|
243
|
+
if (p === src || p.startsWith(`${src}/`))
|
|
244
|
+
return s;
|
|
245
|
+
}
|
|
246
|
+
}
|
|
247
|
+
return undefined;
|
|
248
|
+
}
|
|
249
|
+
/** The `reach.json` report text. */
|
|
250
|
+
export function renderReachRules(rules) {
|
|
251
|
+
return JSON.stringify(rules);
|
|
150
252
|
}
|
|
151
253
|
/** An lcov document that names `paths` as files with no line data —
|
|
152
254
|
* "whole" records, which the index reads as every line unknown, so any
|
|
@@ -1,3 +1,16 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* A test that opens a project file through `node:fs` (or `Bun.file`)
|
|
3
|
+
* instead of `ctx.readProjectFile`. **Advisory.** The subset selector
|
|
4
|
+
* learns which project files a test depends on from the reads the SDK
|
|
5
|
+
* serves (`project-files.ts`); a raw read is a dependence it never sees,
|
|
6
|
+
* so a change to that file can skip the test. The rule is textual — an
|
|
7
|
+
* import or `require` of the fs modules, or a `Bun.file(` call — and is
|
|
8
|
+
* applied to the test tree only, where the alternative exists. It turns
|
|
9
|
+
* blocking when the reach gate enforces (`SUBSET_RUNS.md`).
|
|
10
|
+
*/
|
|
11
|
+
export declare const CODE_RAW_FS = "SPECTEST2003";
|
|
12
|
+
/** The raw-fs findings of one test file, from its text alone. */
|
|
13
|
+
export declare function fsUsageDiagnostics(file: string, text: string): WrapperDiagnostic[];
|
|
1
14
|
/** A finding, shaped like the `TypecheckError` the report already carries. */
|
|
2
15
|
export interface WrapperDiagnostic {
|
|
3
16
|
/** Path relative to the app dir, matching the advisory diagnostics. */
|
|
@@ -55,6 +55,43 @@
|
|
|
55
55
|
// function over a string, which is what the tests below drive. A user type
|
|
56
56
|
// that happens to be called `Carrier<T>` would also be flagged — and would
|
|
57
57
|
// also be an object at runtime, so the finding stays true.
|
|
58
|
+
/**
|
|
59
|
+
* A test that opens a project file through `node:fs` (or `Bun.file`)
|
|
60
|
+
* instead of `ctx.readProjectFile`. **Advisory.** The subset selector
|
|
61
|
+
* learns which project files a test depends on from the reads the SDK
|
|
62
|
+
* serves (`project-files.ts`); a raw read is a dependence it never sees,
|
|
63
|
+
* so a change to that file can skip the test. The rule is textual — an
|
|
64
|
+
* import or `require` of the fs modules, or a `Bun.file(` call — and is
|
|
65
|
+
* applied to the test tree only, where the alternative exists. It turns
|
|
66
|
+
* blocking when the reach gate enforces (`SUBSET_RUNS.md`).
|
|
67
|
+
*/
|
|
68
|
+
export const CODE_RAW_FS = "SPECTEST2003";
|
|
69
|
+
const FS_MODULES = new Set(["fs", "node:fs", "fs/promises", "node:fs/promises"]);
|
|
70
|
+
/** The raw-fs findings of one test file, from its text alone. */
|
|
71
|
+
export function fsUsageDiagnostics(file, text) {
|
|
72
|
+
const out = [];
|
|
73
|
+
const push = (pos, what) => {
|
|
74
|
+
const { line, column } = lineColumnAt(text, pos);
|
|
75
|
+
out.push({
|
|
76
|
+
file,
|
|
77
|
+
line,
|
|
78
|
+
column,
|
|
79
|
+
code: CODE_RAW_FS,
|
|
80
|
+
message: `${what} reads project files behind the SDK's back; use ctx.readProjectFile (or ctx.readProjectFileBytes) ` +
|
|
81
|
+
"so the subset selector knows the test depends on the file",
|
|
82
|
+
blocking: false,
|
|
83
|
+
});
|
|
84
|
+
};
|
|
85
|
+
const importRe = /\b(?:import|export)\b[^;'"]*?\bfrom\s*['"]([^'"]+)['"]|\bimport\s*['"]([^'"]+)['"]|\brequire\(\s*['"]([^'"]+)['"]\s*\)|\bimport\(\s*['"]([^'"]+)['"]\s*\)/g;
|
|
86
|
+
for (const m of text.matchAll(importRe)) {
|
|
87
|
+
const spec = m[1] ?? m[2] ?? m[3] ?? m[4] ?? "";
|
|
88
|
+
if (FS_MODULES.has(spec))
|
|
89
|
+
push(m.index ?? 0, `an import of "${spec}"`);
|
|
90
|
+
}
|
|
91
|
+
for (const m of text.matchAll(/\bBun\.file\(/g))
|
|
92
|
+
push(m.index ?? 0, "Bun.file()");
|
|
93
|
+
return out;
|
|
94
|
+
}
|
|
58
95
|
/**
|
|
59
96
|
* A condition on a wrapped value — always true. **Blocking**, but on different
|
|
60
97
|
* grounds from {@link CODE_EQUALITY}, and the difference is worth knowing.
|
package/package.json
CHANGED
package/src/daemon.ts
CHANGED
|
@@ -48,6 +48,7 @@ import {
|
|
|
48
48
|
walkProjectFiles,
|
|
49
49
|
ENVIRONMENT_SERVICE,
|
|
50
50
|
REACH_REPORT,
|
|
51
|
+
READS_REPORT,
|
|
51
52
|
type CoverageBundleRef,
|
|
52
53
|
type CoverageReport,
|
|
53
54
|
type ServiceCoverageCapture,
|
|
@@ -97,11 +98,11 @@ import {
|
|
|
97
98
|
sanitizeSegment,
|
|
98
99
|
} from "./harness/volume-paths.js";
|
|
99
100
|
import { pollUntilReady } from "./harness/ready-poll.js";
|
|
100
|
-
import { runWrapperRules } from "./harness/wrapper-rules.js";
|
|
101
|
+
import { fsUsageDiagnostics, runWrapperRules } from "./harness/wrapper-rules.js";
|
|
101
102
|
import type { WrapperDiagnostic } from "./harness/wrapper-rules.js";
|
|
102
103
|
import { cpus, tmpdir } from "node:os";
|
|
103
104
|
import { APP_DIR, WORKSPACE, resolveExistingProjectPath, resolveProjectPath, takeProjectReads } from "./project-files.js";
|
|
104
|
-
import { projectReach,
|
|
105
|
+
import { compactReach, projectReach, renderReachRules, renderReadsLcov, type ReachRules, type ReachSource } from "./harness/reach.js";
|
|
105
106
|
import { installFetchWrapper, isTransportError } from "./harness/fetch.js";
|
|
106
107
|
import {
|
|
107
108
|
bindInstrumentationScope,
|
|
@@ -4237,7 +4238,7 @@ async function writeCoverageInventory(svc: NamedService): Promise<string | undef
|
|
|
4237
4238
|
* bring-up capture. The resolution's own path lookups are bookkeeping,
|
|
4238
4239
|
* not reads: they are taken out of the read set again afterwards.
|
|
4239
4240
|
*/
|
|
4240
|
-
async function computeProjectReach(all: NamedService[]): Promise<
|
|
4241
|
+
async function computeProjectReach(all: NamedService[]): Promise<ReachRules> {
|
|
4241
4242
|
const before = takeProjectReads();
|
|
4242
4243
|
try {
|
|
4243
4244
|
return await computeProjectReachInner(all);
|
|
@@ -4248,11 +4249,12 @@ async function computeProjectReach(all: NamedService[]): Promise<string[]> {
|
|
|
4248
4249
|
}
|
|
4249
4250
|
}
|
|
4250
4251
|
|
|
4251
|
-
async function computeProjectReachInner(all: NamedService[]): Promise<
|
|
4252
|
+
async function computeProjectReachInner(all: NamedService[]): Promise<ReachRules> {
|
|
4252
4253
|
const files = await walkProjectFiles(WORKSPACE, [".git"]);
|
|
4253
4254
|
const sources: ReachSource[] = [];
|
|
4254
4255
|
for (const svc of all) {
|
|
4255
4256
|
const src: ReachSource = {
|
|
4257
|
+
service: svc.name,
|
|
4256
4258
|
volumeSources: (svc.volumes ?? []).flatMap((v) => (v.source ? [v.source] : [])),
|
|
4257
4259
|
};
|
|
4258
4260
|
if (svc.image.type === "dockerfile") {
|
|
@@ -4270,29 +4272,35 @@ async function computeProjectReachInner(all: NamedService[]): Promise<string[]>
|
|
|
4270
4272
|
}
|
|
4271
4273
|
sources.push(src);
|
|
4272
4274
|
}
|
|
4273
|
-
|
|
4275
|
+
const reach = projectReach(files, sources);
|
|
4276
|
+
return { version: 1, sources, prefixes: compactReach(files, reach) };
|
|
4274
4277
|
}
|
|
4275
4278
|
|
|
4276
4279
|
/**
|
|
4277
4280
|
* The synthetic `__environment__` capture: what the environment itself
|
|
4278
|
-
* knows — the project's reach (bring-up only) and the project
|
|
4279
|
-
* harness read through the SDK since the previous capture, as
|
|
4280
|
-
* lcov records. Present
|
|
4281
|
-
* the
|
|
4281
|
+
* knows — the project's reach as rules (bring-up only) and the project
|
|
4282
|
+
* files the harness read through the SDK since the previous capture, as
|
|
4283
|
+
* whole lcov records. Present on every capture of every project, coverage
|
|
4284
|
+
* adapters or not: the reach is a property of the environment config,
|
|
4285
|
+
* and it is what lets the control plane tell that a change to `site/**`
|
|
4286
|
+
* can affect no test at all. When services opted in, the two ride the
|
|
4287
|
+
* same bundle as their reports.
|
|
4282
4288
|
*/
|
|
4283
4289
|
async function captureEnvironmentCoverage(all: NamedService[], baseline: boolean): Promise<ServiceCoverageCapture> {
|
|
4284
4290
|
const reports: CoverageReport[] = [];
|
|
4285
4291
|
if (baseline) {
|
|
4286
4292
|
try {
|
|
4287
|
-
const
|
|
4288
|
-
reports.push({ name: REACH_REPORT, content:
|
|
4289
|
-
console.log(
|
|
4293
|
+
const rules = await computeProjectReach(all);
|
|
4294
|
+
reports.push({ name: REACH_REPORT, content: renderReachRules(rules) });
|
|
4295
|
+
console.log(
|
|
4296
|
+
`[coverage] project reach: ${rules.sources.length} source(s), ${rules.prefixes.length} prefix(es) some build or volume can carry`,
|
|
4297
|
+
);
|
|
4290
4298
|
} catch (err) {
|
|
4291
4299
|
console.warn(`[coverage] project reach not computed: ${err instanceof Error ? err.message : String(err)}`);
|
|
4292
4300
|
}
|
|
4293
4301
|
}
|
|
4294
4302
|
const reads = takeProjectReads();
|
|
4295
|
-
reports.push({ name:
|
|
4303
|
+
reports.push({ name: READS_REPORT, content: renderReadsLcov(reads) });
|
|
4296
4304
|
return { service: ENVIRONMENT_SERVICE, reports };
|
|
4297
4305
|
}
|
|
4298
4306
|
|
|
@@ -4304,7 +4312,8 @@ async function captureServiceCoverage(baseline = false): Promise<ServiceCoverage
|
|
|
4304
4312
|
for (const [name, s] of RUNTIME_SERVICES) byName.set(name, s);
|
|
4305
4313
|
const all = [...byName.values()];
|
|
4306
4314
|
const services = all.filter((s) => s.coverage !== undefined);
|
|
4307
|
-
|
|
4315
|
+
// The environment's own capture is unconditional (see above); the
|
|
4316
|
+
// services' come from the ones that opted in, none included.
|
|
4308
4317
|
const environment = captureEnvironmentCoverage(all, baseline);
|
|
4309
4318
|
const captures = await Promise.all(
|
|
4310
4319
|
services.map(async (svc): Promise<ServiceCoverageCapture> => {
|
|
@@ -6415,7 +6424,7 @@ async function runTypecheck(): Promise<TypecheckReport> {
|
|
|
6415
6424
|
return { status: "failed", errors: [], totalErrors: 0, durationMs, detail: "typecheck timed out" };
|
|
6416
6425
|
}
|
|
6417
6426
|
const { errors, total, suppressed } = parseTscOutput(res.stdout + res.stderr);
|
|
6418
|
-
const wrapper = await runWrapperRulesReport(config);
|
|
6427
|
+
const wrapper = [...(await runWrapperRulesReport(config)), ...(await rawFsReport())];
|
|
6419
6428
|
const strip = ({ file, line, column, code, message }: WrapperDiagnostic): TypecheckError => ({
|
|
6420
6429
|
file,
|
|
6421
6430
|
line,
|
|
@@ -6453,6 +6462,29 @@ async function runTypecheck(): Promise<TypecheckReport> {
|
|
|
6453
6462
|
};
|
|
6454
6463
|
}
|
|
6455
6464
|
|
|
6465
|
+
/**
|
|
6466
|
+
* The raw-fs rule (`SPECTEST2003`, advisory) over the test tree: a test
|
|
6467
|
+
* that opens a project file through `node:fs` hides a dependence from the
|
|
6468
|
+
* subset selector. Textual, so it needs no compiler; best-effort.
|
|
6469
|
+
*/
|
|
6470
|
+
async function rawFsReport(): Promise<WrapperDiagnostic[]> {
|
|
6471
|
+
const root = path.join(APP_DIR, "tests");
|
|
6472
|
+
if (!existsSync(root)) return [];
|
|
6473
|
+
try {
|
|
6474
|
+
const files = await walkProjectFiles(root, ["node_modules"]);
|
|
6475
|
+
const out: WrapperDiagnostic[] = [];
|
|
6476
|
+
for (const rel of files) {
|
|
6477
|
+
if (!/\.(ts|tsx|js|jsx|mts|cts)$/.test(rel)) continue;
|
|
6478
|
+
const text = await fs.readFile(path.join(root, rel), "utf8");
|
|
6479
|
+
out.push(...fsUsageDiagnostics(path.join("tests", rel), text));
|
|
6480
|
+
}
|
|
6481
|
+
return out;
|
|
6482
|
+
} catch (err) {
|
|
6483
|
+
console.warn(`[typecheck] raw fs rule threw: ${(err as Error)?.message ?? err}`);
|
|
6484
|
+
return [];
|
|
6485
|
+
}
|
|
6486
|
+
}
|
|
6487
|
+
|
|
6456
6488
|
/**
|
|
6457
6489
|
* The blocking rules, run against the BAKED compiler whatever the project
|
|
6458
6490
|
* pins. Best-effort in every direction: an install that predates the API, a
|
package/src/harness/coverage.ts
CHANGED
|
@@ -40,9 +40,13 @@ export const COVERAGE_CONTAINER_DIR = "/spectest/coverage";
|
|
|
40
40
|
* project's reach (bring-up) and the project files the harness read. */
|
|
41
41
|
export const ENVIRONMENT_SERVICE = "__environment__";
|
|
42
42
|
|
|
43
|
-
/** The bring-up report
|
|
44
|
-
*
|
|
45
|
-
export const REACH_REPORT = "reach.
|
|
43
|
+
/** The bring-up report carrying the project's reach as rules plus a
|
|
44
|
+
* compacted prefix list (`harness/reach.ts`, `ReachRules`). */
|
|
45
|
+
export const REACH_REPORT = "reach.json";
|
|
46
|
+
|
|
47
|
+
/** The per-capture report naming the project files the harness read
|
|
48
|
+
* through the SDK since the previous capture, as whole lcov records. */
|
|
49
|
+
export const READS_REPORT = "reads.lcov";
|
|
46
50
|
|
|
47
51
|
/** Raw bytes of reports one service may ship per capture. Reports are
|
|
48
52
|
* bounded by code size times chain depth, so anything past this is a
|
|
@@ -145,13 +149,26 @@ export function isUnitsReport(text: string): boolean {
|
|
|
145
149
|
* script record, or (by name) a `.units` list. */
|
|
146
150
|
export function isWellFormedReport(text: string, name = ""): boolean {
|
|
147
151
|
if (name.endsWith(".units")) return isUnitsReport(text);
|
|
152
|
+
if (name === REACH_REPORT) return isReachRules(text);
|
|
148
153
|
return isLcovShaped(text) || isV8CoverageJson(text) || isSpectestScriptRecord(text);
|
|
149
154
|
}
|
|
150
155
|
|
|
156
|
+
/** True when `text` is a `reach.json` document: a JSON object with a
|
|
157
|
+
* `sources` array and a `prefixes` array. */
|
|
158
|
+
export function isReachRules(text: string): boolean {
|
|
159
|
+
try {
|
|
160
|
+
const v = JSON.parse(text) as { sources?: unknown; prefixes?: unknown };
|
|
161
|
+
return typeof v === "object" && v !== null && Array.isArray(v.sources) && Array.isArray(v.prefixes);
|
|
162
|
+
} catch {
|
|
163
|
+
return false;
|
|
164
|
+
}
|
|
165
|
+
}
|
|
166
|
+
|
|
151
167
|
/** True when a well-formed report names no source file at all (an lcov
|
|
152
168
|
* with no `SF:`, a V8 document with an empty `result`, a units list
|
|
153
169
|
* with no unit). */
|
|
154
170
|
export function isEmptyReport(text: string): boolean {
|
|
171
|
+
if (isReachRules(text)) return false;
|
|
155
172
|
if (isV8CoverageJson(text)) return /"result"\s*:\s*\[\s*\]/.test(text);
|
|
156
173
|
if (isSpectestScriptRecord(text)) return false;
|
|
157
174
|
if (isUnitsReport(text) && !isLcovShaped(text)) return text.trim().length === 0;
|
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
import { describe, expect, test } from "bun:test";
|
|
2
|
-
import { compileDockerignore, isExcluded, projectReach, renderReadsLcov } from "./reach.js";
|
|
2
|
+
import { compactReach, compileDockerignore, isExcluded, projectReach, reachContains, renderReadsLcov, sourceOf } from "./reach.js";
|
|
3
3
|
|
|
4
4
|
describe("dockerignore semantics", () => {
|
|
5
5
|
const ex = (text: string, rel: string) => isExcluded(compileDockerignore(text), rel);
|
|
@@ -67,3 +67,69 @@ describe("projectReach", () => {
|
|
|
67
67
|
expect(renderReadsLcov(["docs/x.md"])).toBe("TN:\nSF:docs/x.md\nend_of_record\n");
|
|
68
68
|
});
|
|
69
69
|
});
|
|
70
|
+
|
|
71
|
+
describe("reach rules", () => {
|
|
72
|
+
const files = [
|
|
73
|
+
"package.json",
|
|
74
|
+
"packages/api/src/a.ts",
|
|
75
|
+
"packages/api/src/b.ts",
|
|
76
|
+
"packages/api/Dockerfile",
|
|
77
|
+
"packages/api/README.md",
|
|
78
|
+
"packages/dashboard/src/b.tsx",
|
|
79
|
+
"site/app/page.tsx",
|
|
80
|
+
".github/workflows/ci.yml",
|
|
81
|
+
"docs/x.md",
|
|
82
|
+
"spectest/index.ts",
|
|
83
|
+
"data/seed.sql",
|
|
84
|
+
];
|
|
85
|
+
const sources = [
|
|
86
|
+
{ service: "api", context: ".", ignore: "site/**\ndocs/**\n**/*.md\n", extraFiles: ["packages/api/Dockerfile"] },
|
|
87
|
+
{ service: "db", volumeSources: ["data", "/var/run/docker.sock"] },
|
|
88
|
+
];
|
|
89
|
+
const reach = projectReach(files, sources);
|
|
90
|
+
const rules = { version: 1 as const, sources, prefixes: compactReach(files, reach) };
|
|
91
|
+
|
|
92
|
+
test("compactReach folds a wholly reached directory into one prefix and keeps the odd file", () => {
|
|
93
|
+
// packages/api holds README.md, which the ignore drops, so the
|
|
94
|
+
// directory is not whole; its src/ subdirectory is.
|
|
95
|
+
expect(rules.prefixes).toEqual([
|
|
96
|
+
".github/",
|
|
97
|
+
"data/",
|
|
98
|
+
"package.json",
|
|
99
|
+
"packages/api/Dockerfile",
|
|
100
|
+
"packages/api/src/",
|
|
101
|
+
"packages/dashboard/",
|
|
102
|
+
"spectest/",
|
|
103
|
+
]);
|
|
104
|
+
expect(compactReach(files, files)).toEqual(["."]);
|
|
105
|
+
expect(compactReach(files, [])).toEqual([]);
|
|
106
|
+
});
|
|
107
|
+
|
|
108
|
+
test("reachContains answers from the prefixes, then from the rules for a path the prefixes do not know", () => {
|
|
109
|
+
expect(reachContains(rules, "packages/api/src/a.ts")).toBe(true);
|
|
110
|
+
expect(reachContains(rules, "packages/api/src/new.ts")).toBe(true);
|
|
111
|
+
expect(reachContains(rules, "site/app/page.tsx")).toBe(false);
|
|
112
|
+
expect(reachContains(rules, "site/new/thing.tsx")).toBe(false);
|
|
113
|
+
expect(reachContains(rules, "docs/new.md")).toBe(false);
|
|
114
|
+
// A new file the ignore's *.md rule drops, in a directory no prefix
|
|
115
|
+
// covers whole: the rules decide, and they say out.
|
|
116
|
+
expect(reachContains(rules, "packages/api/NOTES.md")).toBe(false);
|
|
117
|
+
// A new file under a whole prefix that the rules would drop: the
|
|
118
|
+
// prefix answers first, on the safe side (in).
|
|
119
|
+
expect(reachContains(rules, "packages/api/src/NOTES.md")).toBe(true);
|
|
120
|
+
// A volume source subtree, and an absolute source that is no project path.
|
|
121
|
+
expect(reachContains(rules, "data/new.sql")).toBe(true);
|
|
122
|
+
// The root context carries data/ too, and it is asked first.
|
|
123
|
+
expect(sourceOf(sources, "data/new.sql")?.service).toBe("api");
|
|
124
|
+
expect(sourceOf([sources[1]!], "data/new.sql")?.service).toBe("db");
|
|
125
|
+
expect(sourceOf([sources[1]!], "var/run/docker.sock")).toBeUndefined();
|
|
126
|
+
// The Dockerfile named by path, and the one at the context root.
|
|
127
|
+
expect(sourceOf(sources, "packages/api/Dockerfile")?.service).toBe("api");
|
|
128
|
+
expect(reachContains({ version: 1, sources: [{ context: "packages/x", ignore: "**" }], prefixes: [] }, "packages/x/Dockerfile")).toBe(true);
|
|
129
|
+
});
|
|
130
|
+
|
|
131
|
+
test("the whole project as one prefix", () => {
|
|
132
|
+
expect(reachContains({ version: 1, sources: [], prefixes: ["."] }, "anything/at/all")).toBe(true);
|
|
133
|
+
expect(reachContains({ version: 1, sources: [], prefixes: [] }, "anything/at/all")).toBe(false);
|
|
134
|
+
});
|
|
135
|
+
});
|
package/src/harness/reach.ts
CHANGED
|
@@ -4,14 +4,24 @@
|
|
|
4
4
|
// a service's **build context** (so an image may carry it), it is the
|
|
5
5
|
// **source of a volume** a service mounts, or a test **reads** it through
|
|
6
6
|
// the SDK. The first two are known from the environment config before any
|
|
7
|
-
// test runs; the harness
|
|
8
|
-
// **
|
|
9
|
-
//
|
|
10
|
-
//
|
|
11
|
-
//
|
|
7
|
+
// test runs; the harness ships them with the bring-up capture as
|
|
8
|
+
// **rules** (`reach.json`, under the synthetic `__environment__`
|
|
9
|
+
// service): per service its context directory, the composed
|
|
10
|
+
// `.dockerignore` text, the Dockerfile it reads and the volume sources it
|
|
11
|
+
// mounts. Rules, not a file list: the control plane evaluates any changed
|
|
12
|
+
// path against them, a file that did not exist at the base included, and
|
|
13
|
+
// a project of ten thousand files ships a few lines. Beside the rules
|
|
14
|
+
// rides a **compacted** view of the current file reach — a directory
|
|
15
|
+
// every file of which is in the reach as one `dir/` prefix, the odd file
|
|
16
|
+
// alone — which the control plane uses as a fast path and the shadow page
|
|
17
|
+
// as the summary. The subset selector then treats a changed file outside
|
|
18
|
+
// the reach as one that cannot affect the suite: `site/**` behind a
|
|
12
19
|
// `.dockerignore`, `.github/**` outside every context. The third door is
|
|
13
20
|
// recorded as it happens (`project-files.ts`, `takeProjectReads`).
|
|
14
21
|
//
|
|
22
|
+
// The rules are shipped for every project, coverage adapters or not: the
|
|
23
|
+
// reach is a property of the environment config alone.
|
|
24
|
+
//
|
|
15
25
|
// The context of a build is what `docker build` would send: every file
|
|
16
26
|
// under the context directory minus what the composed `.dockerignore`
|
|
17
27
|
// (the project's own rules plus the service's `exclude`) matches, with
|
|
@@ -102,6 +112,8 @@ export function isExcluded(patterns: Pattern[], rel: string): boolean {
|
|
|
102
112
|
|
|
103
113
|
/** One service's contribution to the reach. */
|
|
104
114
|
export interface ReachSource {
|
|
115
|
+
/** The service the source belongs to (for the shadow page's verdicts). */
|
|
116
|
+
service?: string;
|
|
105
117
|
/** Build context directory, project-relative (`.` for the root); absent
|
|
106
118
|
* for a registry image. */
|
|
107
119
|
context?: string;
|
|
@@ -156,9 +168,102 @@ export function projectReach(files: readonly string[], sources: readonly ReachSo
|
|
|
156
168
|
return [...out].sort();
|
|
157
169
|
}
|
|
158
170
|
|
|
159
|
-
/** The `reach.
|
|
160
|
-
export
|
|
161
|
-
|
|
171
|
+
/** The `reach.json` report. Mirrors `reach::ReachRules` on the server. */
|
|
172
|
+
export interface ReachRules {
|
|
173
|
+
version: 1;
|
|
174
|
+
/** Every service's contribution, as rules. */
|
|
175
|
+
sources: ReachSource[];
|
|
176
|
+
/** The current file reach, compacted: `dir/` for a directory every
|
|
177
|
+
* project file of which is in the reach, a bare path for a file on its
|
|
178
|
+
* own, `.` when the whole project is. */
|
|
179
|
+
prefixes: string[];
|
|
180
|
+
}
|
|
181
|
+
|
|
182
|
+
/**
|
|
183
|
+
* Compact a file reach against the project's file list: a directory
|
|
184
|
+
* whose every file is in the reach becomes one `dir/` prefix (the root
|
|
185
|
+
* becomes `.`), and a file whose directory is not wholly reached stays a
|
|
186
|
+
* path. Only directories that hold a project file exist here; an empty
|
|
187
|
+
* directory has nothing to be in or out of the reach.
|
|
188
|
+
*/
|
|
189
|
+
export function compactReach(files: readonly string[], reach: readonly string[]): string[] {
|
|
190
|
+
const inReach = new Set(reach);
|
|
191
|
+
// Per directory: how many project files live below it, how many of
|
|
192
|
+
// those the reach holds. "" is the root.
|
|
193
|
+
const total = new Map<string, number>();
|
|
194
|
+
const hit = new Map<string, number>();
|
|
195
|
+
const children = new Map<string, Set<string>>();
|
|
196
|
+
const filesIn = new Map<string, string[]>();
|
|
197
|
+
for (const f of files) {
|
|
198
|
+
const parts = f.split("/");
|
|
199
|
+
let dir = "";
|
|
200
|
+
for (let i = 0; i < parts.length; i += 1) {
|
|
201
|
+
total.set(dir, (total.get(dir) ?? 0) + 1);
|
|
202
|
+
if (inReach.has(f)) hit.set(dir, (hit.get(dir) ?? 0) + 1);
|
|
203
|
+
if (i === parts.length - 1) {
|
|
204
|
+
let list = filesIn.get(dir);
|
|
205
|
+
if (!list) filesIn.set(dir, (list = []));
|
|
206
|
+
list.push(f);
|
|
207
|
+
} else {
|
|
208
|
+
const child = dir === "" ? parts[i]! : `${dir}/${parts[i]!}`;
|
|
209
|
+
let set = children.get(dir);
|
|
210
|
+
if (!set) children.set(dir, (set = new Set()));
|
|
211
|
+
set.add(child);
|
|
212
|
+
dir = child;
|
|
213
|
+
}
|
|
214
|
+
}
|
|
215
|
+
}
|
|
216
|
+
const out: string[] = [];
|
|
217
|
+
const walk = (dir: string): void => {
|
|
218
|
+
const n = total.get(dir) ?? 0;
|
|
219
|
+
if (n > 0 && (hit.get(dir) ?? 0) === n) {
|
|
220
|
+
out.push(dir === "" ? "." : `${dir}/`);
|
|
221
|
+
return;
|
|
222
|
+
}
|
|
223
|
+
for (const f of filesIn.get(dir) ?? []) if (inReach.has(f)) out.push(f);
|
|
224
|
+
for (const c of children.get(dir) ?? []) walk(c);
|
|
225
|
+
};
|
|
226
|
+
walk("");
|
|
227
|
+
return out.sort();
|
|
228
|
+
}
|
|
229
|
+
|
|
230
|
+
/** Is `path` (project-relative) in the reach the rules describe? The
|
|
231
|
+
* compacted prefixes answer first; a path outside them is evaluated
|
|
232
|
+
* against every source, so a file that did not exist when the prefixes
|
|
233
|
+
* were computed gets the same answer the build would give. */
|
|
234
|
+
export function reachContains(rules: ReachRules, path: string): boolean {
|
|
235
|
+
const p = normRel(path);
|
|
236
|
+
for (const pre of rules.prefixes) {
|
|
237
|
+
if (pre === "." || pre === p || (pre.endsWith("/") && p.startsWith(pre))) return true;
|
|
238
|
+
}
|
|
239
|
+
return sourceOf(rules.sources, p) !== undefined;
|
|
240
|
+
}
|
|
241
|
+
|
|
242
|
+
/** The first source whose rules carry `path`, or `undefined`. */
|
|
243
|
+
export function sourceOf(sources: readonly ReachSource[], path: string): ReachSource | undefined {
|
|
244
|
+
const p = normRel(path);
|
|
245
|
+
for (const s of sources) {
|
|
246
|
+
if (s.context !== undefined) {
|
|
247
|
+
const ctx = normRel(s.context);
|
|
248
|
+
const prefix = ctx === "" ? "" : `${ctx}/`;
|
|
249
|
+
if (!prefix || p.startsWith(prefix)) {
|
|
250
|
+
const rel = p.slice(prefix.length);
|
|
251
|
+
if (rel === ".dockerignore" || rel === "Dockerfile" || !isExcluded(compileDockerignore(s.ignore ?? ""), rel)) return s;
|
|
252
|
+
}
|
|
253
|
+
}
|
|
254
|
+
for (const e of s.extraFiles ?? []) if (normRel(e) === p) return s;
|
|
255
|
+
for (const v of s.volumeSources ?? []) {
|
|
256
|
+
if (v.startsWith("/")) continue;
|
|
257
|
+
const src = normRel(v);
|
|
258
|
+
if (p === src || p.startsWith(`${src}/`)) return s;
|
|
259
|
+
}
|
|
260
|
+
}
|
|
261
|
+
return undefined;
|
|
262
|
+
}
|
|
263
|
+
|
|
264
|
+
/** The `reach.json` report text. */
|
|
265
|
+
export function renderReachRules(rules: ReachRules): string {
|
|
266
|
+
return JSON.stringify(rules);
|
|
162
267
|
}
|
|
163
268
|
|
|
164
269
|
/** An lcov document that names `paths` as files with no line data —
|
|
@@ -10,6 +10,8 @@ import {
|
|
|
10
10
|
splitUnion,
|
|
11
11
|
startOfNode,
|
|
12
12
|
truthyMessage,
|
|
13
|
+
CODE_RAW_FS,
|
|
14
|
+
fsUsageDiagnostics,
|
|
13
15
|
} from "./wrapper-rules.js";
|
|
14
16
|
|
|
15
17
|
describe("splitUnion", () => {
|
|
@@ -168,3 +170,27 @@ describe("startOfNode", () => {
|
|
|
168
170
|
expect(startOfNode("a;\n/* open", 2)).toBe(10);
|
|
169
171
|
});
|
|
170
172
|
});
|
|
173
|
+
|
|
174
|
+
describe("raw fs reads in tests (SPECTEST2003, advisory)", () => {
|
|
175
|
+
test("imports, requires, dynamic imports and Bun.file are found; other modules are not", () => {
|
|
176
|
+
const text = [
|
|
177
|
+
'import { readFileSync } from "node:fs";',
|
|
178
|
+
'import fsp from "fs/promises";',
|
|
179
|
+
'import path from "node:path";',
|
|
180
|
+
'const fs = require("fs");',
|
|
181
|
+
'const p = await import("node:fs/promises");',
|
|
182
|
+
'const t = await Bun.file("x").text();',
|
|
183
|
+
'import { z } from "zod";',
|
|
184
|
+
].join("\n");
|
|
185
|
+
const d = fsUsageDiagnostics("tests/a.ts", text);
|
|
186
|
+
expect(d.map((x) => [x.line, x.code, x.blocking])).toEqual([
|
|
187
|
+
[1, CODE_RAW_FS, false],
|
|
188
|
+
[2, CODE_RAW_FS, false],
|
|
189
|
+
[4, CODE_RAW_FS, false],
|
|
190
|
+
[5, CODE_RAW_FS, false],
|
|
191
|
+
[6, CODE_RAW_FS, false],
|
|
192
|
+
]);
|
|
193
|
+
expect(d[0]!.message).toContain("ctx.readProjectFile");
|
|
194
|
+
expect(fsUsageDiagnostics("tests/b.ts", 'import x from "./fs-helpers.js";\nconst fsLike = 1;')).toEqual([]);
|
|
195
|
+
});
|
|
196
|
+
});
|
|
@@ -56,6 +56,45 @@
|
|
|
56
56
|
// that happens to be called `Carrier<T>` would also be flagged — and would
|
|
57
57
|
// also be an object at runtime, so the finding stays true.
|
|
58
58
|
|
|
59
|
+
/**
|
|
60
|
+
* A test that opens a project file through `node:fs` (or `Bun.file`)
|
|
61
|
+
* instead of `ctx.readProjectFile`. **Advisory.** The subset selector
|
|
62
|
+
* learns which project files a test depends on from the reads the SDK
|
|
63
|
+
* serves (`project-files.ts`); a raw read is a dependence it never sees,
|
|
64
|
+
* so a change to that file can skip the test. The rule is textual — an
|
|
65
|
+
* import or `require` of the fs modules, or a `Bun.file(` call — and is
|
|
66
|
+
* applied to the test tree only, where the alternative exists. It turns
|
|
67
|
+
* blocking when the reach gate enforces (`SUBSET_RUNS.md`).
|
|
68
|
+
*/
|
|
69
|
+
export const CODE_RAW_FS = "SPECTEST2003";
|
|
70
|
+
|
|
71
|
+
const FS_MODULES = new Set(["fs", "node:fs", "fs/promises", "node:fs/promises"]);
|
|
72
|
+
|
|
73
|
+
/** The raw-fs findings of one test file, from its text alone. */
|
|
74
|
+
export function fsUsageDiagnostics(file: string, text: string): WrapperDiagnostic[] {
|
|
75
|
+
const out: WrapperDiagnostic[] = [];
|
|
76
|
+
const push = (pos: number, what: string) => {
|
|
77
|
+
const { line, column } = lineColumnAt(text, pos);
|
|
78
|
+
out.push({
|
|
79
|
+
file,
|
|
80
|
+
line,
|
|
81
|
+
column,
|
|
82
|
+
code: CODE_RAW_FS,
|
|
83
|
+
message:
|
|
84
|
+
`${what} reads project files behind the SDK's back; use ctx.readProjectFile (or ctx.readProjectFileBytes) ` +
|
|
85
|
+
"so the subset selector knows the test depends on the file",
|
|
86
|
+
blocking: false,
|
|
87
|
+
});
|
|
88
|
+
};
|
|
89
|
+
const importRe = /\b(?:import|export)\b[^;'"]*?\bfrom\s*['"]([^'"]+)['"]|\bimport\s*['"]([^'"]+)['"]|\brequire\(\s*['"]([^'"]+)['"]\s*\)|\bimport\(\s*['"]([^'"]+)['"]\s*\)/g;
|
|
90
|
+
for (const m of text.matchAll(importRe)) {
|
|
91
|
+
const spec = m[1] ?? m[2] ?? m[3] ?? m[4] ?? "";
|
|
92
|
+
if (FS_MODULES.has(spec)) push(m.index ?? 0, `an import of "${spec}"`);
|
|
93
|
+
}
|
|
94
|
+
for (const m of text.matchAll(/\bBun\.file\(/g)) push(m.index ?? 0, "Bun.file()");
|
|
95
|
+
return out;
|
|
96
|
+
}
|
|
97
|
+
|
|
59
98
|
/** A finding, shaped like the `TypecheckError` the report already carries. */
|
|
60
99
|
export interface WrapperDiagnostic {
|
|
61
100
|
/** Path relative to the app dir, matching the advisory diagnostics. */
|