@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 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, renderReach, renderReadsLcov } from "./harness/reach.js";
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
- return projectReach(files, sources);
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 files the
3611
- * harness read through the SDK since the previous capture, as whole
3612
- * lcov records. Present whenever some service opted in, so the two ride
3613
- * the same bundle as the services' reports.
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 reach = await computeProjectReach(all);
3620
- reports.push({ name: REACH_REPORT, content: renderReach(reach) });
3621
- console.log(`[coverage] project reach: ${reach.length} file(s) some build or volume can carry`);
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: "reads.lcov", content: renderReadsLcov(reads) });
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
- if (services.length === 0)
3643
- return [];
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 listing the project's reach, one project-relative
7
- * path per line (`harness/reach.ts`). */
8
- export declare const REACH_REPORT = "reach.paths";
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). */
@@ -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 listing the project's reach, one project-relative
40
- * path per line (`harness/reach.ts`). */
41
- export const REACH_REPORT = "reach.paths";
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))
@@ -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.paths` report: one path per line. */
34
- export declare function renderReach(paths: readonly string[]): string;
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. */
@@ -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 computes their union here — the project's
8
- // **reach** — and ships it with the bring-up capture (`reach.paths`, one
9
- // project-relative path per line, under the synthetic `__environment__`
10
- // service). The subset selector then treats a changed file outside the
11
- // reach as one that cannot affect the suite: `site/**` behind a
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
- /** The `reach.paths` report: one path per line. */
148
- export function renderReach(paths) {
149
- return paths.join("\n") + (paths.length ? "\n" : "");
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
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@specific.dev/spectest",
3
- "version": "0.86.2",
3
+ "version": "0.87.0",
4
4
  "description": "Spectest SDK for defining test environments in TypeScript.",
5
5
  "type": "module",
6
6
  "main": "./dist/index.js",
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, renderReach, renderReadsLcov, type ReachSource } from "./harness/reach.js";
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<string[]> {
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<string[]> {
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
- return projectReach(files, sources);
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 files the
4279
- * harness read through the SDK since the previous capture, as whole
4280
- * lcov records. Present whenever some service opted in, so the two ride
4281
- * the same bundle as the services' reports.
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 reach = await computeProjectReach(all);
4288
- reports.push({ name: REACH_REPORT, content: renderReach(reach) });
4289
- console.log(`[coverage] project reach: ${reach.length} file(s) some build or volume can carry`);
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: "reads.lcov", content: renderReadsLcov(reads) });
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
- if (services.length === 0) return [];
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
@@ -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 listing the project's reach, one project-relative
44
- * path per line (`harness/reach.ts`). */
45
- export const REACH_REPORT = "reach.paths";
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
+ });
@@ -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 computes their union here — the project's
8
- // **reach** — and ships it with the bring-up capture (`reach.paths`, one
9
- // project-relative path per line, under the synthetic `__environment__`
10
- // service). The subset selector then treats a changed file outside the
11
- // reach as one that cannot affect the suite: `site/**` behind a
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.paths` report: one path per line. */
160
- export function renderReach(paths: readonly string[]): string {
161
- return paths.join("\n") + (paths.length ? "\n" : "");
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. */