@specific.dev/spectest 0.131.21 → 0.132.2

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 (71) hide show
  1. package/dist/browser-coverage.d.ts +3 -0
  2. package/dist/browser-coverage.js +84 -31
  3. package/dist/browser.d.ts +4 -0
  4. package/dist/browser.js +80 -22
  5. package/dist/change-rules.d.ts +45 -0
  6. package/dist/change-rules.js +179 -0
  7. package/dist/components/k3s-coverage.js +4 -4
  8. package/dist/components/supabase-coverage.js +12 -0
  9. package/dist/components/supabase.js +25 -12
  10. package/dist/coverage-bun.js +2 -1
  11. package/dist/coverage-next.d.ts +5 -0
  12. package/dist/coverage-next.js +27 -3
  13. package/dist/coverage-rust.d.ts +2 -0
  14. package/dist/coverage-rust.js +20 -0
  15. package/dist/coverage.d.ts +27 -3
  16. package/dist/coverage.js +68 -15
  17. package/dist/daemon.js +13 -10
  18. package/dist/harness/component-images.d.ts +5 -0
  19. package/dist/harness/component-images.js +15 -2
  20. package/dist/harness/coverage-target.d.ts +2 -0
  21. package/dist/harness/coverage-target.js +3 -2
  22. package/dist/harness/reach.d.ts +6 -1
  23. package/dist/harness/reach.js +8 -34
  24. package/dist/harness/shadow-builder.d.ts +2 -1
  25. package/dist/harness/shadow-builder.js +6 -2
  26. package/dist/index.d.ts +8 -0
  27. package/dist/index.js +5 -0
  28. package/package.json +1 -1
  29. package/src/browser-clipboard.test.ts +177 -0
  30. package/src/browser-coverage-deadline.test.ts +2 -0
  31. package/src/browser-coverage-identity.test.ts +114 -2
  32. package/src/browser-coverage-navigation.test.ts +1 -2
  33. package/src/browser-coverage-sdk.test.ts +91 -0
  34. package/src/browser-coverage.ts +95 -30
  35. package/src/browser-executable.test.ts +7 -5
  36. package/src/browser-finalization.test.ts +1 -1
  37. package/src/browser-image-loading.test.ts +1 -1
  38. package/src/browser-navigation.test.ts +1 -1
  39. package/src/browser-permissions.test.ts +143 -0
  40. package/src/browser-replay-transport.test.ts +60 -0
  41. package/src/browser-screenshot.test.ts +1 -2
  42. package/src/browser-timezone.test.ts +1 -1
  43. package/src/browser.ts +79 -20
  44. package/src/change-rules.fixtures.json +35 -0
  45. package/src/change-rules.test.ts +125 -0
  46. package/src/change-rules.ts +147 -0
  47. package/src/components/k3s-coverage.test.ts +32 -0
  48. package/src/components/k3s-coverage.ts +4 -4
  49. package/src/components/supabase-coverage.ts +12 -0
  50. package/src/components/supabase-dir.test.ts +26 -0
  51. package/src/components/supabase-functions-reach.test.ts +41 -1
  52. package/src/components/supabase-migrations.test.ts +1 -1
  53. package/src/components/supabase.ts +26 -12
  54. package/src/coverage-bun.ts +2 -1
  55. package/src/coverage-next.ts +29 -3
  56. package/src/coverage-rust.test.ts +34 -0
  57. package/src/coverage-rust.ts +22 -0
  58. package/src/coverage.test.ts +2 -1
  59. package/src/coverage.ts +76 -7
  60. package/src/daemon.ts +14 -9
  61. package/src/harness/component-images.test.ts +42 -0
  62. package/src/harness/component-images.ts +20 -3
  63. package/src/harness/coverage-target.ts +5 -2
  64. package/src/harness/reach.test.ts +40 -6
  65. package/src/harness/reach.ts +13 -3
  66. package/src/harness/shadow-builder.test.ts +8 -0
  67. package/src/harness/shadow-builder.ts +7 -2
  68. package/src/index.ts +11 -0
  69. package/src/microphone-fixture.test.ts +106 -15
  70. package/src/playwright-assertions.test.ts +3 -3
  71. package/src/recorder.ts +1 -1
