@specific.dev/spectest 0.85.1 → 0.86.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
@@ -23,7 +23,7 @@ import path from "node:path";
23
23
  import { pathToFileURL } from "node:url";
24
24
  import { ENVIRONMENT_CA_PATH as CA_PATH } from "./environment-ca.js";
25
25
  import { assert, expect, expectRaw, lowerIngress, dnsName as makeDnsDecl, isWildcard, validateServiceImage, proxy as makeProxyDecl, } from "./index.js";
26
- import { COVERAGE_CONTAINER_DIR, coverageBundleRef, coverageHostDir, encodeCoverageBundle, isEmptyReport, readCoverageDir, applyCoverageDelta, walkProjectFiles, } from "./harness/coverage.js";
26
+ import { COVERAGE_CONTAINER_DIR, coverageBundleRef, coverageHostDir, encodeCoverageBundle, isEmptyReport, readCoverageDir, applyCoverageDelta, walkProjectFiles, ENVIRONMENT_SERVICE, REACH_REPORT, } from "./harness/coverage.js";
27
27
  import { configureBrowserCoverage } from "./browser-coverage.js";
28
28
  import { INVENTORY_REPORT, NODE_COVERAGE_MARKERS_SUBDIR, NODE_COVERAGE_SCRIPTS_SUBDIR, applyCoverageAdapters, coverageAdapters, coverageReportsMode, validateCoverage, } from "./coverage.js";
29
29
  import { serviceForHost as hostToService } from "./harness/browser-coverage.js";
@@ -42,7 +42,8 @@ import { resolveHostPath as resolveVolumeHostPath, sanitizeSegment, } from "./ha
42
42
  import { pollUntilReady } from "./harness/ready-poll.js";
43
43
  import { runWrapperRules } from "./harness/wrapper-rules.js";
44
44
  import { cpus } from "node:os";
45
- import { APP_DIR, WORKSPACE, resolveExistingProjectPath, resolveProjectPath } from "./project-files.js";
45
+ import { APP_DIR, WORKSPACE, resolveExistingProjectPath, resolveProjectPath, takeProjectReads } from "./project-files.js";
46
+ import { projectReach, renderReach, renderReadsLcov } from "./harness/reach.js";
46
47
  import { installFetchWrapper, isTransportError } from "./harness/fetch.js";
47
48
  import { bindInstrumentationScope, createInstrumentationScope, runInstrumented, runUninstrumented, } from "./harness/instrumentation-scope.js";
48
49
  import { encodeRegistry } from "./harness/names-registry.js";
@@ -3516,6 +3517,61 @@ async function writeCoverageInventory(svc) {
3516
3517
  console.log(`[coverage] ${svc.name}: inventory of ${units.size} unit(s)`);
3517
3518
  return undefined;
3518
3519
  }
