@specific.dev/spectest 0.131.21 → 0.132.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.
Files changed (49) hide show
  1. package/dist/change-rules.d.ts +45 -0
  2. package/dist/change-rules.js +179 -0
  3. package/dist/components/k3s-coverage.js +4 -4
  4. package/dist/components/supabase-coverage.js +12 -0
  5. package/dist/components/supabase.js +25 -12
  6. package/dist/coverage-bun.js +2 -1
  7. package/dist/coverage-next.d.ts +5 -0
  8. package/dist/coverage-next.js +27 -3
  9. package/dist/coverage-rust.d.ts +2 -0
  10. package/dist/coverage-rust.js +20 -0
  11. package/dist/coverage.d.ts +27 -3
  12. package/dist/coverage.js +68 -15
  13. package/dist/daemon.js +13 -10
  14. package/dist/harness/component-images.d.ts +5 -0
  15. package/dist/harness/component-images.js +15 -2
  16. package/dist/harness/coverage-target.d.ts +2 -0
  17. package/dist/harness/coverage-target.js +3 -2
  18. package/dist/harness/reach.d.ts +6 -1
  19. package/dist/harness/reach.js +8 -34
  20. package/dist/harness/shadow-builder.d.ts +2 -1
  21. package/dist/harness/shadow-builder.js +6 -2
  22. package/dist/index.d.ts +8 -0
  23. package/dist/index.js +5 -0
  24. package/package.json +1 -1
  25. package/src/change-rules.fixtures.json +35 -0
  26. package/src/change-rules.test.ts +125 -0
  27. package/src/change-rules.ts +147 -0
  28. package/src/components/k3s-coverage.test.ts +32 -0
  29. package/src/components/k3s-coverage.ts +4 -4
  30. package/src/components/supabase-coverage.ts +12 -0
  31. package/src/components/supabase-dir.test.ts +26 -0
  32. package/src/components/supabase-functions-reach.test.ts +41 -1
  33. package/src/components/supabase-migrations.test.ts +1 -1
  34. package/src/components/supabase.ts +26 -12
  35. package/src/coverage-bun.ts +2 -1
  36. package/src/coverage-next.ts +29 -3
  37. package/src/coverage-rust.test.ts +34 -0
  38. package/src/coverage-rust.ts +22 -0
  39. package/src/coverage.test.ts +2 -1
  40. package/src/coverage.ts +76 -7
  41. package/src/daemon.ts +14 -9
  42. package/src/harness/component-images.test.ts +42 -0
  43. package/src/harness/component-images.ts +20 -3
  44. package/src/harness/coverage-target.ts +5 -2
  45. package/src/harness/reach.test.ts +40 -6
  46. package/src/harness/reach.ts +13 -3
  47. package/src/harness/shadow-builder.test.ts +8 -0
  48. package/src/harness/shadow-builder.ts +7 -2
  49. package/src/index.ts +11 -0
@@ -1,10 +1,13 @@
1
1
  // Next policy lives in the SDK. The collector/indexer only see ordinary
2
2
  // Node/browser reports and generic, report-scoped source metadata.
3
3
  import path from "node:path";
4
+ import { NEXT_CHANGE_RULES, type ChangeRule } from "./change-rules.js";
4
5
  import { JAVASCRIPT_GENERATED_SOURCES } from "./coverage-javascript.js";
5
6
  import { node, browser, type CoverageAdapter, type CoverageSourceContext, type CoverageSourceOptions } from "./coverage.js";
6
7
 
7
8
  export interface NextCoverageOptions extends CoverageSourceOptions {
9
+ /** Replace this adapter's defaults, or false to disable them. */
10
+ changeRules?: readonly ChangeRule[] | false;
8
11
  /** Repository-relative directory represented by the build's source-map root.
9
12
  * Defaults to "." (repository root). Set explicitly for a nested build root. */
10
13
  sourceRoot?: string;
@@ -108,7 +111,10 @@ if (require.main === module) {
108
111
  walk(path.join(dist, "server"), true);
109
112
  walk(path.join(dist, "static"), false);
110
113
  if (!serverMaps || !browserMaps) throw new Error("Next coverage needs server and browser .js.map files in the runtime image. Enable productionBrowserSourceMaps and experimental.serverSourceMaps in Next config, and copy .next/server and .next/static maps into the runtime image, including Edge maps.");
111
- process.stdout.write(JSON.stringify({generated:launcher(m), serverMaps, browserMaps}));
114
+ const appRoot = typeof m.relativeAppDir === "string" ? m.relativeAppDir :
115
+ (typeof m.appDir === "string" && typeof m.config.turbopack?.root === "string" ? path.relative(m.config.turbopack.root, m.appDir) : "");
116
+ process.stdout.write(JSON.stringify({generated:launcher(m), serverMaps, browserMaps,
117
+ pageExtensions:m.config.pageExtensions || ["tsx","ts","jsx","js"], appRoot}));
112
118
  } catch (e) { console.error("Next coverage: " + e.message); process.exitCode = 1; }
113
119
  }