@@ -0,0 +1,125 @@
1
+ import { describe, expect, test } from "bun:test";
2
+ import fixtures from "./change-rules.fixtures.json";
3
+ import { compileChangeGlob, matchesChangeGlob, evaluateChangePolicy, resolveChangePolicy, COMMON_CHANGE_RULES, NODE_CHANGE_RULES, NEXT_CHANGE_RULES, type ChangeRule } from "./change-rules.js";
4
+ import { node, next, bun, browser, rust, command, applyCoverageAdapters, serviceChangePolicy, defineAdapter, resolveCoverageSourceOptions, inventoryProjectFiles } from "./coverage.js";
5
+ const image = {type: "registry" as const, reference: "node:24"};
6
+ const action = (adapter: ReturnType<typeof node>, path: string) => evaluateChangePolicy(serviceChangePolicy({coverage:{adapters:[adapter]}}), path);
7
+ describe("portable change glob", () => {
8
+ for (const fixture of fixtures) test(JSON.stringify(fixture.pattern) + " " + (fixture.path ?? "invalid"), () => {
9
+ if (fixture.invalid) expect(() => compileChangeGlob(fixture.pattern)).toThrow();
10
+ else expect(matchesChangeGlob(fixture.pattern, fixture.path!)).toBe(fixture.matches!);
11
+ });
12
+ });
13
+ describe("change rule defaults and overrides", () => {
14
+ test("documentation defaults work without coverage, with false opt-out", () => {
15
+ const svc = applyCoverageAdapters("db", {image});
16
+ expect(svc).toEqual({image});
17
+ for (const path of ["AGENTS.md", "nested/CLAUDE.md", "notes.MD", "a/readme.markdown"]) expect(evaluateChangePolicy(serviceChangePolicy(svc), path)).toBe("ignore");
18
+ expect(evaluateChangePolicy(serviceChangePolicy({changeRules:false}), "AGENTS.md")).toBeUndefined();
19
+ expect(evaluateChangePolicy(serviceChangePolicy(svc), "app.mdx")).toBeUndefined();
20
+ });
21
+ test("JS test patterns, configuration and roots", () => {
22
+ for (const adapter of [node(), bun(), browser(), next()]) {
23
+ for (const path of ["src/math.test.ts", "src/math.spec.tsx", "src/math_test.mjs", "src/math_spec.cts", "src/a.test-d.ts", "src/a.cy.tsx", "__tests__/helper.json", "__mocks__/fetch.ts", "__snapshots__/a.snap", "tests/helper.ts", "jest.config.ts", "vitest.config.mjs"]) expect(action(adapter, path)).toBe("ignore");
24
+ for (const path of ["src/a.ts", "src/a.jsx", "src/a.mts"]) expect(action(adapter, path)).toBe("coverage");
25
+ for (const path of ["migrations/001.ts", "webpack.config.ts", "tsconfig.json", ".env.local"]) expect(action(adapter, path)).toBe("full");
26
+ }
27
+ expect(action(node({sourceRoot:"packages/api"}), "packages/api/test/a.ts")).toBe("ignore");
28
+ expect(action(node({sourceRoot:"packages/api"}), "packages/other/a.ts")).toBeUndefined();
29
+ expect(action(bun(), "bunfig.toml")).toBe("full");
30
+ });
31
+ test("Next specializations survive any adapter ordering", () => {
32
+ for (const adapters of [[next(),node(),browser()],[browser(),node(),next()]]) {
33
+ const policy = serviceChangePolicy({coverage:{adapters}});
34
+ for (const path of ["app/layout.tsx", "app/error.tsx", "proxy.ts", "instrumentation-client.ts", "mdx-components.tsx", "pages/_app.tsx", "next.config.mjs", "app/content.mdx"]) expect(evaluateChangePolicy(policy,path)).toBe("full");
35
+ for (const path of ["app/hello/page.tsx", "app/api/new/route.ts", "pages/a.test.tsx"]) expect(evaluateChangePolicy(policy,path)).toBe("coverage");
36
+ }
37
+ });
38
+ test("Rust defaults distinguish tests, build inputs and production", () => {
39
+ for (const path of ["tests/integration.rs", "benches/perf.rs"]) expect(action(rust(),path)).toBe("ignore");
40
+ for (const path of ["src/lib.rs", "src/tests.rs", "examples/server.rs"]) expect(action(rust(),path)).toBe("coverage");
41
+ for (const path of ["Cargo.toml", "Cargo.lock", "build.rs", ".cargo/config.toml", "rust-toolchain.toml", "src/bin/server.rs"]) expect(action(rust(),path)).toBe("full");
42
+ });
43
+ test("service rules are ordered final overrides and adapter options replace defaults", () => {
44
+ const rules: ChangeRule[] = [{pattern:"**/*.ts",action:"ignore"}, {pattern:"migrations/**",action:"full"}, {pattern:String.raw`migrations/\[seed\].ts`,action:"coverage"}];
45
+ const svc = applyCoverageAdapters("api", {image,coverage:{adapters:[node()]},changeRules:rules});
46
+ const twice = applyCoverageAdapters("api", svc);
47
+ for (const configured of [svc,twice]) {
48
+ const policy = serviceChangePolicy(configured);
49
+ expect(evaluateChangePolicy(policy,"a.ts")).toBe("ignore");
50
+ expect(evaluateChangePolicy(policy,"migrations/a.ts")).toBe("full");
51
+ expect(evaluateChangePolicy(policy,"migrations/[seed].ts")).toBe("coverage");
52
+ expect(JSON.parse(JSON.stringify(configured)).changeRules).toEqual(rules);
53
+ expect(JSON.parse(JSON.stringify(policy))).toEqual(policy);
54
+ }
55
+ for (const factory of [node,next,browser,bun,rust]) {
56
+ expect(action(factory({changeRules:false}),"src/a.ts")).toBeUndefined();
57
+ expect(action(factory({changeRules:[{pattern:"**/*.go",action:"coverage"}]}),"src/a.go")).toBe("coverage");
58
+ }
59
+ expect(Object.isFrozen(NODE_CHANGE_RULES)).toBe(true);
60
+ expect(Object.isFrozen(NEXT_CHANGE_RULES)).toBe(true);
61
+ expect(Object.isFrozen(COMMON_CHANGE_RULES)).toBe(true);
62
+ });
63
+ test("dynamic source roots need explicit policies; wrappers retain independent groups", () => {
64
+ const dynamic = defineAdapter({name:"dynamic",ownsReport:()=>true,sourceOptions:async()=>({sourceRoot:"runtime"}),changeRules:[{pattern:"**/*.ts",action:"coverage" as const}]});
65
+ expect(()=>serviceChangePolicy({coverage:{adapters:[dynamic]}})).toThrow();
66
+ const policy = serviceChangePolicy({coverage:{adapters:[{...dynamic,changeRules:dynamic.changeRules!.map(r=>({...r,root:"src"}))}]}});
67
+ expect(evaluateChangePolicy(policy,"src/a.ts")).toBe("coverage");
68
+ const nested = resolveChangePolicy({},[{changeRuleGroups:[[{root:"app",pattern:"**/*.ts",action:"coverage"}],[{root:"app",pattern:"shared.ts",action:"full"}]]}]);
69
+ expect(evaluateChangePolicy(nested,"app/shared.ts")).toBe("full");
70
+ expect(action(command("collect"),"src/a.ts")).toBeUndefined();
71
+ });
72
+ test("Spectest execution files stay active while nearby Markdown is ignored", () => {
73
+ const policy = serviceChangePolicy({coverage:{adapters:[node()]}});
74
+ expect(evaluateChangePolicy(policy,"spectest/helper.test.ts")).toBe("full");
75
+ expect(evaluateChangePolicy(policy,"spectest/tests/__mocks__/helper.ts")).toBe("coverage");
76
+ expect(evaluateChangePolicy(policy,"spectest/tests/AGENTS.md")).toBe("ignore");
77
+ expect(evaluateChangePolicy(policy,"migrations/README.md")).toBe("ignore");
78
+ expect(action(rust(),"src/bin/README.md")).toBe("ignore");
79
+ });
80
+ test("Next capture freezes configured route extensions and rejects divergent runtimes", async () => {
81
+ const adapter = next();
82
+ const svc = {coverage:{adapters:[adapter]}};
83
+ const missing = serviceChangePolicy(svc,{requireResolved:true});
84
+ expect(evaluateChangePolicy(missing,"pages/README.md")).toBe("full");
85
+ expect(evaluateChangePolicy(missing,"src/a.test.ts")).toBe("full");
86
+ const nested = next({sourceRoot:"apps/web"});
87
+ const unknownNested = serviceChangePolicy({coverage:{adapters:[nested]}},{requireResolved:true});
88
+ expect(evaluateChangePolicy(unknownNested,"apps/web/a.ts")).toBe("full");
89
+ expect(evaluateChangePolicy(unknownNested,"other/a.ts")).toBeUndefined();
90
+ const context = (extensions: string[], appRoot = "") => ({service:"web",signal:new AbortController().signal,
91
+ projectFiles:async()=>[],readProjectFile:async()=>null,
92
+ exec:async()=>({stdout:JSON.stringify({generated:null,serverMaps:1,browserMaps:1,pageExtensions:extensions,appRoot}),stderr:""})});
93
+ await resolveCoverageSourceOptions([adapter],context(["tsx","md"]));
94
+ const policy = serviceChangePolicy(svc,{requireResolved:true});
95
+ expect(evaluateChangePolicy(policy,"pages/a.md")).toBe("coverage");
96
+ expect(evaluateChangePolicy(policy,"pages/a.test.tsx")).toBe("coverage");
97
+ expect(evaluateChangePolicy(policy,"pages/a.test.ts")).toBe("ignore");
98
+ expect(evaluateChangePolicy(policy,"app/new/page.md")).toBe("coverage");
99
+ expect(evaluateChangePolicy(policy,"app/new/README.md")).toBe("ignore");
100
+ expect(evaluateChangePolicy(policy,"migrations/README.md")).toBe("ignore");
101
+ expect(evaluateChangePolicy(policy,"pages/_app.tsx")).toBe("full");
102
+ await expect(resolveCoverageSourceOptions([adapter],{...context(["ts"]),service:"other"})).rejects.toThrow("inconsistent change rules");
103
+ const ordinary = next();
104
+ await resolveCoverageSourceOptions([ordinary],context(["tsx"],"web"));
105
+ const ordinaryPolicy = serviceChangePolicy({coverage:{adapters:[ordinary]}},{requireResolved:true});
106
+ expect(evaluateChangePolicy(ordinaryPolicy,"web/pages/a.test.tsx")).toBe("coverage");
107
+ expect(evaluateChangePolicy(ordinaryPolicy,"web/pages/README.md")).toBe("ignore");
108
+ });
109
+ test("ignored fixture manifests cannot change a Node inventory, explicit overrides can", async () => {
110
+ const adapter = node();
111
+ const svc = {coverage:{adapters:[adapter]}};
112
+ const files = ["package.json","__tests__/fixture/package.json"];
113
+ const inventory = (version: string, changeRules?: ChangeRule[]) => adapter.inventory!({service:"app",signal:new AbortController().signal,
114
+ projectFiles:async()=>inventoryProjectFiles({...svc,changeRules},files),
115
+ readProjectFile:async file=>JSON.stringify({name:file,dependencies:{dependency:file.startsWith("__tests__") ? version : "1"}}),
116
+ exec:async()=>({stdout:"",stderr:""})});
117
+ expect(await inventory("1")).toEqual(await inventory("2"));
118
+ const override: ChangeRule[] = [{pattern:"__tests__/**",action:"coverage"}];
119
+ expect(await inventory("1",override)).not.toEqual(await inventory("2",override));
120
+ });
121
+ test("malformed declarations cannot silently broaden selection", () => {
122
+ for (const rule of [{pattern:"**/*.ts",action:"wat"},{pattern:"../file",action:"ignore"},{pattern:"**",action:"ignore",root:"/absolute"},{pattern:"**",action:"ignore",extra:true}]) expect(()=>serviceChangePolicy({changeRules:[rule] as any})).toThrow();
123
+ expect(()=>serviceChangePolicy({changeRules:null as any})).toThrow();
124
+ });
125
+ });
@@ -0,0 +1,147 @@
1
+ /** Portable source-change policy, evaluated identically by the SDK and controller.
2
+ * Globs match whole root-relative paths: *, ?, [], [!], ** and backslash escapes.
3
+ * Braces/extglobs have no special meaning. Directory rules explicitly use /**. */
4
+ export type ChangeAction = "coverage" | "full" | "ignore";
5
+ export interface ChangeRule { root?: string; pattern: string; action: ChangeAction }
6
+ export interface ResolvedChangeRule extends ChangeRule { root: string }
7
+ export interface ChangePolicy { defaults: readonly (readonly ResolvedChangeRule[])[]; overrides: readonly ResolvedChangeRule[] }
8
+ export interface ChangeRuleAdapter {
9
+ changeRules?: readonly ChangeRule[];
10
+ /** Wrappers preserve independent nested adapter policies, including roots. */
11
+ changeRuleGroups?: readonly (readonly ChangeRule[])[];
12
+ sourceOptions?: { sourceRoot?: string } | ((...args: any[]) => unknown);
13
+ }
14
+ export interface ChangeRuleService {
15
+ /** Explicit rules override all defaults. false disables defaults. */
16
+ changeRules?: readonly ChangeRule[] | false;
17
+ coverage?: boolean | { sourceRoot?: string };
18
+ }
19
+ const literal = (c: string) => /[\\^$.*+?()[\]{}|/]/u.test(c) ? `\\${c}` : c;
20
+ const classLiteral = (c: string) => ["\\", "[", "]", "^", "-"].includes(c) ? `\\${c}` : c;
21
+ export function compileChangeGlob(pattern: string): RegExp {
22
+ if (!pattern || pattern.includes("\0") || pattern.startsWith("/") || pattern.split("/").some(p => p === "" || p === "." || p === "..")) throw new Error("change-rule pattern must be a nonempty relative glob without traversal");
23
+ const chars = [...pattern]; let out = "";
24
+ for (let i = 0; i < chars.length; i++) {
25
+ const c = chars[i]!;
26
+ if (c === "\\") {
27
+ const next = chars[++i];
28
+ if (next === undefined || next === "/") throw new Error("invalid glob escape");
29
+ out += literal(next);
30
+ } else if (c === "*") {
31
+ if (chars[i + 1] === "*") {
32
+ i++;
33
+ if ((i > 1 && chars[i - 2] !== "/") || (i + 1 < chars.length && chars[i + 1] !== "/")) throw new Error("** must occupy a whole path segment");
34
+ if (chars[i + 1] === "/") { i++; out += "(?:[\\s\\S]*/)?"; } else out += "[\\s\\S]*";
35
+ } else out += "[^/]*";
36
+ } else if (c === "?") out += "[^/]";
37
+ else if (c === "[") {
38
+ let negate = false; if (chars[i + 1] === "!") { negate = true; i++; }
39
+ const members: { c: string; escaped: boolean }[] = [];
40
+ let closed = false;
41
+ while (++i < chars.length) {
42
+ let ch = chars[i]!;
43
+ if (ch === "]") { closed = true; break; }
44
+ let escaped = false;
45
+ if (ch === "\\") { ch = chars[++i]!; escaped = true; if (ch === undefined) throw new Error("invalid class escape"); }
46
+ if (ch === "/") throw new Error("glob classes cannot match separators");
47
+ members.push({ c: ch, escaped });
48
+ }
49
+ if (!closed || !members.length) throw new Error("unterminated or empty glob class");
50
+ let body = "";
51
+ for (let j = 0; j < members.length; j++) {
52
+ const m = members[j]!;
53
+ if (members[j + 1]?.c === "-" && !members[j + 1]!.escaped && members[j + 2]) {
54
+ const end = members[j + 2]!;
55
+ if (m.c.codePointAt(0)! > end.c.codePointAt(0)! || (m.c.codePointAt(0)! <= 47 && end.c.codePointAt(0)! >= 47)) throw new Error("invalid glob range");
56
+ body += `${classLiteral(m.c)}-${classLiteral(end.c)}`; j += 2;
57
+ } else body += classLiteral(m.c);
58
+ }
59
+ // Prefix slash in a negated class so it remains segment-scoped.
60
+ out += `[${negate ? "^/" : ""}${body}]`;
61
+ } else out += literal(c);
62
+ }
63
+ return new RegExp(`^(?:${out})$(?![\\s\\S])`, "u");
64
+ }
65
+ export function matchesChangeGlob(pattern: string, path: string): boolean { return compileChangeGlob(pattern).test(path); }
66
+ export function validateChangeRules(value: unknown): asserts value is readonly ChangeRule[] {
67
+ if (!Array.isArray(value)) throw new Error("changeRules must be an array or false");
68
+ for (const rule of value) {
69
+ if (!rule || typeof rule !== "object" || Array.isArray(rule) || !Object.keys(rule).every(k => ["root", "pattern", "action"].includes(k)) || typeof rule.pattern !== "string" || !["coverage", "full", "ignore"].includes(rule.action)) throw new Error("invalid change rule");
70
+ if (rule.root !== undefined && (typeof rule.root !== "string" || (rule.root !== "." && (!rule.root || rule.root.includes("\\") || rule.root.includes("\0") || rule.root.split("/").some((p: string) => !p || p === "." || p === ".."))))) throw new Error("change-rule root must be repository-relative");
71
+ compileChangeGlob(rule.pattern);
72
+ }
73
+ }
74
+ const freeze = (rules: ChangeRule[]): readonly ChangeRule[] => Object.freeze(rules.map(r => Object.freeze(r)));
75
+ export const COMMON_CHANGE_RULES = freeze(["**/*.md", "**/*.markdown", "**/*.MD", "**/*.MARKDOWN"].map(pattern => ({ pattern, action: "ignore" })));
76
+ const extensions = ["js", "jsx", "mjs", "cjs", "ts", "tsx", "mts", "cts"];
77
+ const full = (pattern: string): ChangeRule => ({pattern, action: "full"});
78
+ const ignore = (pattern: string): ChangeRule => ({pattern, action: "ignore"});
79
+ const coverage = (pattern: string): ChangeRule => ({pattern, action: "coverage"});
80
+ export const JAVASCRIPT_CHANGE_RULES = freeze([
81
+ ...extensions.map(ext => coverage(`**/*.${ext}`)),
82
+ full("**/migrations/**"),
83
+ ...extensions.flatMap(ext => [full(`**/*.config.${ext}`), full(`**/.babelrc.${ext}`), full(`**/.pnp.${ext}`), full(`**/.pnp.loader.${ext}`)]),
84
+ ...["**/tsconfig*.json", "**/jsconfig*.json", "**/.babelrc", "**/.babelrc.json", "**/.swcrc", "**/.env", "**/.env.*"].map(full),
85
+ ...extensions.flatMap(ext => [ignore(`**/test.${ext}`), ignore(`**/spec.${ext}`)]),
86
+ ...extensions.flatMap(ext => [".test", ".spec", "_test", "_spec", ".test-d", ".spec-d", ".cy"].map(suffix => ignore(`**/*${suffix}.${ext}`))),
87
+ ...["**/__tests__/**", "**/__mocks__/**", "**/__snapshots__/**", "test/**", "tests/**", "e2e/**", "cypress/**", "playwright/**"].map(ignore),
88
+ ...[...extensions, "json"].flatMap(ext => ["jest", "vitest", "playwright", "cypress"].map(tool => ignore(`**/${tool}.config.${ext}`))),
89
+ ...COMMON_CHANGE_RULES,
90
+ ]);
91
+ export const NODE_CHANGE_RULES = JAVASCRIPT_CHANGE_RULES;
92
+ export const BROWSER_CHANGE_RULES = JAVASCRIPT_CHANGE_RULES;
93
+ export const BUN_CHANGE_RULES = freeze([...NODE_CHANGE_RULES, full("**/bunfig.toml")]);
94
+ const sharedNext = ["layout", "template", "error", "global-error", "loading", "not-found", "default", "forbidden", "unauthorized", "middleware", "proxy", "instrumentation", "instrumentation-client", "mdx-components", "_app", "_document", "_error"];
95
+ export const NEXT_CHANGE_RULES = freeze([
96
+ ...JAVASCRIPT_CHANGE_RULES,
97
+ // Pages-router files are routes even if their names resemble unit tests.
98
+ ...["pages", "src/pages"].flatMap(root => extensions.map(ext => coverage(`${root}/**/*.${ext}`))),
99
+ ...["app", "src/app"].flatMap(root => extensions.flatMap(ext => [coverage(`${root}/**/page.${ext}`), coverage(`${root}/**/route.${ext}`)])),
100
+ full("**/*.mdx"), full("**/*.MDX"),
101
+ ...extensions.flatMap(ext => sharedNext.map(name => full(`**/${name}.${ext}`))),
102
+ ]);
103
+ export const RUST_CHANGE_RULES = freeze([
104
+ coverage("**/*.rs"), ignore("tests/**"), ignore("benches/**"),
105
+ ...["**/Cargo.toml", "**/Cargo.lock", "**/.cargo/config", "**/.cargo/config.toml", "**/rust-toolchain", "**/rust-toolchain.toml", "**/build.rs", "src/bin/**"].map(full),
106
+ ...COMMON_CHANGE_RULES,
107
+ ]);
108
+ export function resolveChangePolicy(service: ChangeRuleService, adapters: readonly ChangeRuleAdapter[] = []): ChangePolicy {
109
+ if (service.changeRules !== undefined && service.changeRules !== false) validateChangeRules(service.changeRules);
110
+ const serviceRoot = typeof service.coverage === "object" ? service.coverage.sourceRoot ?? "." : ".";
111
+ const resolve = (rules: readonly ChangeRule[], root: string): ResolvedChangeRule[] => {
112
+ validateChangeRules(rules); const out = rules.map(r => ({ ...r, root: r.root ?? root })); validateChangeRules(out); return out;
113
+ };
114
+ const defaults: ResolvedChangeRule[][] = service.changeRules === false ? [] : [resolve([{pattern:"spectest/**",action:"full"}, {pattern:"spectest/tests/**",action:"coverage"}, ...COMMON_CHANGE_RULES], ".")];
115
+ if (service.changeRules !== false) for (const adapter of adapters) {
116
+ const groups = [...(adapter.changeRules ? [adapter.changeRules] : []), ...(adapter.changeRuleGroups ?? [])];
117
+ const root = typeof adapter.sourceOptions === "object" ? adapter.sourceOptions.sourceRoot ?? serviceRoot : serviceRoot;
118
+ for (const group of groups) {
119
+ if (typeof adapter.sourceOptions === "function" && group.some(r => r.root === undefined)) throw new Error("dynamic sourceOptions requires explicit change-rule roots");
120
+ defaults.push(resolve(group, root));
121
+ }
122
+ }
123
+ return { defaults, overrides: resolve(service.changeRules || [], serviceRoot) };
124
+ }
125
+ /** Compile once for scans over a source tree; regexes carry no mutable flags. */
126
+ export function compileChangePolicy(policy: ChangePolicy): (path: string) => ChangeAction | undefined {
127
+ const compile = (rules: readonly ResolvedChangeRule[]) => rules.map(r => ({...r, regex:compileChangeGlob(r.pattern)}));
128
+ const defaults = policy.defaults.map(compile), overrides = compile(policy.overrides);
129
+ const groupAction = (rules: ReturnType<typeof compile>, path: string) => {
130
+ let result: ChangeAction | undefined;
131
+ for (const r of rules) {
132
+ const rel = r.root === "." ? path : path === r.root ? "" : path.startsWith(r.root + "/") ? path.slice(r.root.length + 1) : undefined;
133
+ if (rel !== undefined && r.regex.test(rel)) result = r.action;
134
+ }
135
+ return result;
136
+ };
137
+ const rank = {ignore: 1, coverage: 2, full: 3};
138
+ return path => {
139
+ const override = groupAction(overrides, path); if (override) return override;
140
+ let result: ChangeAction | undefined;
141
+ for (const rules of defaults) { const action = groupAction(rules,path); if (action && (!result || rank[action] > rank[result])) result = action; }
142
+ return result;
143
+ };
144
+ }
145
+ export function evaluateChangePolicy(policy: ChangePolicy, path: string): ChangeAction | undefined {
146
+ return compileChangePolicy(policy)(path);
147
+ }
@@ -158,3 +158,35 @@ test("collection bounds parallel targets and shares lazy discovery for command a
158
158
  expect(commands.filter(command => command.includes("capture-probe"))).toHaveLength(11);
159
159
  } finally { await fs.rm(root, { recursive: true, force: true }); }