3520
+ /**
3521
+ * The project's reach (`harness/reach.ts`): every project file some
3522
+ * service's build context includes, some volume mounts, or a build
3523
+ * reads. Computed from the same resolution the builds use, once at the
3524
+ * bring-up capture.
3525
+ */
3526
+ async function computeProjectReach(all) {
3527
+ const files = await walkProjectFiles(WORKSPACE, [".git"]);
3528
+ const sources = [];
3529
+ for (const svc of all) {
3530
+ const src = {
3531
+ volumeSources: (svc.volumes ?? []).flatMap((v) => (v.source ? [v.source] : [])),
3532
+ };
3533
+ if (svc.image.type === "dockerfile") {
3534
+ try {
3535
+ const b = await resolveDockerfileBuild(svc);
3536
+ src.context = b.context;
3537
+ src.ignore = b.ignore;
3538
+ if (typeof svc.image.path === "string")
3539
+ src.extraFiles = [svc.image.path];
3540
+ }
3541
+ catch {
3542
+ // Unresolvable here: count the whole project as its context, the
3543
+ // conservative answer.
3544
+ src.context = ".";
3545
+ src.ignore = "";
3546
+ }
3547
+ }
3548
+ sources.push(src);
3549
+ }
3550
+ return projectReach(files, sources);
3551
+ }
3552
+ /**
3553
+ * The synthetic `__environment__` capture: what the environment itself
3554
+ * knows — the project's reach (bring-up only) and the project files the
3555
+ * harness read through the SDK since the previous capture, as whole
3556
+ * lcov records. Present whenever some service opted in, so the two ride
3557
+ * the same bundle as the services' reports.
3558
+ */
3559
+ async function captureEnvironmentCoverage(all, baseline) {
3560
+ const reports = [];
3561
+ if (baseline) {
3562
+ try {
3563
+ const reach = await computeProjectReach(all);
3564
+ reports.push({ name: REACH_REPORT, content: renderReach(reach) });
3565
+ console.log(`[coverage] project reach: ${reach.length} file(s) some build or volume can carry`);
3566
+ }
3567
+ catch (err) {
3568
+ console.warn(`[coverage] project reach not computed: ${err instanceof Error ? err.message : String(err)}`);
3569
+ }
3570
+ }
3571
+ const reads = takeProjectReads();
3572
+ reports.push({ name: "reads.lcov", content: renderReadsLcov(reads) });
3573
+ return { service: ENVIRONMENT_SERVICE, reports };
3574
+ }
3519
3575
  async function captureServiceCoverage(baseline = false) {
3520
3576
  const l = loaded;
3521
3577
  if (!l)
@@ -3525,8 +3581,12 @@ async function captureServiceCoverage(baseline = false) {
3525
3581
  byName.set(s.name, s);
3526
3582
  for (const [name, s] of RUNTIME_SERVICES)
3527
3583
  byName.set(name, s);
3528
- const services = [...byName.values()].filter((s) => s.coverage !== undefined);
3529
- return Promise.all(services.map(async (svc) => {
3584
+ const all = [...byName.values()];
3585
+ const services = all.filter((s) => s.coverage !== undefined);
3586
+ if (services.length === 0)
3587
+ return [];
3588
+ const environment = captureEnvironmentCoverage(all, baseline);
3589
+ const captures = await Promise.all(services.map(async (svc) => {
3530
3590
  const { error, deltaReports } = await runCoverageAdapters(svc);
3531
3591
  if (error)
3532
3592
  return { service: svc.name, reports: [], error };
@@ -3548,6 +3608,7 @@ async function captureServiceCoverage(baseline = false) {
3548
3608
  // files and V8 JSON pass through.
3549
3609
  return applyCoverageDelta(read, coverageReportsMode(svc.coverage), deltaReports);
3550
3610
  }));
3611
+ return [...captures, await environment];
3551
3612
  }
3552
3613
  /**
3553
3614
  * Mint a session id. `idScope` (the running test's case id; `"eval"`
@@ -5479,7 +5540,7 @@ export function harnessMethods(state) {
5479
5540
  // After bring-up the code that started the service has run, so a
5480
5541
  // report set that names no file at all is a misconfigured tool —
5481
5542
  // visible in the boot log, never a failure (there is no case).
5482
- if (!c.error && c.reports.length > 0 && c.reports.every((r) => isEmptyReport(r.content))) {
5543
+ if (c.service !== ENVIRONMENT_SERVICE && !c.error && c.reports.length > 0 && c.reports.every((r) => isEmptyReport(r.content))) {
5483
5544
  // eslint-disable-next-line no-console
5484
5545
  console.warn(`[coverage] service "${c.service}": every report is empty after bring-up — is coverage enabled at process start?`);
5485
5546
  }
@@ -1,5 +1,11 @@
1
1
  /** Where the directory is mounted inside an opted-in container. */
2
2
  export declare const COVERAGE_CONTAINER_DIR = "/spectest/coverage";
3
+ /** The synthetic service the environment's own reports ride under: the
4
+ * project's reach (bring-up) and the project files the harness read. */
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";
3
9
  /** Raw bytes of reports one service may ship per capture. Reports are
4
10
  * bounded by code size times chain depth, so anything past this is a
5
11
  * runaway (a tool writing a new file per request), not coverage. */
@@ -83,7 +89,7 @@ export declare function isEmptyReport(text: string): boolean;
83
89
  /** Every regular file under `root`, recursively, as `/`-separated paths
84
90
  * relative to it, with the directories no project file lives in
85
91
  * skipped. The harness's project file list for an adapter's inventory. */
86
- export declare function walkProjectFiles(root: string): Promise<string[]>;
92
+ export declare function walkProjectFiles(root: string, skipNames?: readonly string[]): Promise<string[]>;
87
93
  /** Every regular file under `dir`, recursively, as paths relative to it.
88
94
  * Hidden files are skipped (a tool's own bookkeeping — `.seq`, `.lock`,
89
95
  * a tmp file mid-write — is not a report). */
@@ -33,6 +33,12 @@ import path from "node:path";
33
33
  import { gzipSync } from "node:zlib";
34
34
  /** Where the directory is mounted inside an opted-in container. */
35
35
  export const COVERAGE_CONTAINER_DIR = "/spectest/coverage";
36
+ /** The synthetic service the environment's own reports ride under: the
37
+ * project's reach (bring-up) and the project files the harness read. */
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";
36
42
  /** Raw bytes of reports one service may ship per capture. Reports are
37
43
  * bounded by code size times chain depth, so anything past this is a
38
44
  * runaway (a tool writing a new file per request), not coverage. */
@@ -100,8 +106,8 @@ export function isEmptyReport(text) {
100
106
  /** Every regular file under `root`, recursively, as `/`-separated paths
101
107
  * relative to it, with the directories no project file lives in
102
108
  * skipped. The harness's project file list for an adapter's inventory. */
103
- export async function walkProjectFiles(root) {
104
- const skip = new Set(["node_modules", ".git", ".spectest", "target", "__pycache__", ".venv", "dist", "build", ".next"]);
109
+ export async function walkProjectFiles(root, skipNames = ["node_modules", ".git", ".spectest", "target", "__pycache__", ".venv", "dist", "build", ".next"]) {
110
+ const skip = new Set(skipNames);
105
111
  const out = [];
106
112
  async function walk(d, rel) {
107
113
  let entries;
@@ -0,0 +1,39 @@
1
+ /** One compiled pattern: its regex over the context-relative path, and
2
+ * whether it is an exception (`!pattern`). */
3
+ interface Pattern {
4
+ re: RegExp;
5
+ exception: boolean;
6
+ }
7
+ /** Compile a `.dockerignore` text. */
8
+ export declare function compileDockerignore(text: string): Pattern[];
9
+ /** Is `rel` (a file path relative to the context root) excluded from the
10
+ * context? The last matching pattern decides; a pattern matching any
11
+ * parent directory of the file counts as matching the file. */
12
+ export declare function isExcluded(patterns: Pattern[], rel: string): boolean;
13
+ /** One service's contribution to the reach. */
14
+ export interface ReachSource {
15
+ /** Build context directory, project-relative (`.` for the root); absent
16
+ * for a registry image. */
17
+ context?: string;
18
+ /** The composed `.dockerignore` text of the build. */
19
+ ignore?: string;
20
+ /** Files the build reads outside the context rules: the Dockerfile
21
+ * named by `path` (project-relative). */
22
+ extraFiles?: string[];
23
+ /** Volume sources, project-relative; an absolute source is not a
24
+ * project path and is skipped. */
25
+ volumeSources?: string[];
26
+ }
27
+ /**
28
+ * The project's reach: every project file (`files`, project-relative)
29
+ * some service's build context includes or some volume mounts, plus the
30
+ * Dockerfiles the builds read. Sorted, unique.
31
+ */
32
+ 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
+ /** An lcov document that names `paths` as files with no line data —
36
+ * "whole" records, which the index reads as every line unknown, so any
37
+ * change to the file selects the case that read it. */
38
+ export declare function renderReadsLcov(paths: readonly string[]): string;
39
+ export {};
@@ -0,0 +1,159 @@
1
+ // What a change to a project file can reach — the pure part.
2
+ //
3
+ // A project file affects a test only through one of three doors: it is in
4
+ // a service's **build context** (so an image may carry it), it is the
5
+ // **source of a volume** a service mounts, or a test **reads** it through
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
12
+ // `.dockerignore`, `.github/**` outside every context. The third door is
13
+ // recorded as it happens (`project-files.ts`, `takeProjectReads`).
14
+ //
15
+ // The context of a build is what `docker build` would send: every file
16
+ // under the context directory minus what the composed `.dockerignore`
17
+ // (the project's own rules plus the service's `exclude`) matches, with
18
+ // Docker's semantics — a pattern is matched against the path relative to
19
+ // the context root, `*` and `?` stay inside one segment, `**` spans
20
+ // segments, `!` re-includes, and a pattern that matches a directory
21
+ // excludes everything below it. When in doubt the answer is "reachable":
22
+ // over-approximating the reach only costs a full run.
23
+ /** Docker's `.dockerignore` pattern → a regex over a `/`-separated path,
24
+ * after moby's `patternmatcher`. */
25
+ function compilePattern(raw) {
26
+ let p = raw.trim().replace(/\\/g, "/");
27
+ while (p.startsWith("./"))
28
+ p = p.slice(2);
29
+ p = p.replace(/^\/+/, "").replace(/\/+$/, "");
30
+ let out = "^";
31
+ let i = 0;
32
+ while (i < p.length) {
33
+ const c = p[i];
34
+ if (c === "*") {
35
+ if (p[i + 1] === "*") {
36
+ // "**": swallow a trailing separator; at the end of the pattern
37
+ // it matches everything below, elsewhere zero or more segments.
38
+ i += 2;
39
+ if (p[i] === "/")
40
+ i += 1;
41
+ out += i >= p.length ? ".*" : "(?:.*/)?";
42
+ continue;
43
+ }
44
+ out += "[^/]*";
45
+ }
46
+ else if (c === "?") {
47
+ out += "[^/]";
48
+ }
49
+ else if (c === "[") {
50
+ const close = p.indexOf("]", i);
51
+ if (close < 0) {
52
+ out += "\\[";
53
+ }
54
+ else {
55
+ out += p.slice(i, close + 1);
56
+ i = close;
57
+ }
58
+ }
59
+ else if (".+()|{}$^".includes(c)) {
60
+ out += `\\${c}`;
61
+ }
62
+ else {
63
+ out += c;
64
+ }
65
+ i += 1;
66
+ }
67
+ out += "$";
68
+ return new RegExp(out);
69
+ }
70
+ /** Compile a `.dockerignore` text. */
71
+ export function compileDockerignore(text) {
72
+ const out = [];
73
+ for (const raw of text.split(/\r?\n/)) {
74
+ const line = raw.trim();
75
+ if (line.length === 0 || line.startsWith("#"))
76
+ continue;
77
+ const exception = line.startsWith("!");
78
+ const body = exception ? line.slice(1).trim() : line;
79
+ if (body.length === 0)
80
+ continue;
81
+ out.push({ re: compilePattern(body), exception });
82
+ }
83
+ return out;
84
+ }
85
+ /** Is `rel` (a file path relative to the context root) excluded from the
86
+ * context? The last matching pattern decides; a pattern matching any
87
+ * parent directory of the file counts as matching the file. */
88
+ export function isExcluded(patterns, rel) {
89
+ const candidates = [rel];
90
+ let cut = rel.lastIndexOf("/");
91
+ while (cut > 0) {
92
+ candidates.push(rel.slice(0, cut));
93
+ cut = rel.lastIndexOf("/", cut - 1);
94
+ }
95
+ let excluded = false;
96
+ for (const pat of patterns) {
97
+ if (candidates.some((c) => pat.re.test(c)))
98
+ excluded = !pat.exception;
99
+ }
100
+ return excluded;
101
+ }
102
+ function normRel(p) {
103
+ let s = p.replace(/\\/g, "/");
104
+ while (s.startsWith("./"))
105
+ s = s.slice(2);
106
+ s = s.replace(/^\/+/, "").replace(/\/+$/, "");
107
+ return s === "." ? "" : s;
108
+ }
109
+ /**
110
+ * The project's reach: every project file (`files`, project-relative)
111
+ * some service's build context includes or some volume mounts, plus the
112
+ * Dockerfiles the builds read. Sorted, unique.
113
+ */
114
+ export function projectReach(files, sources) {
115
+ const out = new Set();
116
+ for (const s of sources) {
117
+ if (s.context !== undefined) {
118
+ const ctx = normRel(s.context);
119
+ const prefix = ctx === "" ? "" : `${ctx}/`;
120
+ const patterns = compileDockerignore(s.ignore ?? "");
121
+ for (const f of files) {
122
+ if (prefix && !f.startsWith(prefix))
123
+ continue;
124
+ const rel = f.slice(prefix.length);
125
+ // `docker build` always sends the Dockerfile and .dockerignore.
126
+ if (rel === ".dockerignore" || rel === "Dockerfile" || !isExcluded(patterns, rel))
127
+ out.add(f);
128
+ }
129
+ }
130
+ for (const e of s.extraFiles ?? []) {
131
+ const rel = normRel(e);
132
+ if (rel)
133
+ out.add(rel);
134
+ }
135
+ for (const v of s.volumeSources ?? []) {
136
+ if (v.startsWith("/"))
137
+ continue;
138
+ const src = normRel(v);
139
+ for (const f of files) {
140
+ if (f === src || f.startsWith(`${src}/`))
141
+ out.add(f);
142
+ }
143
+ }
144
+ }
145
+ return [...out].sort();
146
+ }
147
+ /** The `reach.paths` report: one path per line. */
148
+ export function renderReach(paths) {
149
+ return paths.join("\n") + (paths.length ? "\n" : "");
150
+ }
151
+ /** An lcov document that names `paths` as files with no line data —
152
+ * "whole" records, which the index reads as every line unknown, so any
153
+ * change to the file selects the case that read it. */
154
+ export function renderReadsLcov(paths) {
155
+ let out = "TN:\n";
156
+ for (const p of paths)
157
+ out += `SF:${p}\nend_of_record\n`;
158
+ return out;
159
+ }
@@ -2,6 +2,9 @@
2
2
  export declare const WORKSPACE: string;
3
3
  /** The app dir; the user's `spectest/` sits directly under it. */
4
4
  export declare const APP_DIR: string;
5
+ /** Take (and clear) the project-relative paths resolved since the last
6
+ * take. Coverage attributes them to the case as whole files. */
7
+ export declare function takeProjectReads(): string[];
5
8
  /**
6
9
  * Absolute in-VM path for a path in the user's repo. Absolute input is
7
10
  * returned as-is. A relative path resolves against the project, preferring the
@@ -49,6 +49,18 @@ function envIgnored() {
49
49
  _envIgnored = new Set(paths);
50
50
  return _envIgnored;
51
51
  }
52
+ /** Project files resolved since the last take — what a test read
53
+ * through the SDK. Module memory: forks with the environment; the
54
+ * coverage capture takes and clears it after every case, so a forked
55
+ * child starts with what its parent's capture left, which is nothing. */
56
+ const PROJECT_READS = new Set();
57
+ /** Take (and clear) the project-relative paths resolved since the last
58
+ * take. Coverage attributes them to the case as whole files. */
59
+ export function takeProjectReads() {
60
+ const out = [...PROJECT_READS].sort();
61
+ PROJECT_READS.clear();
62
+ return out;
63
+ }
52
64
  /** Repo-relative form of `p`: no leading `./`, POSIX separators. */
53
65
  function relative(p) {
54
66
  return path.normalize(p).replace(/^\.\//, "").replace(/^\/+/, "");
@@ -66,6 +78,7 @@ export function resolveProjectPath(p) {
66
78
  if (path.isAbsolute(p))
67
79
  return p;
68
80
  const rel = relative(p);
81
+ PROJECT_READS.add(rel);
69
82
  if (rel.startsWith("spectest/")) {
70
83
  const app = path.join(APP_DIR, rel);
71
84
  if (existsSync(app))
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@specific.dev/spectest",
3
- "version": "0.85.1",
3
+ "version": "0.86.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
@@ -45,6 +45,8 @@ import {
45
45
  readCoverageDir,
46
46
  applyCoverageDelta,
47
47
  walkProjectFiles,
48
+ ENVIRONMENT_SERVICE,
49
+ REACH_REPORT,
48
50
  type CoverageBundleRef,
49
51
  type CoverageReport,
50
52
  type ServiceCoverageCapture,
@@ -97,7 +99,8 @@ import { pollUntilReady } from "./harness/ready-poll.js";
97
99
  import { runWrapperRules } from "./harness/wrapper-rules.js";
98
100
  import type { WrapperDiagnostic } from "./harness/wrapper-rules.js";
99
101
  import { cpus, tmpdir } from "node:os";
100
- import { APP_DIR, WORKSPACE, resolveExistingProjectPath, resolveProjectPath } from "./project-files.js";
102
+ import { APP_DIR, WORKSPACE, resolveExistingProjectPath, resolveProjectPath, takeProjectReads } from "./project-files.js";
103
+ import { projectReach, renderReach, renderReadsLcov, type ReachSource } from "./harness/reach.js";
101
104
  import { installFetchWrapper, isTransportError } from "./harness/fetch.js";
102
105
  import {
103
106
  bindInstrumentationScope,
@@ -4182,14 +4185,71 @@ async function writeCoverageInventory(svc: NamedService): Promise<string | undef
4182
4185
  return undefined;
4183
4186
  }
4184
4187
 
4188
+ /**
4189
+ * The project's reach (`harness/reach.ts`): every project file some
4190
+ * service's build context includes, some volume mounts, or a build
4191
+ * reads. Computed from the same resolution the builds use, once at the
4192
+ * bring-up capture.
4193
+ */
4194
+ async function computeProjectReach(all: NamedService[]): Promise<string[]> {
4195
+ const files = await walkProjectFiles(WORKSPACE, [".git"]);
4196
+ const sources: ReachSource[] = [];
4197
+ for (const svc of all) {
4198
+ const src: ReachSource = {
4199
+ volumeSources: (svc.volumes ?? []).flatMap((v) => (v.source ? [v.source] : [])),
4200
+ };
4201
+ if (svc.image.type === "dockerfile") {
4202
+ try {
4203
+ const b = await resolveDockerfileBuild(svc);
4204
+ src.context = b.context;
4205
+ src.ignore = b.ignore;
4206
+ if (typeof svc.image.path === "string") src.extraFiles = [svc.image.path];
4207
+ } catch {
4208
+ // Unresolvable here: count the whole project as its context, the
4209
+ // conservative answer.
4210
+ src.context = ".";
4211
+ src.ignore = "";
4212
+ }
4213
+ }
4214
+ sources.push(src);
4215
+ }
4216
+ return projectReach(files, sources);
4217
+ }
4218
+
4219
+ /**
4220
+ * The synthetic `__environment__` capture: what the environment itself
4221
+ * knows — the project's reach (bring-up only) and the project files the
4222
+ * harness read through the SDK since the previous capture, as whole
4223
+ * lcov records. Present whenever some service opted in, so the two ride
4224
+ * the same bundle as the services' reports.
4225
+ */
4226
+ async function captureEnvironmentCoverage(all: NamedService[], baseline: boolean): Promise<ServiceCoverageCapture> {
4227
+ const reports: CoverageReport[] = [];
4228
+ if (baseline) {
4229
+ try {
4230
+ const reach = await computeProjectReach(all);
4231
+ reports.push({ name: REACH_REPORT, content: renderReach(reach) });
4232
+ console.log(`[coverage] project reach: ${reach.length} file(s) some build or volume can carry`);
4233
+ } catch (err) {
4234
+ console.warn(`[coverage] project reach not computed: ${err instanceof Error ? err.message : String(err)}`);
4235
+ }
4236
+ }
4237
+ const reads = takeProjectReads();
4238
+ reports.push({ name: "reads.lcov", content: renderReadsLcov(reads) });
4239
+ return { service: ENVIRONMENT_SERVICE, reports };
4240
+ }
4241
+
4185
4242
  async function captureServiceCoverage(baseline = false): Promise<ServiceCoverageCapture[]> {
4186
4243
  const l = loaded;
4187
4244
  if (!l) return [];
4188
4245
  const byName = new Map<string, NamedService>();
4189
4246
  for (const s of namedServices(l.project.environment)) byName.set(s.name, s);
4190
4247
  for (const [name, s] of RUNTIME_SERVICES) byName.set(name, s);
4191
- const services = [...byName.values()].filter((s) => s.coverage !== undefined);
4192
- return Promise.all(
4248
+ const all = [...byName.values()];
4249
+ const services = all.filter((s) => s.coverage !== undefined);
4250
+ if (services.length === 0) return [];
4251
+ const environment = captureEnvironmentCoverage(all, baseline);
4252
+ const captures = await Promise.all(
4193
4253
  services.map(async (svc): Promise<ServiceCoverageCapture> => {
4194
4254
  const { error, deltaReports } = await runCoverageAdapters(svc);
4195
4255
  if (error) return { service: svc.name, reports: [], error };
@@ -4210,6 +4270,7 @@ async function captureServiceCoverage(baseline = false): Promise<ServiceCoverage
4210
4270
  return applyCoverageDelta(read, coverageReportsMode(svc.coverage), deltaReports);
4211
4271
  }),
4212
4272
  );
4273
+ return [...captures, await environment];
4213
4274
  }
4214
4275
 
4215
4276
  /** Per-Browser rrweb session shipped to the control plane. */
@@ -6586,7 +6647,7 @@ export function harnessMethods(state: RouteState): MethodTable {
6586
6647
  // After bring-up the code that started the service has run, so a
6587
6648
  // report set that names no file at all is a misconfigured tool —
6588
6649
  // visible in the boot log, never a failure (there is no case).
6589
- if (!c.error && c.reports.length > 0 && c.reports.every((r) => isEmptyReport(r.content))) {
6650
+ if (c.service !== ENVIRONMENT_SERVICE && !c.error && c.reports.length > 0 && c.reports.every((r) => isEmptyReport(r.content))) {
6590
6651
  // eslint-disable-next-line no-console
6591
6652
  console.warn(
6592
6653
  `[coverage] service "${c.service}": every report is empty after bring-up — is coverage enabled at process start?`,
@@ -36,6 +36,14 @@ import { gzipSync } from "node:zlib";
36
36
  /** Where the directory is mounted inside an opted-in container. */
37
37
  export const COVERAGE_CONTAINER_DIR = "/spectest/coverage";
38
38
 
39
+ /** The synthetic service the environment's own reports ride under: the
40
+ * project's reach (bring-up) and the project files the harness read. */
41
+ export const ENVIRONMENT_SERVICE = "__environment__";
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";
46
+
39
47
  /** Raw bytes of reports one service may ship per capture. Reports are
40
48
  * bounded by code size times chain depth, so anything past this is a
41
49
  * runaway (a tool writing a new file per request), not coverage. */
@@ -153,8 +161,11 @@ export function isEmptyReport(text: string): boolean {
153
161
  /** Every regular file under `root`, recursively, as `/`-separated paths
154
162
  * relative to it, with the directories no project file lives in
155
163
  * skipped. The harness's project file list for an adapter's inventory. */
156
- export async function walkProjectFiles(root: string): Promise<string[]> {
157
- const skip = new Set(["node_modules", ".git", ".spectest", "target", "__pycache__", ".venv", "dist", "build", ".next"]);
164
+ export async function walkProjectFiles(
165
+ root: string,
166
+ skipNames: readonly string[] = ["node_modules", ".git", ".spectest", "target", "__pycache__", ".venv", "dist", "build", ".next"],
167
+ ): Promise<string[]> {
168
+ const skip = new Set(skipNames);
158
169
  const out: string[] = [];
159
170
  async function walk(d: string, rel: string): Promise<void> {
160
171
  let entries;
@@ -0,0 +1,69 @@
1
+ import { describe, expect, test } from "bun:test";
2
+ import { compileDockerignore, isExcluded, projectReach, renderReadsLcov } from "./reach.js";
3
+
4
+ describe("dockerignore semantics", () => {
5
+ const ex = (text: string, rel: string) => isExcluded(compileDockerignore(text), rel);
6
+ test("a bare name matches only at the context root; **/ matches at any depth", () => {
7
+ expect(ex("node_modules", "node_modules/x.js")).toBe(true);
8
+ expect(ex("node_modules", "packages/a/node_modules/x.js")).toBe(false);
9
+ expect(ex("**/node_modules/**", "packages/a/node_modules/x.js")).toBe(true);
10
+ expect(ex("**/node_modules", "packages/a/node_modules/x.js")).toBe(true);
11
+ });
12
+ test("site/** excludes the tree; a directory match covers every file below", () => {
13
+ expect(ex("site/**", "site/app/page.tsx")).toBe(true);
14
+ expect(ex("site", "site/app/page.tsx")).toBe(true);
15
+ expect(ex("site/**", "sites/x")).toBe(false);
16
+ expect(ex("docs", "docs.md")).toBe(false);
17
+ });
18
+ test("* and ? stay within a segment; [] classes; ! re-includes, last match wins", () => {
19
+ expect(ex("*.md", "README.md")).toBe(true);
20
+ expect(ex("*.md", "docs/README.md")).toBe(false);
21
+ expect(ex("**/*.md", "docs/README.md")).toBe(true);
22
+ expect(ex("a?c", "abc")).toBe(true);
23
+ expect(ex("a?c", "a/c")).toBe(false);
24
+ expect(ex("[ab]x", "ax")).toBe(true);
25
+ expect(ex("docs/**\n!docs/keep.md", "docs/keep.md")).toBe(false);
26
+ expect(ex("docs/**\n!docs/keep.md", "docs/other.md")).toBe(true);
27
+ expect(ex("!docs/keep.md\ndocs/**", "docs/keep.md")).toBe(true);
28
+ });
29
+ test("comments, blanks, ./ and leading slashes", () => {
30
+ expect(ex("# a comment\n\n./site/**\n", "site/x")).toBe(true);
31
+ expect(ex("/site/**", "site/x")).toBe(true);
32
+ });
33
+ });
34
+
35
+ describe("projectReach", () => {
36
+ const files = [
37
+ "package.json",
38
+ "packages/api/src/a.ts",
39
+ "packages/api/Dockerfile",
40
+ "packages/dashboard/src/b.tsx",
41
+ "site/app/page.tsx",
42
+ ".github/workflows/ci.yml",
43
+ "docs/x.md",
44
+ "spectest/index.ts",
45
+ "data/seed.sql",
46
+ ];
47
+ test("a root context minus its ignore, a narrowed context, a Dockerfile path, a volume source", () => {
48
+ const reach = projectReach(files, [
49
+ { context: ".", ignore: "site/**\ndocs/**\n", extraFiles: ["packages/api/Dockerfile"] },
50
+ { context: "packages/dashboard", ignore: "" },
51
+ { volumeSources: ["data", "/var/run/docker.sock"] },
52
+ ]);
53
+ expect(reach).toEqual([
54
+ ".github/workflows/ci.yml",
55
+ "data/seed.sql",
56
+ "package.json",
57
+ "packages/api/Dockerfile",
58
+ "packages/api/src/a.ts",
59
+ "packages/dashboard/src/b.tsx",
60
+ "spectest/index.ts",
61
+ ]);
62
+ });
63
+ test("a narrowed context alone reaches only its subtree", () => {
64
+ expect(projectReach(files, [{ context: "packages/dashboard", ignore: "" }])).toEqual(["packages/dashboard/src/b.tsx"]);
65
+ });
66
+ test("renderReadsLcov names whole files", () => {
67
+ expect(renderReadsLcov(["docs/x.md"])).toBe("TN:\nSF:docs/x.md\nend_of_record\n");
68
+ });
69
+ });
@@ -0,0 +1,171 @@
1
+ // What a change to a project file can reach — the pure part.
2
+ //
3
+ // A project file affects a test only through one of three doors: it is in
4
+ // a service's **build context** (so an image may carry it), it is the
5
+ // **source of a volume** a service mounts, or a test **reads** it through
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
12
+ // `.dockerignore`, `.github/**` outside every context. The third door is
13
+ // recorded as it happens (`project-files.ts`, `takeProjectReads`).
14
+ //
15
+ // The context of a build is what `docker build` would send: every file
16
+ // under the context directory minus what the composed `.dockerignore`
17
+ // (the project's own rules plus the service's `exclude`) matches, with
18
+ // Docker's semantics — a pattern is matched against the path relative to
19
+ // the context root, `*` and `?` stay inside one segment, `**` spans
20
+ // segments, `!` re-includes, and a pattern that matches a directory
21
+ // excludes everything below it. When in doubt the answer is "reachable":
22
+ // over-approximating the reach only costs a full run.
23
+
24
+ /** One compiled pattern: its regex over the context-relative path, and
25
+ * whether it is an exception (`!pattern`). */
26
+ interface Pattern {
27
+ re: RegExp;
28
+ exception: boolean;
29
+ }
30
+
31
+ /** Docker's `.dockerignore` pattern → a regex over a `/`-separated path,
32
+ * after moby's `patternmatcher`. */
33
+ function compilePattern(raw: string): RegExp {
34
+ let p = raw.trim().replace(/\\/g, "/");
35
+ while (p.startsWith("./")) p = p.slice(2);
36
+ p = p.replace(/^\/+/, "").replace(/\/+$/, "");
37
+ let out = "^";
38
+ let i = 0;
39
+ while (i < p.length) {
40
+ const c = p[i]!;
41
+ if (c === "*") {
42
+ if (p[i + 1] === "*") {
43
+ // "**": swallow a trailing separator; at the end of the pattern
44
+ // it matches everything below, elsewhere zero or more segments.
45
+ i += 2;
46
+ if (p[i] === "/") i += 1;
47
+ out += i >= p.length ? ".*" : "(?:.*/)?";
48
+ continue;
49
+ }
50
+ out += "[^/]*";
51
+ } else if (c === "?") {
52
+ out += "[^/]";
53
+ } else if (c === "[") {
54
+ const close = p.indexOf("]", i);
55
+ if (close < 0) {
56
+ out += "\\[";
57
+ } else {
58
+ out += p.slice(i, close + 1);
59
+ i = close;
60
+ }
61
+ } else if (".+()|{}$^".includes(c)) {
62
+ out += `\\${c}`;
63
+ } else {
64
+ out += c;
65
+ }
66
+ i += 1;
67
+ }
68
+ out += "$";
69
+ return new RegExp(out);
70
+ }
71
+
72
+ /** Compile a `.dockerignore` text. */
73
+ export function compileDockerignore(text: string): Pattern[] {
74
+ const out: Pattern[] = [];
75
+ for (const raw of text.split(/\r?\n/)) {
76
+ const line = raw.trim();
77
+ if (line.length === 0 || line.startsWith("#")) continue;
78
+ const exception = line.startsWith("!");
79
+ const body = exception ? line.slice(1).trim() : line;
80
+ if (body.length === 0) continue;
81
+ out.push({ re: compilePattern(body), exception });
82
+ }
83
+ return out;
84
+ }
85
+
86
+ /** Is `rel` (a file path relative to the context root) excluded from the
87
+ * context? The last matching pattern decides; a pattern matching any
88
+ * parent directory of the file counts as matching the file. */
89
+ export function isExcluded(patterns: Pattern[], rel: string): boolean {
90
+ const candidates: string[] = [rel];
91
+ let cut = rel.lastIndexOf("/");
92
+ while (cut > 0) {
93
+ candidates.push(rel.slice(0, cut));
94
+ cut = rel.lastIndexOf("/", cut - 1);
95
+ }
96
+ let excluded = false;
97
+ for (const pat of patterns) {
98
+ if (candidates.some((c) => pat.re.test(c))) excluded = !pat.exception;
99
+ }
100
+ return excluded;
101
+ }
102
+
103
+ /** One service's contribution to the reach. */
104
+ export interface ReachSource {
105
+ /** Build context directory, project-relative (`.` for the root); absent
106
+ * for a registry image. */
107
+ context?: string;
108
+ /** The composed `.dockerignore` text of the build. */
109
+ ignore?: string;
110
+ /** Files the build reads outside the context rules: the Dockerfile
111
+ * named by `path` (project-relative). */
112
+ extraFiles?: string[];
113
+ /** Volume sources, project-relative; an absolute source is not a
114
+ * project path and is skipped. */
115
+ volumeSources?: string[];
116
+ }
117
+
118
+ function normRel(p: string): string {
119
+ let s = p.replace(/\\/g, "/");
120
+ while (s.startsWith("./")) s = s.slice(2);
121
+ s = s.replace(/^\/+/, "").replace(/\/+$/, "");
122
+ return s === "." ? "" : s;
123
+ }
124
+
125
+ /**
126
+ * The project's reach: every project file (`files`, project-relative)
127
+ * some service's build context includes or some volume mounts, plus the
128
+ * Dockerfiles the builds read. Sorted, unique.
129
+ */
130
+ export function projectReach(files: readonly string[], sources: readonly ReachSource[]): string[] {
131
+ const out = new Set<string>();
132
+ for (const s of sources) {
133
+ if (s.context !== undefined) {
134
+ const ctx = normRel(s.context);
135
+ const prefix = ctx === "" ? "" : `${ctx}/`;
136
+ const patterns = compileDockerignore(s.ignore ?? "");
137
+ for (const f of files) {
138
+ if (prefix && !f.startsWith(prefix)) continue;
139
+ const rel = f.slice(prefix.length);
140
+ // `docker build` always sends the Dockerfile and .dockerignore.
141
+ if (rel === ".dockerignore" || rel === "Dockerfile" || !isExcluded(patterns, rel)) out.add(f);
142
+ }
143
+ }
144
+ for (const e of s.extraFiles ?? []) {
145
+ const rel = normRel(e);
146
+ if (rel) out.add(rel);
147
+ }
148
+ for (const v of s.volumeSources ?? []) {
149
+ if (v.startsWith("/")) continue;
150
+ const src = normRel(v);
151
+ for (const f of files) {
152
+ if (f === src || f.startsWith(`${src}/`)) out.add(f);
153
+ }
154
+ }
155
+ }
156
+ return [...out].sort();
157
+ }
158
+
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" : "");
162
+ }
163
+
164
+ /** An lcov document that names `paths` as files with no line data —
165
+ * "whole" records, which the index reads as every line unknown, so any
166
+ * change to the file selects the case that read it. */
167
+ export function renderReadsLcov(paths: readonly string[]): string {
168
+ let out = "TN:\n";
169
+ for (const p of paths) out += `SF:${p}\nend_of_record\n`;
170
+ return out;
171
+ }
@@ -52,6 +52,20 @@ function envIgnored(): Set<string> {
52
52
  return _envIgnored;
53
53
  }
54
54
 
55
+ /** Project files resolved since the last take — what a test read
56
+ * through the SDK. Module memory: forks with the environment; the
57
+ * coverage capture takes and clears it after every case, so a forked
58
+ * child starts with what its parent's capture left, which is nothing. */
59
+ const PROJECT_READS = new Set<string>();
60
+
61
+ /** Take (and clear) the project-relative paths resolved since the last
62
+ * take. Coverage attributes them to the case as whole files. */
63
+ export function takeProjectReads(): string[] {
64
+ const out = [...PROJECT_READS].sort();
65
+ PROJECT_READS.clear();
66
+ return out;
67
+ }
68
+
55
69
  /** Repo-relative form of `p`: no leading `./`, POSIX separators. */
56
70
  function relative(p: string): string {
57
71
  return path.normalize(p).replace(/^\.\//, "").replace(/^\/+/, "");
@@ -69,6 +83,7 @@ function relative(p: string): string {
69
83
  export function resolveProjectPath(p: string): string {
70
84
  if (path.isAbsolute(p)) return p;
71
85
  const rel = relative(p);
86
+ PROJECT_READS.add(rel);
72
87
  if (rel.startsWith("spectest/")) {
73
88
  const app = path.join(APP_DIR, rel);
74
89
  if (existsSync(app)) return app;