114
120
  `;
@@ -117,6 +123,8 @@ export interface NextBuildMetadata {
117
123
  generated: string | null;
118
124
  serverMaps: number;
119
125
  browserMaps: number;
126
+ pageExtensions?: string[];
127
+ appRoot?: string;
120
128
  }
121
129
 
122
130
  const GENERATED_SOURCES = [
@@ -138,7 +146,7 @@ export function next(options: NextCoverageOptions = {}): CoverageAdapter {
138
146
  for (const [key, value] of Object.entries({ appDir: options.appDir, distDir: options.distDir })) {
139
147
  if (value !== undefined && (!value || path.posix.isAbsolute(value) || value.split("/").some(p => p === "..") || /[\\\0\r\n]/.test(value))) throw new Error(`coverage.next ${key} must be a relative runtime directory`);
140
148
  }
141
- const server = node(), client = browser();
149
+ const server = node({ sourceRoot, changeRules: options.changeRules === undefined ? NEXT_CHANGE_RULES : options.changeRules }), client = browser();
142
150
  const cache = new Map<string, Promise<NextBuildMetadata>>();
143
151
  const metadata = (ctx: Pick<CoverageSourceContext, "service" | "exec">) => {
144
152
  let value = cache.get(ctx.service);
@@ -151,7 +159,25 @@ export function next(options: NextCoverageOptions = {}): CoverageAdapter {
151
159
  };
152
160
  return {
153
161
  defaultExcludeSources: JAVASCRIPT_GENERATED_SOURCES,
154
- name: "next", reports: "delta", explains: server.explains,
162
+ name: "next", reports: "delta", explains: server.explains, changeRules: server.changeRules?.map(rule => ({...rule, root: rule.root ?? sourceRoot})),
163
+ ...(options.changeRules === undefined ? { async resolveChangeRules(ctx: CoverageSourceContext) {
164
+ const m = await metadata(ctx);
165
+ const extensions = m.pageExtensions ?? ["tsx", "ts", "jsx", "js"];
166
+ if (!Array.isArray(extensions) || extensions.some(ext => typeof ext !== "string" || !ext || /[^a-zA-Z0-9._-]/.test(ext))) throw new Error("invalid Next pageExtensions metadata");
167
+ const appRoot = m.appRoot || ".";
168
+ if (appRoot !== "." && (appRoot.includes("\\") || appRoot.split("/").some(part => !part || part === "." || part === ".."))) throw new Error("invalid Next app root metadata");
169
+ const root = path.posix.join(sourceRoot, appRoot);
170
+ // Remove the static conventional route allowance; the build's extensions
171
+ // determine which files are actually routes (including custom test-like names).
172
+ const rules = NEXT_CHANGE_RULES.filter(rule => !/^(pages|src\/pages|app|src\/app)\//.test(rule.pattern));
173
+ const routes = extensions.flatMap(ext => [
174
+ ...["pages", "src/pages"].map(dir => ({root,pattern:`${dir}/**/*.${ext}`,action:"coverage" as const})),
175
+ ...["app", "src/app"].flatMap(dir => ["page", "route"].map(name => ({root,pattern:`${dir}/**/${name}.${ext}`,action:"coverage" as const}))),
176
+ ]);
177
+ // Shared entries still require full runs, even when they are also routes.
178
+ const full = rules.filter(rule => rule.action === "full" && rule.pattern !== "**/migrations/**");
179
+ return [...rules.map(rule => ({...rule,root:rule.root ?? sourceRoot})), ...routes, ...full.map(rule => ({...rule,root:rule.root ?? sourceRoot}))];
180
+ }} : {}),
155
181
  ownsReport: name => server.ownsReport!(name) || client.ownsReport!(name),
156
182
  configure(svc, info) {
157
183
  const configured = server.configure!(svc, info);
@@ -5,6 +5,8 @@ import { execFileSync } from "node:child_process";
5
5
  import path from "node:path";
6
6
  import os from "node:os";
7
7
  import { rust } from "./coverage-rust";
8
+ import { resolveCoverageSourceOptions, serviceChangePolicy } from "./coverage";
9
+ import { evaluateChangePolicy } from "./change-rules";
8
10
  import { captureCoverageTarget } from "./harness/coverage-target";
9
11
  import { encodeCoverageBundle } from "./harness/coverage";
10
12
 
@@ -72,3 +74,35 @@ test.skipIf(!installedTools)("Rust real LLVM reports capture live and exited pro
72
74
  expect((await capture()).error).toContain("without a final report");
73
75
  } finally { child?.kill(); await fs.rm(root, { recursive: true, force: true }); }
74
76
  }, 30000);
77
+
78
+
79
+ test("Rust workspace rules discover crate-local tests and binaries without running Cargo", async () => {
80
+ const files = [
81
+ "workspace/Cargo.toml", "workspace/crates/a/Cargo.toml", "workspace/crates/b/Cargo.toml",
82
+ "workspace/crates/a/tests/fixture/Cargo.toml", "outside/Cargo.toml",
83
+ ];
84
+ const context = {service:"rust", signal:AbortSignal.timeout(1000),
85
+ projectFiles:async()=>files,readProjectFile:async()=>null,
86
+ exec:async()=>{throw new Error("crate policy must not execute Cargo");}};
87
+ for (const explicitRoot of [true, false]) {
88
+ const adapter = rust(explicitRoot ? {sourceRoot:"workspace"} : {});
89
+ const svc = {coverage:{sourceRoot:"workspace",adapters:[adapter]}};
90
+ await resolveCoverageSourceOptions([adapter],context,{sourceRoot:"workspace"});
91
+ const policy = serviceChangePolicy(svc,{requireResolved:true});
92
+ for (const crate of ["a","b"]) {
93
+ const root = `workspace/crates/${crate}`;
94
+ for (const file of ["tests/integration.rs", "tests/data.json", "benches/perf.rs"]) expect(evaluateChangePolicy(policy,`${root}/${file}`)).toBe("ignore");
95
+ for (const file of ["src/lib.rs", "src/tests.rs", "examples/server.rs"]) expect(evaluateChangePolicy(policy,`${root}/${file}`)).toBe("coverage");
96
+ expect(evaluateChangePolicy(policy,`${root}/src/bin/server.rs`)).toBe("full");
97
+ expect(evaluateChangePolicy(policy,`${root}/src/bin/README.md`)).toBe("ignore");
98
+ }
99
+ expect(evaluateChangePolicy(policy,"workspace/crates/a/tests/fixture/src/bin/main.rs")).toBe("ignore");
100
+ expect(evaluateChangePolicy(policy,"workspace/not-a-crate/tests/helper.rs")).toBe("coverage");
101
+ expect(evaluateChangePolicy(policy,"workspace/not-a-crate/benches/data.json")).toBeUndefined();
102
+ expect(evaluateChangePolicy(policy,"outside/tests/helper.rs")).toBeUndefined();
103
+ }
104
+ for (const changeRules of [false, []] as const) {
105
+ const adapter = rust({changeRules});
106
+ expect(adapter.resolveChangeRules).toBeUndefined();
107
+ }
108
+ });
@@ -1,9 +1,11 @@
1
+ import { RUST_CHANGE_RULES, COMMON_CHANGE_RULES, validateChangeRules, type ChangeRule } from "./change-rules.js";
1
2
  import { promises as fs, readFileSync } from "node:fs";
2
3
  import path from "node:path";
3
4
  import { connect } from "node:net";
4
5
  import type { CoverageAdapter, CoverageSourceOptions } from "./coverage.js";
5
6
 
6
7
  export interface RustCoverageOptions extends CoverageSourceOptions {
8
+ changeRules?: readonly ChangeRule[] | false;
7
9
  /** Only for idle command containers where no Rust process has run yet. */
8
10
  allowEmpty?: boolean;
9
11
  /** Compiler workspace directory, when different from the runtime workdir.
@@ -50,6 +52,8 @@ export async function flushCoverageSockets(dir: string, prefix: string, signal:
50
52
  * The helper exports cumulative per-process LCOV before exit and on request;
51
53
  * no compiler tools or binaries need to be copied out of terminated pods. */
52
54
  export function rust(options: RustCoverageOptions = {}): CoverageAdapter {
55
+ const rules = options.changeRules === undefined ? RUST_CHANGE_RULES : options.changeRules;
56
+ if (rules !== false) validateChangeRules(rules);
53
57
  const { sourceRoot, excludeSources, llvmCov, llvmProfdata, ignoreFilenameRegex, buildRoot } = options;
54
58
  if (buildRoot !== undefined && (!buildRoot.startsWith("/") || buildRoot === "/" || /[\0\r\n]/.test(buildRoot) || buildRoot.split("/").includes(".."))) throw new Error("Rust buildRoot must be an absolute compiler workspace directory");
55
59
  for (const value of [llvmCov, llvmProfdata, ignoreFilenameRegex]) {
@@ -57,6 +61,24 @@ export function rust(options: RustCoverageOptions = {}): CoverageAdapter {
57
61
  }
58
62
  return {
59
63
  name: "rust", reports: "cumulative",
64
+ changeRules: rules === false ? [] : rules.map(rule => ({...rule})),
65
+ ...(options.changeRules === undefined ? { async resolveChangeRules(ctx) {
66
+ const root = sourceRoot ?? ctx.sourceRoot ?? ".";
67
+ const prefix = root === "." ? "" : root + "/";
68
+ const files = await ctx.projectFiles();
69
+ const crates = [...new Set(files.filter(file =>
70
+ file.startsWith(prefix) && (file === "Cargo.toml" || file.endsWith("/Cargo.toml"))
71
+ ).map(file => path.posix.dirname(file)))].sort();
72
+ // A workspace can contain many packages; their canonical test directories
73
+ // are relative to each Cargo.toml, not just the adapter's source root.
74
+ // Test fixture packages stay ignored even when they contain binary targets.
75
+ return [
76
+ ...RUST_CHANGE_RULES.map(rule => ({...rule,root:rule.root ?? root})),
77
+ ...crates.map(root => ({root,pattern:"src/bin/**",action:"full" as const})),
78
+ ...crates.flatMap(root => ["tests/**", "benches/**"].map(pattern => ({root,pattern,action:"ignore" as const}))),
79
+ ...COMMON_CHANGE_RULES.map(rule => ({...rule,root})),
80
+ ];
81
+ }} satisfies Partial<CoverageAdapter> : {}),
60
82
  sourceOptions: { ...(sourceRoot !== undefined ? { sourceRoot } : {}), ...(excludeSources ? { excludeSources } : {}) },
61
83
  ownsReport: name => /^rust-.*\.lcov$/.test(name),
62
84
  configure: service => ({ ...service, env: { ...service.env,
@@ -12,6 +12,7 @@ import {
12
12
  NODE_COVERAGE_MARKERS_SUBDIR,
13
13
  NODE_COVERAGE_SOCKET_PREFIX,
14
14
  NODE_EXPLAINS,
15
+ NODE_CHANGE_RULES,
15
16
  NODE_INVENTORY_SCRIPT,
16
17
  npmManifestUnit,
17
18
  applyCoverageAdapters,
@@ -112,7 +113,7 @@ describe("applyCoverageAdapters", () => {
112
113
  expect(typeof list[0]?.capture).toBe("function");
113
114
  expect(typeof list[1]?.load).toBe("function");
114
115
  expect(JSON.parse(JSON.stringify(out)).coverage).toEqual({
115
- adapters: [{ adapter: "node", defaultExcludeSources: [...JAVASCRIPT_GENERATED_SOURCES] }, { adapter: "browser", defaultExcludeSources: [...JAVASCRIPT_GENERATED_SOURCES] }],
116
+ adapters: [{ adapter: "node", changeRules: NODE_CHANGE_RULES, defaultExcludeSources: [...JAVASCRIPT_GENERATED_SOURCES] }, { adapter: "browser", changeRules: NODE_CHANGE_RULES, defaultExcludeSources: [...JAVASCRIPT_GENERATED_SOURCES] }],
116
117
  explains: [...NODE_EXPLAINS],
117
118
  });
118
119
  });
package/src/coverage.ts CHANGED
@@ -29,6 +29,8 @@
29
29
 
30
30
  import { createHash } from "node:crypto";
31
31
  import { JAVASCRIPT_GENERATED_SOURCES } from "./coverage-javascript.js";
32
+ import { NODE_CHANGE_RULES, BROWSER_CHANGE_RULES, resolveChangePolicy, validateChangeRules, compileChangePolicy, type ChangeRule, type ChangePolicy } from "./change-rules.js";
33
+ export * from "./change-rules.js";
32
34
 
33
35
  import type { ServiceConfig } from "./index.js";
34
36
  import { harvestAllBrowserCoverage, takeBrowserCoverageReport } from "./browser-coverage.js";
@@ -80,13 +82,17 @@ export interface CoverageLoadContext {
80
82
  * of the manifests that declare them (see {@link CoverageAdapter.inventory}).
81
83
  */
82
84
  export interface CoverageInventoryContext {
85
+ /** Effective repository-relative source root for build-specific policy resolution. */
86
+ sourceRoot?: string;
87
+ /** Selection overrides of the owning service, forwarded by container wrappers. */
88
+ changeRules?: readonly ChangeRule[] | false;
83
89
  /** The services-map key. */
84
90
  service: string;
85
91
  /** Run a shell command (`sh -c`) inside the container. Rejects on a
86
92
  * non-zero exit with the command's output in the message. */
87
93
  exec(command: string): Promise<{ stdout: string; stderr: string }>;
88
- /** Every file of the uploaded project, project-relative, `/`-separated,
89
- * `node_modules`, `.git` and the like skipped. */
94
+ /** Project-relative files; inventory calls omit paths ignored by effective
95
+ * change policy, as well as node_modules, .git and other upload exclusions. */
90
96
  projectFiles(): Promise<string[]>;
91
97
  /** One project file's text (project-relative path); `null` when absent. */
92
98
  readProjectFile(path: string): Promise<string | null>;
@@ -127,6 +133,12 @@ export type CoverageSourceContext = CoverageInventoryContext;
127
133
  export interface CoverageAdapter {
128
134
  /** Names the adapter in errors (`coverage adapter "node" on service …`). */
129
135
  name: string;
136
+ /** Ordered default rules; service rules are explicit overrides. */
137
+ changeRules?: readonly ChangeRule[];
138
+ /** Resolve build-specific rules once evidence is available; replaces static defaults. */
139
+ resolveChangeRules?(ctx: CoverageSourceContext): Promise<readonly ChangeRule[]>;
140
+ /** Wrappers retain separate policy groups for each nested adapter. */
141
+ changeRuleGroups?: readonly (readonly ChangeRule[])[];
130
142
  /** Archived on this adapter's reports; other adapters retain their own policy. */
131
143
  sourceOptions?: CoverageSourceOptions | ((ctx: CoverageSourceContext) => Promise<CoverageSourceOptions>);
132
144
  /** Generated scaffolding excluded in addition to explicit or legacy source policy. */
@@ -251,19 +263,23 @@ export function validateCoverage(service: string, cov: unknown): void {
251
263
  ((a as CoverageAdapter).explains === undefined ||
252
264
  (Array.isArray((a as CoverageAdapter).explains) &&
253
265
  ((a as CoverageAdapter).explains as unknown[]).every((e) => typeof e === "string"))) &&
254
- ["configure", "load", "capture", "collect", "inventory", "ownsReport"].every(
266
+ ["configure", "load", "capture", "collect", "inventory", "ownsReport", "resolveChangeRules"].every(
255
267
  (k) =>
256
268
  (a as Record<string, unknown>)[k] === undefined ||
257
269
  typeof (a as Record<string, unknown>)[k] === "function",
258
270
  );
259
271
  const sourceOptions = (a as CoverageAdapter | null)?.sourceOptions;
272
+ const changeRules = (a as CoverageAdapter | null)?.changeRules;
273
+ const groups = (a as CoverageAdapter | null)?.changeRuleGroups;
274
+ if (changeRules !== undefined) validateChangeRules(changeRules);
275
+ if (groups !== undefined) { if (!Array.isArray(groups)) throw new Error("invalid changeRuleGroups"); for (const group of groups) validateChangeRules(group); }
260
276
  const defaults = (a as CoverageAdapter | null)?.defaultExcludeSources;
261
277
  const defaultShape = defaults === undefined || typeof (a as CoverageAdapter).ownsReport === "function";
262
278
  const sourceShape = sourceOptions === undefined || (typeof (a as CoverageAdapter).ownsReport === "function" &&
263
279
  (typeof sourceOptions === "function" || (typeof sourceOptions === "object" && sourceOptions !== null && !Array.isArray(sourceOptions))));
264
280
  if (!ok || !sourceShape || !defaultShape) {
265
281
  throw new Error(
266
- `service "${service}" has an invalid coverage adapter — an adapter is \`{ name, reports?, explains?, configure?, load?, capture?, inventory? }\` (see \`defineAdapter\` in @specific.dev/spectest/coverage)`,
282
+ `service "${service}" has an invalid coverage adapter — an adapter is \`{ name, reports?, explains?, changeRules?, configure?, load?, capture?, inventory? }\` (see \`defineAdapter\` in @specific.dev/spectest/coverage)`,
267
283
  );