160
160
  }, 10000);
161
+
162
+ test("nested inventory uses cluster overrides unless an image supplies its own", async () => {
163
+ const root = await fs.mkdtemp(path.join(os.tmpdir(), "k3s-policy-inventory-"));
164
+ try {
165
+ const id = "c".repeat(16);
166
+ const dir = path.join(root, ".pods", id);
167
+ await fs.mkdir(dir, { recursive: true });
168
+ await fs.writeFile(path.join(dir, ".instance.json"), JSON.stringify({ id, image: image.reference, container: "api", namespace: "app", root: "/app" }));
169
+ await fs.writeFile(path.join(dir, "app.lcov"), "TN:\n");
170
+ const adapter: CoverageAdapter = { name: "manifest", async inventory(ctx) {
171
+ return (await ctx.projectFiles()).map(file => `manifest:${file}`);
172
+ } };
173
+ const pod = { metadata: { name: "api", annotations: { "spectest.dev/coverage": JSON.stringify({ instances: [id] }) } },
174
+ status: { containerStatuses: [{ name: "api", state: { running: {} } }] } };
175
+ const ctx = { service: "k8s", reportDir: root, signal: AbortSignal.timeout(5000),
176
+ changeRules: [{ pattern: "**/fixtures/**", action: "ignore" as const }],
177
+ projectFiles: async () => ["app/package.json", "app/fixtures/package.json"],
178
+ readProjectFile: async () => null, writeReport: async () => {},
179
+ exec: async () => ({ stdout: JSON.stringify({ items: [pod] }), stderr: "" }),
180
+ };
181
+ const capture = async (restoreFixture: boolean) => {
182
+ const bridge = k3sCoverage([{ ...image, coverage: { adapters: [adapter] },
183
+ ...(restoreFixture ? { changeRules: [{ pattern: "**/fixtures/**", action: "full" as const }] } : {}),
184
+ }]);
185
+ const [result] = await bridge.adapter().collect!(ctx, true);
186
+ expect(result.error).toBeUndefined();
187
+ return result.reports.find(report => report.name === "inventory.units")?.content;
188
+ };
189
+ expect(await capture(false)).toBe("manifest:app/package.json");
190
+ expect(await capture(true)).toBe("manifest:app/fixtures/package.json\nmanifest:app/package.json");
191
+ } finally { await fs.rm(root, { recursive: true, force: true }); }
192
+ });
@@ -33,12 +33,12 @@ export interface K3sCoverageDependencies {
33
33
  /** One component-owned bridge; runtime adapters remain unaware of Kubernetes. */
34
34
  export function k3sCoverage(images: readonly KubernetesImage[]) {
35
35
  const declarations = images.flatMap(image => typeof image === "string" || image.coverage === undefined ? [] : [{
36
- reference: canonicalImageReference(image.reference), coverage: image.coverage,
36
+ reference: canonicalImageReference(image.reference), coverage: image.coverage, changeRules: image.changeRules,
37
37
  }]);
38
38
  for (const image of declarations) {
39
39
  const adapters = coverageAdapters(image.coverage);
40
40
  if (adapters.some(adapter => adapter.load || adapter.collect)) throw new Error("k3s image coverage accepts container adapters; put browser coverage on the cluster service");
41
- applyCoverageAdapters(image.reference, { image: { type: "registry", reference: image.reference }, coverage: image.coverage });
41
+ applyCoverageAdapters(image.reference, { image: { type: "registry", reference: image.reference }, coverage: image.coverage, changeRules: image.changeRules });
42
42
  }
43
43
  const metadata = new Map<string, ImageInfo>();
44
44
  const service = (cluster: string, reference: string) => `${cluster}/image-${hash(reference)}`;
@@ -136,7 +136,7 @@ export function k3sCoverage(images: readonly KubernetesImage[]) {
136
136
  // adapter's functions would otherwise disappear from JSON.stringify.
137
137
  toJSON() { return { adapter: "k3s-containers", images: declarations.map(image => ({
138
138
  reference: image.reference,
139
- config: applyCoverageAdapters(image.reference, { image: { type: "registry", reference: image.reference }, coverage: image.coverage }),
139
+ config: applyCoverageAdapters(image.reference, { image: { type: "registry", reference: image.reference }, coverage: image.coverage, changeRules: image.changeRules }),
140
140
  })) }; },
141
141
  async collect(ctx, baseline) {
142
142
  const root = path.join(ctx.reportDir, ".pods");
@@ -176,7 +176,7 @@ export function k3sCoverage(images: readonly KubernetesImage[]) {
176
176
  }
177
177
  }
178
178
  const captured = await captureCoverageTarget({
179
- coverage: declaration.coverage, root: instance.root, baseline,
179
+ coverage: declaration.coverage, changeRules: declaration.changeRules ?? ctx.changeRules, root: instance.root, baseline,
180
180
  inventoryAvailable: baseline && adapters.some(a => a.inventory) ? !!await livePod() : false,
181
181
  counterKey: `${service(ctx.service, instance.image)}/${id}`,
182
182
  context: { ...ctx, service: service(ctx.service, instance.image), reportDir: dir,
@@ -2,6 +2,7 @@ import { promises as fs } from "node:fs";
2
2
  import path from "node:path";
3
3
  import { createHash } from "node:crypto";
4
4
  import type { CoverageAdapter } from "../coverage.js";
5
+ import { COMMON_CHANGE_RULES, NODE_CHANGE_RULES, type ChangeRule } from "../change-rules.js";
5
6
  import { rawFetch } from "../harness/raw-fetch.js";
6
7
 
7
8
  // Drain all work even on failure: no writes may outlive the capture boundary.
@@ -21,6 +22,17 @@ export function supabaseFunctionsCoverage(functionRoot: string): CoverageAdapter
21
22
  name: "supabase-functions",
22
23
  reports: "delta",
23
24
  sourceOptions: { sourceRoot: "." },
25
+ // Ordinary modules follow callers, including shared modules outside the
26
+ // functions directory. Concrete reach still limits which paths this
27
+ // service owns. Directory-discovered entrypoints remain in the inventory.
28
+ changeRules: [
29
+ ...["**/deno.json", "**/deno.jsonc", "**/deno.lock", "**/import_map.json"].map(pattern => ({ pattern, action: "full" as const })),
30
+ ...NODE_CHANGE_RULES,
31
+ ...["config.toml", "config.json"].map(pattern => ({ root: path.posix.dirname(functionRoot), pattern, action: "full" as const })),
32
+ // Migration directory conventions in the JS preset must not make
33
+ // incidental Markdown defeat the shared documentation default.
34
+ ...COMMON_CHANGE_RULES,
35
+ ] satisfies ChangeRule[],
24
36
  ownsReport: name => name.startsWith("edge-") || name.startsWith("edge-scripts/"),
25
37
  async inventory(ctx) {
26
38
  // Functions are discovered by directory, not by an import in user code.
@@ -11,6 +11,9 @@ import { join } from "node:path";
11
11
  import { google } from "./google.js";
12
12
  import { supabase } from "./supabase.js";
13
13
  import type { ServiceConfig, ServicesMap } from "../index.js";
14
+ import { serviceChangePolicy } from "../coverage.js";
15
+ import { evaluateChangePolicy } from "../change-rules.js";
16
+ import { reachContains } from "../harness/reach.js";
14
17
 
15
18
  let root: string;
16
19
  const prevWorkspace = process.env.SPECTEST_WORKSPACE;
@@ -93,6 +96,29 @@ function expand(stack: ReturnType<typeof supabase>, name = "supabase"): Services
93
96
  const svc = (parts: ServicesMap, key: string): ServiceConfig => parts[key] as ServiceConfig;
94
97
 
95
98
  describe("supabase({ dir })", () => {
99
+ test("database reach follows an alternate directory while preserving runtime Markdown templates", () => {
100
+ mkdirSync(join(root, "custom/backend/migrations"), { recursive: true });
101
+ writeFileSync(join(root, "custom/invite[en].md"), "Welcome {{ .Token }}");
102
+ const parts = expand(supabase({
103
+ dir: "custom/backend",
104
+ configOverrides: { auth: { email: { template: { invite: { content_path: "custom/invite[en].md" } } } } },
105
+ }));
106
+ const db = svc(parts, "db");
107
+ expect(db.inputs).toEqual([
108
+ "custom/backend/config.toml", "custom/backend/config.json", "custom/backend/migrations", "custom/backend/seed.sql", "custom/invite[en].md",
109
+ ]);
110
+ expect(db.volumes?.[0]).toMatchObject({ source: join(root, "custom/backend"), reachExempt: true });
111
+ const reach = { version: 1, sources: [{ inputs: [...db.inputs!] }], prefixes: [] };
112
+ expect(reachContains(reach, "custom/backend/migrations/new.sql")).toBe(true);
113
+ expect(reachContains(reach, "custom/backend/functions/new/index.ts")).toBe(false);
114
+ expect(reachContains(reach, "supabase/config.toml")).toBe(false);
115
+ const policy = serviceChangePolicy(db);
116
+ expect(evaluateChangePolicy(policy, "custom/backend/migrations/notes.md")).toBe("ignore");
117
+ expect(evaluateChangePolicy(policy, "custom/invite[en].md")).toBe("full");
118
+ expect(evaluateChangePolicy(policy, "custom/invitee.md")).toBe("ignore");
119
+ expect(evaluateChangePolicy(policy, "custom/backend/config.json")).toBe("full");
120
+ });
121
+
96
122
  test("overrides hook addresses without moving migrations or leaking env references", () => {
97
123
  const stack = supabase({
98
124
  dir: "supabase",
@@ -6,6 +6,10 @@ import { join } from "node:path";
6
6
  import { excludeAllBut, functionsReach, importMapLocalTargets, importSpecifiers } from "./supabase-functions-reach.js";
7
7
  import { serviceDockerignore } from "../harness/build-context.js";
8
8
  import { projectReach, reachContains } from "../harness/reach.js";
9
+ import { inventoryProjectFiles, serviceChangePolicy } from "../coverage.js";
10
+ import { evaluateChangePolicy } from "../change-rules.js";
11
+ import type { ServiceConfig } from "../index.js";
12
+ import { supabaseFunctionsCoverage } from "./supabase-coverage.js";
9
13
 
10
14
  let root: string;
11
15
 
@@ -71,6 +75,31 @@ describe("parsers", () => {
71
75
  });
72
76
 
73
77
  describe("supabase() wiring", () => {
78
+ test("ignored test fixtures do not change inventory while actual function config and discovery do", async () => {
79
+ const adapter = supabaseFunctionsCoverage("supabase/functions");
80
+ const service = { coverage: { adapters: [adapter] } };
81
+ const files: Record<string, string> = {
82
+ "supabase/functions/hello/index.ts": "Deno.serve(() => new Response());",
83
+ "supabase/functions/hello/deno.json": '{"imports":{}}',
84
+ "supabase/functions/__tests__/fixture/deno.json": '{"imports":{}}',
85
+ "supabase/functions/__tests__/fixture/package.json": '{"dependencies":{}}',
86
+ };
87
+ const inventory = () => adapter.inventory!({ service: "edge", signal: AbortSignal.timeout(1000),
88
+ projectFiles: async () => inventoryProjectFiles(service, Object.keys(files)),
89
+ readProjectFile: async p => files[p] ?? null,
90
+ exec: async () => { throw new Error("inventory should only read project metadata"); },
91
+ });
92
+ const baseline = await inventory();
93
+ files["supabase/functions/__tests__/fixture/deno.json"] = '{"imports":{"x":"./fixture.ts"}}';
94
+ files["supabase/functions/__tests__/fixture/package.json"] = '{"dependencies":{"x":"2"}}';
95
+ expect(await inventory()).toEqual(baseline);
96
+ files["supabase/functions/hello/deno.json"] = '{"imports":{"x":"./helper.ts"}}';
97
+ const configured = await inventory();
98
+ expect(configured).not.toEqual(baseline);
99
+ files["supabase/functions/new/index.ts"] = "Deno.serve(() => new Response());";
100
+ expect(await inventory()).not.toEqual(configured);
101
+ });
102
+
74
103
  test("the functions image context is the module graph, and the root mount is reach-exempt", async () => {
75
104
  const prev = process.env.SPECTEST_WORKSPACE;
76
105
  process.env.SPECTEST_WORKSPACE = root;
@@ -83,7 +112,18 @@ describe("supabase() wiring", () => {
83
112
  expect(fn.image.exclude?.slice(0, 9)).toEqual(["**", "!lib/db", "!lib/db/**", "!packages/shared/src", "!packages/shared/src/**", "!packages/util", "!packages/util/**", "!supabase/functions", "!supabase/functions/**"]);
84
113
  expect(fn.image.exclude?.at(-1)).toBe("**/.venv/**");
85
114
  expect(fn.volumes?.[0]).toMatchObject({ source: root, reachExempt: true });
86
- expect((parts.db as { inputs?: readonly string[] }).inputs).toEqual(["supabase"]);
115
+ const db = parts.db as ServiceConfig;
116
+ expect(db.inputs).toEqual(["supabase/config.toml", "supabase/config.json", "supabase/migrations", "supabase/seed.sql"]);
117
+ const dbRules = serviceChangePolicy(db);
118
+ expect(evaluateChangePolicy(dbRules, "supabase/migrations/002.sql")).toBe("full");
119
+ expect(evaluateChangePolicy(dbRules, "supabase/config.json")).toBe("full");
120
+ expect(evaluateChangePolicy(dbRules, "supabase/seed.sql")).toBe("full");
121
+ expect(evaluateChangePolicy(dbRules, "supabase/migrations/AGENTS.md")).toBe("ignore");
122
+ expect(projectReach(["supabase/functions/hello/index.ts", "supabase/functions/hello/index_test.ts"], [{ inputs: [...db.inputs!] }])).toEqual([]);
123
+ const fnRules = serviceChangePolicy(parts.functions as ServiceConfig);
124
+ expect(evaluateChangePolicy(fnRules, "packages/shared/src/new-helper.ts")).toBe("coverage");
125
+ expect(evaluateChangePolicy(fnRules, "supabase/functions/hello/index_test.ts")).toBe("ignore");
126
+ expect(evaluateChangePolicy(fnRules, "supabase/functions/deno.json")).toBe("full");
87
127
  const sources = [
88
128
  { context: fn.image.context, ignore: serviceDockerignore(null, fn.image.exclude) },
89
129
  { inputs: [...(parts.db as { inputs: string[] }).inputs] },
@@ -41,7 +41,7 @@ test("hundreds of sorted migrations and a seed use one exec without reading SQL
41
41
  for (let i = 558; i >= 0; i--) writeFileSync(join(root, "migrations", `${String(i).padStart(4, "0")}.sql`), "select 1");
42
42
  writeFileSync(join(root, "migrations", "ignored.txt"), "not SQL");
43
43
  const services = parts(root);
44
- expect(services.db!.volumes).toContainEqual({ source: root, target: "/spectest/supabase", readOnly: true });
44
+ expect(services.db!.volumes).toContainEqual({ source: root, target: "/spectest/supabase", readOnly: true, reachExempt: true });
45
45
  const calls: { service: string; command: string[]; opts?: ExecOpts }[] = [];
46
46
  await services.migrations!.setup!({
47
47
  exec: async (service: string, command: string[], opts?: ExecOpts) => {
@@ -77,6 +77,7 @@ import { SUPABASE_WEBHOOKS_SQL } from "./supabase-webhooks.js";
77
77
  import { postgresTls } from "./postgres-tls.js";
78
78
  import { supabasePoolerService } from "./supabase-pooler.js";
79
79
  import { SUPABASE_COVERAGE_COLLECTOR, supabaseFunctionsCoverage } from "./supabase-coverage.js";
80
+ import { COMMON_CHANGE_RULES } from "../change-rules.js";
80
81
  import {
81
82
  mapSupabaseConfig,
82
83
  parseSupabaseConfigFile,
@@ -1932,20 +1933,33 @@ export function supabase(opts: SupabaseOptions = {}): SupabaseStack {
1932
1933
  const parts: ServicesMap = {};
1933
1934
 
1934
1935
  // ── db ──────────────────────────────────────────────────────────
1935
- // What the stack reads from the project: the Supabase folder
1936
- // (config.toml, migrations, seed, functions) and the mail templates,
1937
- // which may sit anywhere in the repo. Declared as inputs so a change
1938
- // to any of them is never taken as one that can affect no test; the
1939
- // migrations volume carries the folder too, but only when there is
1940
- // a migrations directory, and a config-only project must count as
1941
- // well.
1942
- const inputs: string[] = [
1943
- ...(opts.dir !== undefined && !opts.dir.startsWith("/") ? [opts.dir] : []),
1944
- ...templateBodies.map(([, spec]) => spec.path).filter((t) => !t.startsWith("/")),
1945
- ];
1936
+ // Track actual setup inputs, including currently absent conventional
1937
+ // paths so newly added config/migrations still select the full suite.
1938
+ // Functions own their module graph separately; giving the database the
1939
+ // entire folder would veto every function's source-selection policy.
1940
+ const projectRelative = (absolute: string): string | undefined =>
1941
+ absolute === root ? "." : absolute.startsWith(root + "/") ? absolute.slice(root.length + 1) : undefined;
1942
+ const inputs = [...new Set([
1943
+ ...(dirPath === null ? [] : ["config.toml", "config.json", "migrations", "seed.sql"].map(p => projectRelative(join(dirPath, p)))),
1944
+ ...templateBodies.map(([, spec]) => projectRelative(absProjectPath(spec.path))),
1945
+ ].filter((p): p is string => p !== undefined))];
1946
+ const literalGlob = (p: string) => p.replace(/[\\*?\[\]]/g, "\\$&");
1946
1947
  const dbDef: ServiceDefinition = {
1947
1948
  image: { type: "registry", reference: opts.db?.image ?? IMAGES.db },
1948
1949
  ...(inputs.length > 0 ? { inputs } : {}),
1950
+ changeRules: [
1951
+ ...inputs.flatMap(p => [
1952
+ { pattern: literalGlob(p), action: "full" as const },
1953
+ { pattern: `${literalGlob(p)}/**`, action: "full" as const },
1954
+ ]),
1955
+ ...COMMON_CHANGE_RULES,
1956
+ // A configured template is runtime content even if its extension
1957
+ // is Markdown. Its exact declaration overrides the common ignore.
1958
+ ...templateBodies.flatMap(([, spec]) => {
1959
+ const p = projectRelative(absProjectPath(spec.path));
1960
+ return p === undefined ? [] : [{ pattern: literalGlob(p), action: "full" as const }];
1961
+ }),
1962
+ ],
1949
1963
  // `args` (not `command`): keep the image's docker-entrypoint.sh so it
1950
1964
  // runs initdb + its baked init-scripts before starting.
1951
1965
  args: [
@@ -1971,7 +1985,7 @@ export function supabase(opts: SupabaseOptions = {}): SupabaseStack {
1971
1985
  // Read SQL directly, including large seeds, without copying it through
1972
1986
  // the harness and docker exec stdin. Keep this out of PGDATA.
1973
1987
  ...(migrationsPath === null ? {} : {
1974
- volumes: [{ source: dirname(migrationsPath), target: SQL_MOUNT, readOnly: true }],
1988
+ volumes: [{ source: dirname(migrationsPath), target: SQL_MOUNT, readOnly: true, reachExempt: true }],
1975
1989
  }),
1976
1990
  ports: [5432],
1977
1991
  // `pg_isready` succeeds only once the image finishes initdb + its
@@ -1,3 +1,4 @@
1
+ import { BUN_CHANGE_RULES } from "./change-rules.js";
1
2
  import { appendEnvFlag, node, NODE_COVERAGE_HOOK, NODE_COVERAGE_HOOK_PATH, type CoverageAdapter, type NodeCoverageOptions } from "./coverage.js";
2
3
  import { access } from "node:fs/promises";
3
4
  import path from "node:path";
@@ -12,7 +13,7 @@ export interface BunCoverageOptions extends NodeCoverageOptions {
12
13
  }
13
14
 
14
15
  export function bun(options: BunCoverageOptions = {}): CoverageAdapter {
15
- const adapter = node(options);
16
+ const adapter = node({ ...options, changeRules: options.changeRules === undefined ? BUN_CHANGE_RULES : options.changeRules });
16
17
  return {
17
18
  ...adapter,
18
19
  name: "bun",