@specific.dev/spectest 0.86.1 → 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.
@@ -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. */