268
284
  }
269
285
  if (sourceOptions !== undefined && typeof sourceOptions !== "function") validateCoverageSourceOptions(service, sourceOptions);
@@ -291,6 +307,7 @@ export function coverageAdapters(cov: ServiceCoverage | undefined): readonly Cov
291
307
  export function applyCoverageAdapters<S extends ServiceConfig>(key: string, svc: S): S {
292
308
  validateCoverage(key, svc.coverage);
293
309
  const adapters = coverageAdapters(svc.coverage);
310
+ serviceChangePolicy(svc); // Validate even uncovered services; never enables collection.
294
311
  if (adapters.length === 0) return svc;
295
312
  let out: ServiceConfig = svc;
296
313
  for (const a of adapters) {
@@ -312,6 +329,41 @@ export function applyCoverageAdapters<S extends ServiceConfig>(key: string, svc:
312
329
  return { ...out, coverage: wire } as unknown as S;
313
330
  }
314
331
 
332
+ /** Resolve service policy without requiring coverage to be enabled. */
333
+ const RESOLVED_CHANGE_RULES = new Map<NonNullable<CoverageAdapter["resolveChangeRules"]>, readonly ChangeRule[]>();
334
+ export async function resolveAdapterChangeRules(adapter: CoverageAdapter, ctx: CoverageSourceContext): Promise<void> {
335
+ if (!adapter.resolveChangeRules) return;
336
+ const rules = await adapter.resolveChangeRules(ctx);
337
+ validateChangeRules(rules);
338
+ const before = RESOLVED_CHANGE_RULES.get(adapter.resolveChangeRules);
339
+ if (before && JSON.stringify(before) !== JSON.stringify(rules)) throw new Error(`adapter ${adapter.name} resolved inconsistent change rules across runtimes`);
340
+ RESOLVED_CHANGE_RULES.set(adapter.resolveChangeRules, rules.map(rule => ({...rule})));
341
+ }
342
+ export function serviceChangePolicy(svc: Pick<ServiceConfig, "coverage" | "changeRules">, options: {requireResolved?: boolean} = {}): ChangePolicy {
343
+ const adapters = coverageAdapters(svc.coverage).map(adapter => {
344
+ if (!adapter.resolveChangeRules || svc.changeRules === false) return adapter;
345
+ const rules = RESOLVED_CHANGE_RULES.get(adapter.resolveChangeRules);
346
+ if (!rules && options.requireResolved) {
347
+ // An unused declared image has no runtime metadata. Archive a conservative
348
+ // fallback, so its source cannot gain ignore/coverage exceptions.
349
+ const fallbackRoot = typeof adapter.sourceOptions === "object" ? adapter.sourceOptions.sourceRoot : undefined;
350
+ const serviceRoot = typeof svc.coverage === "object" ? svc.coverage.sourceRoot : undefined;
351
+ const roots = new Set([...(adapter.changeRules ?? []), ...(adapter.changeRuleGroups ?? []).flat()].map(rule => rule.root ?? fallbackRoot ?? serviceRoot ?? "."));
352
+ if (!roots.size) roots.add(fallbackRoot ?? serviceRoot ?? ".");
353
+ return {...adapter, changeRuleGroups:[], changeRules:[...roots].map(root => ({root,pattern:"**",action:"full" as const}))};
354
+ }
355
+ return rules ? {...adapter,changeRules:rules} : adapter;
356
+ });
357
+ return resolveChangePolicy(svc, adapters);
358
+ }
359
+
360
+ /** Inventory fingerprints must not turn ignored fixture manifests into changes.
361
+ * Installed runtime dependency inventories remain independent of project files. */
362
+ export function inventoryProjectFiles(svc: Pick<ServiceConfig, "coverage" | "changeRules">, files: readonly string[]): string[] {
363
+ const action = compileChangePolicy(serviceChangePolicy(svc, {requireResolved:true}));
364
+ return files.filter(file => action(file) !== "ignore");
365
+ }
366
+
315
367
  function validateCoverageSourceOptions(service: string, options: unknown): asserts options is CoverageSourceOptions {
316
368
  if (!options || typeof options !== "object" || Array.isArray(options) ||
317
369
  !Object.keys(options).every(k => k === "sourceRoot" || k === "excludeSources")) {
@@ -323,6 +375,9 @@ function validateCoverageSourceOptions(service: string, options: unknown): asser
323
375
  /** Resolve adapter policies once per capture. Invalid/overlapping ownership is
324
376
  * an error, so one adapter cannot silently reinterpret another's reports. */
325
377
  export async function resolveCoverageSourceOptions(adapters: readonly CoverageAdapter[], ctx: CoverageSourceContext, fallback?: CoverageSourceOptions) {
378
+ await Promise.all(adapters.map(a => resolveAdapterChangeRules(a, {
379
+ ...ctx, sourceRoot: (typeof a.sourceOptions === "object" ? a.sourceOptions.sourceRoot : undefined) ?? fallback?.sourceRoot ?? ".",
380
+ })));
326
381
  return Promise.all(adapters.filter(a => a.sourceOptions !== undefined || a.defaultExcludeSources !== undefined || a.ownsReport).map(async a => {
327
382
  if (!a.ownsReport) throw new Error(`coverage adapter "${a.name}" needs ownsReport with sourceOptions`);
328
383
  let options = typeof a.sourceOptions === "function" ? await a.sourceOptions(ctx) : a.sourceOptions;
@@ -353,7 +408,7 @@ export function attachCoverageSourceOptions<T extends { reports: { name: string;
353
408
  function withWireName(a: CoverageAdapter): CoverageAdapter {
354
409
  if (Object.prototype.hasOwnProperty.call(a, "toJSON")) return a;
355
410
  return Object.assign(Object.create(Object.getPrototypeOf(a) as object | null), a, {
356
- toJSON: () => ({ adapter: a.name, ...(a.defaultExcludeSources ? { defaultExcludeSources: a.defaultExcludeSources } : {}), ...(a.sourceOptions && typeof a.sourceOptions !== "function" ? { sourceOptions: a.sourceOptions } : {}) }),
411
+ toJSON: () => ({ adapter: a.name, ...(a.changeRules ? {changeRules: a.changeRules} : {}), ...(a.changeRuleGroups ? {changeRuleGroups: a.changeRuleGroups} : {}), ...(a.defaultExcludeSources ? { defaultExcludeSources: a.defaultExcludeSources } : {}), ...(a.sourceOptions && typeof a.sourceOptions !== "function" ? { sourceOptions: a.sourceOptions } : {}) }),
357
412
  }) as CoverageAdapter;
358
413
  }
359
414
 
@@ -973,6 +1028,8 @@ process.stdout.write(Array.from(out).sort().join("\\n") + (out.size ? "\\n" : ""
973
1028
  `;
974
1029
 
975
1030
  export interface NodeCoverageOptions extends CoverageSourceOptions {
1031
+ /** Replace this adapter's defaults, or false to disable them. */
1032
+ changeRules?: readonly ChangeRule[] | false;
976
1033
  /** Generated runtime entrypoints, relative to the service working directory.
977
1034
  * Track their exact contents as units rather than inventing repository lines.
978
1035
  * A content change changes the inventory and invalidates dependent tests. */
@@ -980,6 +1037,11 @@ export interface NodeCoverageOptions extends CoverageSourceOptions {
980
1037
  }
981
1038
 
982
1039
  export function node(options: NodeCoverageOptions = {}): CoverageAdapter {
1040
+ const policy = options.changeRules === undefined ? NODE_CHANGE_RULES : options.changeRules;
1041
+ if (policy !== false) validateChangeRules(policy);
1042
+ if (options.sourceRoot !== undefined || options.excludeSources !== undefined) {
1043
+ validateCoverageSourceOptions("node", { sourceRoot: options.sourceRoot, excludeSources: options.excludeSources });
1044
+ }
983
1045
  if (options.generated !== undefined && (!Array.isArray(options.generated) || options.generated.some(p => typeof p !== "string"))) {
984
1046
  throw new Error("coverage.node generated must be a list of relative files");
985
1047
  }
@@ -990,6 +1052,7 @@ export function node(options: NodeCoverageOptions = {}): CoverageAdapter {
990
1052
  }
991
1053
  return {
992
1054
  name: "node",
1055
+ changeRules: policy === false ? [] : policy.map(rule => ({...rule})),
993
1056
  defaultExcludeSources: JAVASCRIPT_GENERATED_SOURCES,
994
1057
  ...(options.sourceRoot !== undefined || options.excludeSources !== undefined ? { sourceOptions: { sourceRoot: options.sourceRoot, excludeSources: options.excludeSources } } : {}),
995
1058
  ownsReport: name => isNodeDumpName(name) || name === NODE_COVERAGE_EMPTY_REPORT,
@@ -1204,11 +1267,17 @@ export async function collectNodeReports(dir: string): Promise<NodeReportStats>
1204
1267
  * harvested first, and only this service's totals are taken and cleared. Nothing ran ⇒
1205
1268
  * an lcov with no records, which is a report saying so.
1206
1269
  */
1207
- export function browser(options: CoverageSourceOptions = {}): CoverageAdapter {
1270
+ export interface BrowserCoverageOptions extends CoverageSourceOptions {
1271
+ changeRules?: readonly ChangeRule[] | false;
1272
+ }
1273
+ export function browser(options: BrowserCoverageOptions = {}): CoverageAdapter {
1274
+ const rules = options.changeRules === undefined ? BROWSER_CHANGE_RULES : options.changeRules;
1275
+ if (rules !== false) validateChangeRules(rules);
1208
1276
  return {
1209
1277
  name: "browser",
1278
+ changeRules: rules === false ? [] : rules.map(rule => ({...rule})),
1210
1279
  defaultExcludeSources: JAVASCRIPT_GENERATED_SOURCES,
1211
- ...(options.sourceRoot !== undefined || options.excludeSources !== undefined ? { sourceOptions: options } : {}),
1280
+ ...(options.sourceRoot !== undefined || options.excludeSources !== undefined ? { sourceOptions: {sourceRoot: options.sourceRoot, excludeSources: options.excludeSources} } : {}),
1212
1281
  ownsReport: name => name === "browser.lcov",
1213
1282
  reports: "delta",
1214
1283
  load(ctx) {
package/src/daemon.ts CHANGED
@@ -72,6 +72,8 @@ import {
72
72
  NODE_COVERAGE_SCRIPTS_SUBDIR,
73
73
  applyCoverageAdapters,
74
74
  coverageAdapters,
75
+ serviceChangePolicy,
76
+ inventoryProjectFiles,
75
77
  resolveCoverageSourceOptions,
76
78
  attachCoverageSourceOptions,
77
79
  coverageReportsMode,
@@ -123,7 +125,7 @@ import { evaluateChecked } from "./harness/eval-typecheck.js";
123
125
  import type { WrapperDiagnostic } from "./harness/wrapper-rules.js";
124
126
  import { cpus, tmpdir } from "node:os";
125
127
  import { APP_DIR, WORKSPACE, resolveExistingProjectPath, resolveProjectPath, takeProjectReads } from "./project-files.js";
126
- import { compactReach, projectReach, renderReachRules, renderReadsLcov, type ReachRules, type ReachSource } from "./harness/reach.js";
128
+ import { compactReach, environmentReachSource, projectReach, renderReachRules, renderReadsLcov, type ReachRules, type ReachSource } from "./harness/reach.js";
127
129
  import { installFetchWrapper, isTransportError } from "./harness/fetch.js";
128
130
  import {
129
131
  bindInstrumentationScope,
@@ -1382,7 +1384,7 @@ async function runServiceBuild(
1382
1384
  if (remote) {
1383
1385
  buildArgs = ["buildx", "build", "--builder", remote.builder,
1384
1386
  "--progress=plain", "--metadata-file", metadataPath,
1385
- "--output", shadowOutput(remoteReference!), ...argFlags,
1387
+ "--output", shadowOutput(remoteReference!, remote), ...argFlags,
1386
1388
  "-f", dfPath, contextDir];
1387
1389
  } else if (useLocal) {
1388
1390
  // The in-VM builder is BuildKit's containerd worker on this VM's
@@ -1470,7 +1472,7 @@ async function runServiceBuild(
1470
1472
  const immutable = `${remoteReference!.split(":result")[0]}@${digest}`;
1471
1473
  const pullStarted = Date.now();
1472
1474
  const pulled = await shxStream("docker", ["pull", immutable], undefined, undefined, () => {});
1473
- await shadowTiming(name, "pull-unpack", pullStarted, { digest, ok: pulled.code === 0, lazy: false });
1475
+ await shadowTiming(name, "pull-unpack", pullStarted, { digest, ok: pulled.code === 0, lazy: remote.imageFormat === "nydus-v6", imageFormat: remote.imageFormat ?? "oci-zstd" });
1474
1476
  if (pulled.code !== 0) return { ok: false, log: pulled.stderr };
1475
1477
  const tagged = await docker(["tag", immutable, tag]);
1476
1478
  if (tagged.code !== 0) return { ok: false, log: tagged.stderr };
@@ -3793,7 +3795,7 @@ async function writeCoverageInventory(svc: NamedService): Promise<string | undef
3793
3795
  }
3794
3796
  return { stdout: r.stdout, stderr: r.stderr };
3795
3797
  },
3796
- projectFiles: () => walkProjectFiles(WORKSPACE),
3798
+ projectFiles: async () => inventoryProjectFiles(svc, await walkProjectFiles(WORKSPACE)),
3797
3799
  async readProjectFile(p: string) {
3798
3800
  // Not recorded as a read: an inventory fingerprinting every
3799
3801
  // manifest is bookkeeping, not the suite depending on the files.
@@ -3846,11 +3848,13 @@ async function computeProjectReach(all: NamedService[]): Promise<ReachRules> {
3846
3848
 
3847
3849
  async function computeProjectReachInner(all: NamedService[]): Promise<ReachRules> {
3848
3850
  const files = await walkProjectFiles(WORKSPACE, [".git"]);
3849
- const sources: ReachSource[] = [];
3851
+ const sources: ReachSource[] = [environmentReachSource(ENVIRONMENT_SERVICE)];
3850
3852
  const owners = new Map(componentBuildTasks(all).map(t => [t.name, t.owner]));
3851
3853
  for (const svc of imageTasks(all)) {
3854
+ const changePolicy = serviceChangePolicy(svc, {requireResolved: true});
3852
3855
  const src: ReachSource = {
3853
3856
  service: owners.get(svc.name) ?? svc.name,
3857
+ changePolicy,
3854
3858
  volumeSources: (svc.volumes ?? []).flatMap((v) => (v.source && !v.reachExempt ? [v.source] : [])),
3855
3859
  ...(svc.inputs && svc.inputs.length > 0 ? { inputs: [...svc.inputs] } : {}),
3856
3860
  };
@@ -3870,7 +3874,7 @@ async function computeProjectReachInner(all: NamedService[]): Promise<ReachRules
3870
3874
  sources.push(src);
3871
3875
  }
3872
3876
  const reach = projectReach(files, sources);
3873
- return { version: 1, sources, prefixes: compactReach(files, reach) };
3877
+ return { version: 2, sources, prefixes: compactReach(files, reach) };
3874
3878
  }
3875
3879
 
3876
3880
  /**
@@ -3915,7 +3919,6 @@ async function captureServiceCoverage(baseline = false): Promise<ServiceCoverage
3915
3919
  });
3916
3920
  // The environment's own capture is unconditional (see above); the
3917
3921
  // services' come from the ones that opted in, none included.
3918
- const environment = captureEnvironmentCoverage(all, baseline);
3919
3922
  const captures = await Promise.all(
3920
3923
  services.map(async (svc): Promise<ServiceCoverageCapture> => {
3921
3924
  let policies: Awaited<ReturnType<typeof resolveCoverageSourceOptions>>;
@@ -3967,6 +3970,7 @@ async function captureServiceCoverage(baseline = false): Promise<ServiceCoverage
3967
3970
  try {
3968
3971
  return await adapter.collect!({
3969
3972
  ...coverageCaptureContext(svc, signal, new Set()),
3973
+ changeRules: svc.changeRules,
3970
3974
  projectFiles: () => walkProjectFiles(WORKSPACE),
3971
3975
  async readProjectFile(p) {
3972
3976
  try { return await fs.readFile(resolveProjectPath(p, { record: false }), "utf8"); }
@@ -3977,7 +3981,8 @@ async function captureServiceCoverage(baseline = false): Promise<ServiceCoverage
3977
3981
  return [{ service: `${svc.name}/${adapter.name}`, reports: [], error: String(error) }];
3978
3982
  }
3979
3983
  })));
3980
- return [...captures, ...components.flat(), await environment];
3984
+ // Freeze build-dependent policy only after all direct and nested adapters resolved it.
3985
+ return [...captures, ...components.flat(), await captureEnvironmentCoverage(all, baseline)];
3981
3986
  }
3982
3987
 
3983
3988
  /** Per-Browser rrweb session shipped to the control plane. */
@@ -6074,7 +6079,7 @@ export function harnessMethods(state: RouteState): MethodTable {
6074
6079
  return {
6075
6080
  health: async () => ({
6076
6081
  ok: true,
6077
- capabilities: ["prepared-images-v1", "shadow-builder-v1"],
6082
+ capabilities: ["prepared-images-v1", "shadow-builder-v1", "shadow-nydus-v1"],
6078
6083
  // Optional discovery: old SDKs omit this field and are never sent the
6079
6084
  // new method. Check the loaded project, including after warm restore.
6080
6085
  setupInteractive: typeof loaded?.project.interactive?.seed === "function",
@@ -1,6 +1,9 @@
1
1
  import { expect, test } from "bun:test";
2
2
  import { canonicalImageReference, componentBuildTasks, isComponentImageTask, isDigestReference, kubernetesImages } from "./component-images";
3
3
  import { validateServiceImage, type BuildImage } from "../index";
4
+ import { node, rust, serviceChangePolicy } from "../coverage";
5
+ import { evaluateChangePolicy } from "../change-rules";
6
+ import { projectReach } from "./reach";
4
7
 
5
8
  test("services and Kubernetes share all Dockerfile build options without a type discriminator", () => {
6
9
  const image: BuildImage & { reference: string } = {
@@ -51,6 +54,45 @@ test("component image tasks are recognised by name and services are not", () =>
51
54
  expect(isComponentImageTask("spectest-image")).toBe(false);
52
55
  });
53
56
 
57
+ test("image tasks retain each adapter policy and context without granting permissions to sibling images", () => {
58
+ const images = kubernetesImages([
59
+ { reference: "app/api:1", context: "packages/api", content: "FROM node:24",
60
+ coverage: { adapters: [node({ sourceRoot: "packages/api" })] } },
61
+ { reference: "app/worker:1", context: "crates/worker", content: "FROM rust:1",
62
+ coverage: { adapters: [rust({ sourceRoot: "crates/worker" })] },
63
+ changeRules: [{ root: "crates/worker", pattern: "generated/**", action: "ignore" }] },
64
+ { reference: "app/uncovered:1", context: "packages/uncovered", content: "FROM scratch" },
65
+ ]);
66
+ const tasks = componentBuildTasks([{ name: "cluster", image: "k3s:latest", imageBuilds: images.builds }]);
67
+ const [api, worker, uncovered] = tasks;
68
+ expect(api.coverage).toBeDefined();
69
+ expect(api.image).not.toHaveProperty("coverage");
70
+ expect(worker.image).not.toHaveProperty("changeRules");
71
+ expect(evaluateChangePolicy(serviceChangePolicy(api), "packages/api/main.ts")).toBe("coverage");
72
+ expect(evaluateChangePolicy(serviceChangePolicy(api), "crates/worker/main.rs")).toBeUndefined();
73
+ expect(evaluateChangePolicy(serviceChangePolicy(worker), "crates/worker/src/main.rs")).toBe("coverage");
74
+ expect(evaluateChangePolicy(serviceChangePolicy(worker), "crates/worker/generated/build.rs")).toBe("ignore");
75
+ expect(evaluateChangePolicy(serviceChangePolicy(uncovered), "packages/uncovered/main.ts")).toBeUndefined();
76
+ expect(evaluateChangePolicy(serviceChangePolicy(uncovered), "packages/uncovered/CLAUDE.md")).toBe("ignore");
77
+ const sources = tasks.map(task => ({ context: task.image.context }));
78
+ expect(projectReach(["packages/api/main.ts", "docs/outside.md"], sources)).toEqual(["packages/api/main.ts"]);
79
+ const serialized = JSON.parse(JSON.stringify(images.builds));
80
+ expect(serialized[0].coverage.adapters[0].changeRules).toBeDefined();
81
+ expect(serialized[1].changeRules[0].action).toBe("ignore");
82
+ });
83
+
84
+ test("cluster overrides reach its image tasks unless an image specializes them", () => {
85
+ const images = kubernetesImages([
86
+ { reference: "app/a:1", content: "FROM scratch" },
87
+ { reference: "app/b:1", content: "FROM scratch", changeRules: false },
88
+ ]);
89
+ const tasks = componentBuildTasks([{ name: "cluster", image: "k3s:latest", imageBuilds: images.builds,
90
+ changeRules: [{ pattern: "**/*.md", action: "full" as const }] }]);
91
+ expect(evaluateChangePolicy(serviceChangePolicy(tasks[0]), "README.md")).toBe("full");
92
+ expect(evaluateChangePolicy(serviceChangePolicy(tasks[1]), "README.md")).toBeUndefined();
93
+ expect(() => kubernetesImages([{ reference: "bad:1", content: "FROM scratch", changeRules: [{}] as never }])).toThrow();
94
+ });
95
+
54
96
  test("only a full sha256 digest reference is immutable", () => {
55
97
  const digest = "a".repeat(64);
56
98
  expect(isDigestReference(`postgres@sha256:${digest}`)).toBe(true);
@@ -1,7 +1,13 @@
1
1
  import { createHash } from "node:crypto";
2
2
  import type { BuildImage, ComponentImageBuild, ServiceConfig } from "../index.js";
3
+ import { validateChangeRules, type ChangeRule } from "../change-rules.js";
3
4
 
4
- export type KubernetesImage = string | (BuildImage & { reference: string; coverage?: import("../coverage.js").ServiceCoverage });
5
+ export type KubernetesImage = string | (BuildImage & {
6
+ reference: string;
7
+ coverage?: import("../coverage.js").ServiceCoverage;
8
+ /** Override change-selection defaults for this image's own build context. */
9
+ changeRules?: readonly ChangeRule[] | false;
10
+ });
5
11
 
6
12
  /** containerd/CRI use fully qualified names, including the default tag. */
7
13
  export function canonicalImageReference(reference: string): string {
@@ -51,8 +57,13 @@ export function kubernetesImages(images: readonly KubernetesImage[]): { pulls: s
51
57
  seen.set(reference, identity);
52
58
  if (typeof image === "string") pulls.push(image);
53
59
  else {
54
- const { coverage: _coverage, ...build } = image;
55
- builds.push({ reference, image: image.coverage === undefined ? image : build });
60
+ const { coverage, changeRules, ...build } = image;
61
+ if (changeRules !== undefined && changeRules !== false) validateChangeRules(changeRules);
62
+ builds.push({ reference,
63
+ image: coverage === undefined && changeRules === undefined ? image : build,
64
+ ...(coverage === undefined ? {} : { coverage }),
65
+ ...(changeRules === undefined ? {} : { changeRules }),
66
+ });
56
67
  }
57
68
  }
58
69
  return { pulls, builds };
@@ -74,6 +85,12 @@ export function componentBuildTasks<T extends ServiceConfig & { name: string }>(
74
85
  owner: svc.name,
75
86
  reference: build.reference,
76
87
  image: build.image,
88
+ ...(build.coverage === undefined ? {} : { coverage: build.coverage }),
89
+ // An image may specialize the cluster's explicit overrides. Each task
90
+ // keeps its own context and adapter source root; policies never leak to
91
+ // another image merely because the same cluster runs both.
92
+ ...(build.changeRules !== undefined || svc.changeRules !== undefined
93
+ ? { changeRules: build.changeRules ?? svc.changeRules } : {}),
77
94
  })));
78
95
  }
79
96
 
@@ -3,12 +3,14 @@
3
3
  import { promises as fs } from "node:fs";
4
4
  import path from "node:path";
5
5
  import { attachCoverageSourceOptions, coverageAdapters, coverageReportsMode, resolveCoverageSourceOptions,
6
- type CoverageCaptureContext, type CoverageInventoryContext, type ServiceCoverage } from "../coverage.js";
6
+ inventoryProjectFiles, type CoverageCaptureContext, type CoverageInventoryContext, type ServiceCoverage } from "../coverage.js";
7
+ import type { ChangeRule } from "../change-rules.js";
7
8
  import { applyCoverageDelta, readCoverageDir, type ServiceCoverageCapture } from "./coverage.js";
8
9
 
9
10
  export async function captureCoverageTarget(options: {
10
11
  context: CoverageCaptureContext & CoverageInventoryContext;
11
12
  coverage: ServiceCoverage;
13
+ changeRules?: readonly ChangeRule[] | false;
12
14
  root: string;
13
15
  baseline: boolean;
14
16
  inventoryAvailable?: boolean;
@@ -39,7 +41,8 @@ export async function captureCoverageTarget(options: {
39
41
  }
40
42
  if (baseline && options.inventoryAvailable !== false) {
41
43
  const units = new Set<string>();
42
- for (const adapter of adapters) for (const unit of await adapter.inventory?.(context) ?? []) units.add(unit);
44
+ const inventoryContext = {...context, projectFiles: async () => inventoryProjectFiles({coverage,changeRules:options.changeRules}, await context.projectFiles())};
45
+ for (const adapter of adapters) for (const unit of await adapter.inventory?.(inventoryContext) ?? []) units.add(unit);
43
46
  await context.writeReport("inventory.units", [...units].sort().join("\n"));
44
47
  }
45
48
  ctx.signal.throwIfAborted();