@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
@@ -0,0 +1,45 @@
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 {
6
+ root?: string;
7
+ pattern: string;
8
+ action: ChangeAction;
9
+ }
10
+ export interface ResolvedChangeRule extends ChangeRule {
11
+ root: string;
12
+ }
13
+ export interface ChangePolicy {
14
+ defaults: readonly (readonly ResolvedChangeRule[])[];
15
+ overrides: readonly ResolvedChangeRule[];
16
+ }
17
+ export interface ChangeRuleAdapter {
18
+ changeRules?: readonly ChangeRule[];
19
+ /** Wrappers preserve independent nested adapter policies, including roots. */
20
+ changeRuleGroups?: readonly (readonly ChangeRule[])[];
21
+ sourceOptions?: {
22
+ sourceRoot?: string;
23
+ } | ((...args: any[]) => unknown);
24
+ }
25
+ export interface ChangeRuleService {
26
+ /** Explicit rules override all defaults. false disables defaults. */
27
+ changeRules?: readonly ChangeRule[] | false;
28
+ coverage?: boolean | {
29
+ sourceRoot?: string;
30
+ };
31
+ }
32
+ export declare function compileChangeGlob(pattern: string): RegExp;
33
+ export declare function matchesChangeGlob(pattern: string, path: string): boolean;
34
+ export declare function validateChangeRules(value: unknown): asserts value is readonly ChangeRule[];
35
+ export declare const COMMON_CHANGE_RULES: readonly ChangeRule[];
36
+ export declare const JAVASCRIPT_CHANGE_RULES: readonly ChangeRule[];
37
+ export declare const NODE_CHANGE_RULES: readonly ChangeRule[];
38
+ export declare const BROWSER_CHANGE_RULES: readonly ChangeRule[];
39
+ export declare const BUN_CHANGE_RULES: readonly ChangeRule[];
40
+ export declare const NEXT_CHANGE_RULES: readonly ChangeRule[];
41
+ export declare const RUST_CHANGE_RULES: readonly ChangeRule[];
42
+ export declare function resolveChangePolicy(service: ChangeRuleService, adapters?: readonly ChangeRuleAdapter[]): ChangePolicy;
43
+ /** Compile once for scans over a source tree; regexes carry no mutable flags. */
44
+ export declare function compileChangePolicy(policy: ChangePolicy): (path: string) => ChangeAction | undefined;
45
+ export declare function evaluateChangePolicy(policy: ChangePolicy, path: string): ChangeAction | undefined;
@@ -0,0 +1,179 @@
1
+ const literal = (c) => /[\\^$.*+?()[\]{}|/]/u.test(c) ? `\\${c}` : c;
2
+ const classLiteral = (c) => ["\\", "[", "]", "^", "-"].includes(c) ? `\\${c}` : c;
3
+ export function compileChangeGlob(pattern) {
4
+ if (!pattern || pattern.includes("\0") || pattern.startsWith("/") || pattern.split("/").some(p => p === "" || p === "." || p === ".."))
5
+ throw new Error("change-rule pattern must be a nonempty relative glob without traversal");
6
+ const chars = [...pattern];
7
+ let out = "";
8
+ for (let i = 0; i < chars.length; i++) {
9
+ const c = chars[i];
10
+ if (c === "\\") {
11
+ const next = chars[++i];
12
+ if (next === undefined || next === "/")
13
+ throw new Error("invalid glob escape");
14
+ out += literal(next);
15
+ }
16
+ else if (c === "*") {
17
+ if (chars[i + 1] === "*") {
18
+ i++;
19
+ if ((i > 1 && chars[i - 2] !== "/") || (i + 1 < chars.length && chars[i + 1] !== "/"))
20
+ throw new Error("** must occupy a whole path segment");
21
+ if (chars[i + 1] === "/") {
22
+ i++;
23
+ out += "(?:[\\s\\S]*/)?";
24
+ }
25
+ else
26
+ out += "[\\s\\S]*";
27
+ }
28
+ else
29
+ out += "[^/]*";
30
+ }
31
+ else if (c === "?")
32
+ out += "[^/]";
33
+ else if (c === "[") {
34
+ let negate = false;
35
+ if (chars[i + 1] === "!") {
36
+ negate = true;
37
+ i++;
38
+ }
39
+ const members = [];
40
+ let closed = false;
41
+ while (++i < chars.length) {
42
+ let ch = chars[i];
43
+ if (ch === "]") {
44
+ closed = true;
45
+ break;
46
+ }
47
+ let escaped = false;
48
+ if (ch === "\\") {
49
+ ch = chars[++i];
50
+ escaped = true;
51
+ if (ch === undefined)
52
+ throw new Error("invalid class escape");
53
+ }
54
+ if (ch === "/")
55
+ throw new Error("glob classes cannot match separators");
56
+ members.push({ c: ch, escaped });
57
+ }
58
+ if (!closed || !members.length)
59
+ throw new Error("unterminated or empty glob class");
60
+ let body = "";
61
+ for (let j = 0; j < members.length; j++) {
62
+ const m = members[j];
63
+ if (members[j + 1]?.c === "-" && !members[j + 1].escaped && members[j + 2]) {
64
+ const end = members[j + 2];
65
+ if (m.c.codePointAt(0) > end.c.codePointAt(0) || (m.c.codePointAt(0) <= 47 && end.c.codePointAt(0) >= 47))
66
+ throw new Error("invalid glob range");
67
+ body += `${classLiteral(m.c)}-${classLiteral(end.c)}`;
68
+ j += 2;
69
+ }
70
+ else
71
+ body += classLiteral(m.c);
72
+ }
73
+ // Prefix slash in a negated class so it remains segment-scoped.
74
+ out += `[${negate ? "^/" : ""}${body}]`;
75
+ }
76
+ else
77
+ out += literal(c);
78
+ }
79
+ return new RegExp(`^(?:${out})$(?![\\s\\S])`, "u");
80
+ }
81
+ export function matchesChangeGlob(pattern, path) { return compileChangeGlob(pattern).test(path); }
82
+ export function validateChangeRules(value) {
83
+ if (!Array.isArray(value))
84
+ throw new Error("changeRules must be an array or false");
85
+ for (const rule of value) {
86
+ 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))
87
+ throw new Error("invalid change rule");
88
+ if (rule.root !== undefined && (typeof rule.root !== "string" || (rule.root !== "." && (!rule.root || rule.root.includes("\\") || rule.root.includes("\0") || rule.root.split("/").some((p) => !p || p === "." || p === "..")))))
89
+ throw new Error("change-rule root must be repository-relative");
90
+ compileChangeGlob(rule.pattern);
91
+ }
92
+ }
93
+ const freeze = (rules) => Object.freeze(rules.map(r => Object.freeze(r)));
94
+ export const COMMON_CHANGE_RULES = freeze(["**/*.md", "**/*.markdown", "**/*.MD", "**/*.MARKDOWN"].map(pattern => ({ pattern, action: "ignore" })));
95
+ const extensions = ["js", "jsx", "mjs", "cjs", "ts", "tsx", "mts", "cts"];
96
+ const full = (pattern) => ({ pattern, action: "full" });
97
+ const ignore = (pattern) => ({ pattern, action: "ignore" });
98
+ const coverage = (pattern) => ({ pattern, action: "coverage" });
99
+ export const JAVASCRIPT_CHANGE_RULES = freeze([
100
+ ...extensions.map(ext => coverage(`**/*.${ext}`)),
101
+ full("**/migrations/**"),
102
+ ...extensions.flatMap(ext => [full(`**/*.config.${ext}`), full(`**/.babelrc.${ext}`), full(`**/.pnp.${ext}`), full(`**/.pnp.loader.${ext}`)]),
103
+ ...["**/tsconfig*.json", "**/jsconfig*.json", "**/.babelrc", "**/.babelrc.json", "**/.swcrc", "**/.env", "**/.env.*"].map(full),
104
+ ...extensions.flatMap(ext => [ignore(`**/test.${ext}`), ignore(`**/spec.${ext}`)]),
105
+ ...extensions.flatMap(ext => [".test", ".spec", "_test", "_spec", ".test-d", ".spec-d", ".cy"].map(suffix => ignore(`**/*${suffix}.${ext}`))),
106
+ ...["**/__tests__/**", "**/__mocks__/**", "**/__snapshots__/**", "test/**", "tests/**", "e2e/**", "cypress/**", "playwright/**"].map(ignore),
107
+ ...[...extensions, "json"].flatMap(ext => ["jest", "vitest", "playwright", "cypress"].map(tool => ignore(`**/${tool}.config.${ext}`))),
108
+ ...COMMON_CHANGE_RULES,
109
+ ]);
110
+ export const NODE_CHANGE_RULES = JAVASCRIPT_CHANGE_RULES;
111
+ export const BROWSER_CHANGE_RULES = JAVASCRIPT_CHANGE_RULES;
112
+ export const BUN_CHANGE_RULES = freeze([...NODE_CHANGE_RULES, full("**/bunfig.toml")]);
113
+ const sharedNext = ["layout", "template", "error", "global-error", "loading", "not-found", "default", "forbidden", "unauthorized", "middleware", "proxy", "instrumentation", "instrumentation-client", "mdx-components", "_app", "_document", "_error"];
114
+ export const NEXT_CHANGE_RULES = freeze([
115
+ ...JAVASCRIPT_CHANGE_RULES,
116
+ // Pages-router files are routes even if their names resemble unit tests.
117
+ ...["pages", "src/pages"].flatMap(root => extensions.map(ext => coverage(`${root}/**/*.${ext}`))),
118
+ ...["app", "src/app"].flatMap(root => extensions.flatMap(ext => [coverage(`${root}/**/page.${ext}`), coverage(`${root}/**/route.${ext}`)])),
119
+ full("**/*.mdx"), full("**/*.MDX"),
120
+ ...extensions.flatMap(ext => sharedNext.map(name => full(`**/${name}.${ext}`))),
121
+ ]);
122
+ export const RUST_CHANGE_RULES = freeze([
123
+ coverage("**/*.rs"), ignore("tests/**"), ignore("benches/**"),
124
+ ...["**/Cargo.toml", "**/Cargo.lock", "**/.cargo/config", "**/.cargo/config.toml", "**/rust-toolchain", "**/rust-toolchain.toml", "**/build.rs", "src/bin/**"].map(full),
125
+ ...COMMON_CHANGE_RULES,
126
+ ]);
127
+ export function resolveChangePolicy(service, adapters = []) {
128
+ if (service.changeRules !== undefined && service.changeRules !== false)
129
+ validateChangeRules(service.changeRules);
130
+ const serviceRoot = typeof service.coverage === "object" ? service.coverage.sourceRoot ?? "." : ".";
131
+ const resolve = (rules, root) => {
132
+ validateChangeRules(rules);
133
+ const out = rules.map(r => ({ ...r, root: r.root ?? root }));
134
+ validateChangeRules(out);
135
+ return out;
136
+ };
137
+ const defaults = service.changeRules === false ? [] : [resolve([{ pattern: "spectest/**", action: "full" }, { pattern: "spectest/tests/**", action: "coverage" }, ...COMMON_CHANGE_RULES], ".")];
138
+ if (service.changeRules !== false)
139
+ for (const adapter of adapters) {
140
+ const groups = [...(adapter.changeRules ? [adapter.changeRules] : []), ...(adapter.changeRuleGroups ?? [])];
141
+ const root = typeof adapter.sourceOptions === "object" ? adapter.sourceOptions.sourceRoot ?? serviceRoot : serviceRoot;
142
+ for (const group of groups) {
143
+ if (typeof adapter.sourceOptions === "function" && group.some(r => r.root === undefined))
144
+ throw new Error("dynamic sourceOptions requires explicit change-rule roots");
145
+ defaults.push(resolve(group, root));
146
+ }
147
+ }
148
+ return { defaults, overrides: resolve(service.changeRules || [], serviceRoot) };
149
+ }
150
+ /** Compile once for scans over a source tree; regexes carry no mutable flags. */
151
+ export function compileChangePolicy(policy) {
152
+ const compile = (rules) => rules.map(r => ({ ...r, regex: compileChangeGlob(r.pattern) }));
153
+ const defaults = policy.defaults.map(compile), overrides = compile(policy.overrides);
154
+ const groupAction = (rules, path) => {
155
+ let result;
156
+ for (const r of rules) {
157
+ const rel = r.root === "." ? path : path === r.root ? "" : path.startsWith(r.root + "/") ? path.slice(r.root.length + 1) : undefined;
158
+ if (rel !== undefined && r.regex.test(rel))
159
+ result = r.action;
160
+ }
161
+ return result;
162
+ };
163
+ const rank = { ignore: 1, coverage: 2, full: 3 };
164
+ return path => {
165
+ const override = groupAction(overrides, path);
166
+ if (override)
167
+ return override;
168
+ let result;
169
+ for (const rules of defaults) {
170
+ const action = groupAction(rules, path);
171
+ if (action && (!result || rank[action] > rank[result]))
172
+ result = action;
173
+ }
174
+ return result;
175
+ };
176
+ }
177
+ export function evaluateChangePolicy(policy, path) {
178
+ return compileChangePolicy(policy)(path);
179
+ }
@@ -13,13 +13,13 @@ const quote = (value) => "'" + value.replaceAll("'", "'\\''") + "'";
13
13
  /** One component-owned bridge; runtime adapters remain unaware of Kubernetes. */
14
14
  export function k3sCoverage(images) {
15
15
  const declarations = images.flatMap(image => typeof image === "string" || image.coverage === undefined ? [] : [{
16
- reference: canonicalImageReference(image.reference), coverage: image.coverage,
16
+ reference: canonicalImageReference(image.reference), coverage: image.coverage, changeRules: image.changeRules,
17
17
  }]);
18
18
  for (const image of declarations) {
19
19
  const adapters = coverageAdapters(image.coverage);
20
20
  if (adapters.some(adapter => adapter.load || adapter.collect))
21
21
  throw new Error("k3s image coverage accepts container adapters; put browser coverage on the cluster service");
22
- applyCoverageAdapters(image.reference, { image: { type: "registry", reference: image.reference }, coverage: image.coverage });
22
+ applyCoverageAdapters(image.reference, { image: { type: "registry", reference: image.reference }, coverage: image.coverage, changeRules: image.changeRules });
23
23
  }
24
24
  const metadata = new Map();
25
25
  const service = (cluster, reference) => `${cluster}/image-${hash(reference)}`;
@@ -136,7 +136,7 @@ export function k3sCoverage(images) {
136
136
  toJSON() {
137
137
  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
  },
142
142
  async collect(ctx, baseline) {
@@ -191,7 +191,7 @@ export function k3sCoverage(images) {
191
191
  }
192
192
  }
193
193
  const captured = await captureCoverageTarget({
194
- coverage: declaration.coverage, root: instance.root, baseline,
194
+ coverage: declaration.coverage, changeRules: declaration.changeRules ?? ctx.changeRules, root: instance.root, baseline,
195
195
  inventoryAvailable: baseline && adapters.some(a => a.inventory) ? !!await livePod() : false,
196
196
  counterKey: `${service(ctx.service, instance.image)}/${id}`,
197
197
  context: { ...ctx, service: service(ctx.service, instance.image), reportDir: dir,
@@ -1,6 +1,7 @@
1
1
  import { promises as fs } from "node:fs";
2
2
  import path from "node:path";
3
3
  import { createHash } from "node:crypto";
4
+ import { COMMON_CHANGE_RULES, NODE_CHANGE_RULES } from "../change-rules.js";
4
5
  import { rawFetch } from "../harness/raw-fetch.js";
5
6
  // Drain all work even on failure: no writes may outlive the capture boundary.
6
7
  async function finishAll(pending) {
@@ -19,6 +20,17 @@ export function supabaseFunctionsCoverage(functionRoot) {
19
20
  name: "supabase-functions",
20
21
  reports: "delta",
21
22
  sourceOptions: { sourceRoot: "." },
23
+ // Ordinary modules follow callers, including shared modules outside the
24
+ // functions directory. Concrete reach still limits which paths this
25
+ // service owns. Directory-discovered entrypoints remain in the inventory.
26
+ changeRules: [
27
+ ...["**/deno.json", "**/deno.jsonc", "**/deno.lock", "**/import_map.json"].map(pattern => ({ pattern, action: "full" })),
28
+ ...NODE_CHANGE_RULES,
29
+ ...["config.toml", "config.json"].map(pattern => ({ root: path.posix.dirname(functionRoot), pattern, action: "full" })),
30
+ // Migration directory conventions in the JS preset must not make
31
+ // incidental Markdown defeat the shared documentation default.
32
+ ...COMMON_CHANGE_RULES,
33
+ ],
22
34
  ownsReport: name => name.startsWith("edge-") || name.startsWith("edge-scripts/"),
23
35
  async inventory(ctx) {
24
36
  // Functions are discovered by directory, not by an import in user code.
@@ -67,6 +67,7 @@ import { SUPABASE_WEBHOOKS_SQL } from "./supabase-webhooks.js";
67
67
  import { postgresTls } from "./postgres-tls.js";
68
68
  import { supabasePoolerService } from "./supabase-pooler.js";
69
69
  import { SUPABASE_COVERAGE_COLLECTOR, supabaseFunctionsCoverage } from "./supabase-coverage.js";
70
+ import { COMMON_CHANGE_RULES } from "../change-rules.js";
70
71
  import { mapSupabaseConfig, parseSupabaseConfigFile, } from "./supabase-config.js";
71
72
  // ──────────────────────────────────────────────────────────────────────────
72
73
  // Digest-pinned upstream images. Update BUILTIN_IMAGES together for compatible
@@ -1449,20 +1450,32 @@ export function supabase(opts = {}) {
1449
1450
  const realtimeHost = `realtime-dev.${g.key("realtime")}`;
1450
1451
  const parts = {};
1451
1452
  // ── db ──────────────────────────────────────────────────────────
1452
- // What the stack reads from the project: the Supabase folder
1453
- // (config.toml, migrations, seed, functions) and the mail templates,
1454
- // which may sit anywhere in the repo. Declared as inputs so a change
1455
- // to any of them is never taken as one that can affect no test; the
1456
- // migrations volume carries the folder too, but only when there is
1457
- // a migrations directory, and a config-only project must count as
1458
- // well.
1459
- const inputs = [
1460
- ...(opts.dir !== undefined && !opts.dir.startsWith("/") ? [opts.dir] : []),
1461
- ...templateBodies.map(([, spec]) => spec.path).filter((t) => !t.startsWith("/")),
1462
- ];
1453
+ // Track actual setup inputs, including currently absent conventional
1454
+ // paths so newly added config/migrations still select the full suite.
1455
+ // Functions own their module graph separately; giving the database the
1456
+ // entire folder would veto every function's source-selection policy.
1457
+ const projectRelative = (absolute) => absolute === root ? "." : absolute.startsWith(root + "/") ? absolute.slice(root.length + 1) : undefined;
1458
+ const inputs = [...new Set([
1459
+ ...(dirPath === null ? [] : ["config.toml", "config.json", "migrations", "seed.sql"].map(p => projectRelative(join(dirPath, p)))),
1460
+ ...templateBodies.map(([, spec]) => projectRelative(absProjectPath(spec.path))),
1461
+ ].filter((p) => p !== undefined))];
1462
+ const literalGlob = (p) => p.replace(/[\\*?\[\]]/g, "\\$&");
1463
1463
  const dbDef = {
1464
1464
  image: { type: "registry", reference: opts.db?.image ?? IMAGES.db },
1465
1465
  ...(inputs.length > 0 ? { inputs } : {}),
1466
+ changeRules: [
1467
+ ...inputs.flatMap(p => [
1468
+ { pattern: literalGlob(p), action: "full" },
1469
+ { pattern: `${literalGlob(p)}/**`, action: "full" },
1470
+ ]),
1471
+ ...COMMON_CHANGE_RULES,
1472
+ // A configured template is runtime content even if its extension
1473
+ // is Markdown. Its exact declaration overrides the common ignore.
1474
+ ...templateBodies.flatMap(([, spec]) => {
1475
+ const p = projectRelative(absProjectPath(spec.path));
1476
+ return p === undefined ? [] : [{ pattern: literalGlob(p), action: "full" }];
1477
+ }),
1478
+ ],
1466
1479
  // `args` (not `command`): keep the image's docker-entrypoint.sh so it
1467
1480
  // runs initdb + its baked init-scripts before starting.
1468
1481
  args: [
@@ -1488,7 +1501,7 @@ export function supabase(opts = {}) {
1488
1501
  // Read SQL directly, including large seeds, without copying it through
1489
1502
  // the harness and docker exec stdin. Keep this out of PGDATA.
1490
1503
  ...(migrationsPath === null ? {} : {
1491
- volumes: [{ source: dirname(migrationsPath), target: SQL_MOUNT, readOnly: true }],
1504
+ volumes: [{ source: dirname(migrationsPath), target: SQL_MOUNT, readOnly: true, reachExempt: true }],
1492
1505
  }),
1493
1506
  ports: [5432],
1494
1507
  // `pg_isready` succeeds only once the image finishes initdb + its
@@ -1,8 +1,9 @@
1
+ import { BUN_CHANGE_RULES } from "./change-rules.js";
1
2
  import { appendEnvFlag, node, NODE_COVERAGE_HOOK, NODE_COVERAGE_HOOK_PATH } from "./coverage.js";
2
3
  import { access } from "node:fs/promises";
3
4
  import path from "node:path";
4
5
  export function bun(options = {}) {
5
- const adapter = node(options);
6
+ const adapter = node({ ...options, changeRules: options.changeRules === undefined ? BUN_CHANGE_RULES : options.changeRules });
6
7
  return {
7
8
  ...adapter,
8
9
  name: "bun",
@@ -1,5 +1,8 @@
1
+ import { type ChangeRule } from "./change-rules.js";
1
2
  import { type CoverageAdapter, type CoverageSourceOptions } from "./coverage.js";
2
3
  export interface NextCoverageOptions extends CoverageSourceOptions {
4
+ /** Replace this adapter's defaults, or false to disable them. */
5
+ changeRules?: readonly ChangeRule[] | false;
3
6
  /** Repository-relative directory represented by the build's source-map root.
4
7
  * Defaults to "." (repository root). Set explicitly for a nested build root. */
5
8
  sourceRoot?: string;
@@ -14,6 +17,8 @@ export interface NextBuildMetadata {
14
17
  generated: string | null;
15
18
  serverMaps: number;
16
19
  browserMaps: number;
20
+ pageExtensions?: string[];
21
+ appRoot?: string;
17
22
  }
18
23
  /** Server + browser coverage for a built Next app. Build-time maps are required;
19
24
  * enable native Next source maps and retain them in the runtime image.
@@ -1,6 +1,7 @@
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 } from "./change-rules.js";
4
5
  import { JAVASCRIPT_GENERATED_SOURCES } from "./coverage-javascript.js";
5
6
  import { node, browser } from "./coverage.js";
6
7
  export const NEXT_COVERAGE_HELPER_PATH = "/spectest/next-coverage.cjs";
@@ -96,7 +97,10 @@ if (require.main === module) {
96
97
  walk(path.join(dist, "server"), true);
97
98
  walk(path.join(dist, "static"), false);
98
99
  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.");
99
- process.stdout.write(JSON.stringify({generated:launcher(m), serverMaps, browserMaps}));
100
+ const appRoot = typeof m.relativeAppDir === "string" ? m.relativeAppDir :
101
+ (typeof m.appDir === "string" && typeof m.config.turbopack?.root === "string" ? path.relative(m.config.turbopack.root, m.appDir) : "");
102
+ process.stdout.write(JSON.stringify({generated:launcher(m), serverMaps, browserMaps,
103
+ pageExtensions:m.config.pageExtensions || ["tsx","ts","jsx","js"], appRoot}));
100
104
  } catch (e) { console.error("Next coverage: " + e.message); process.exitCode = 1; }
101
105
  }
102
106
  `;
@@ -120,7 +124,7 @@ export function next(options = {}) {
120
124
  if (value !== undefined && (!value || path.posix.isAbsolute(value) || value.split("/").some(p => p === "..") || /[\\\0\r\n]/.test(value)))
121
125
  throw new Error(`coverage.next ${key} must be a relative runtime directory`);
122
126
  }
123
- const server = node(), client = browser();
127
+ const server = node({ sourceRoot, changeRules: options.changeRules === undefined ? NEXT_CHANGE_RULES : options.changeRules }), client = browser();
124
128
  const cache = new Map();
125
129
  const metadata = (ctx) => {
126
130
  let value = cache.get(ctx.service);
@@ -133,7 +137,27 @@ export function next(options = {}) {
133
137
  };
134
138
  return {
135
139
  defaultExcludeSources: JAVASCRIPT_GENERATED_SOURCES,
136
- name: "next", reports: "delta", explains: server.explains,
140
+ name: "next", reports: "delta", explains: server.explains, changeRules: server.changeRules?.map(rule => ({ ...rule, root: rule.root ?? sourceRoot })),
141
+ ...(options.changeRules === undefined ? { async resolveChangeRules(ctx) {
142
+ const m = await metadata(ctx);
143
+ const extensions = m.pageExtensions ?? ["tsx", "ts", "jsx", "js"];
144
+ if (!Array.isArray(extensions) || extensions.some(ext => typeof ext !== "string" || !ext || /[^a-zA-Z0-9._-]/.test(ext)))
145
+ throw new Error("invalid Next pageExtensions metadata");
146
+ const appRoot = m.appRoot || ".";
147
+ if (appRoot !== "." && (appRoot.includes("\\") || appRoot.split("/").some(part => !part || part === "." || part === "..")))
148
+ throw new Error("invalid Next app root metadata");
149
+ const root = path.posix.join(sourceRoot, appRoot);
150
+ // Remove the static conventional route allowance; the build's extensions
151
+ // determine which files are actually routes (including custom test-like names).
152
+ const rules = NEXT_CHANGE_RULES.filter(rule => !/^(pages|src\/pages|app|src\/app)\//.test(rule.pattern));
153
+ const routes = extensions.flatMap(ext => [
154
+ ...["pages", "src/pages"].map(dir => ({ root, pattern: `${dir}/**/*.${ext}`, action: "coverage" })),
155
+ ...["app", "src/app"].flatMap(dir => ["page", "route"].map(name => ({ root, pattern: `${dir}/**/${name}.${ext}`, action: "coverage" }))),
156
+ ]);
157
+ // Shared entries still require full runs, even when they are also routes.
158
+ const full = rules.filter(rule => rule.action === "full" && rule.pattern !== "**/migrations/**");
159
+ return [...rules.map(rule => ({ ...rule, root: rule.root ?? sourceRoot })), ...routes, ...full.map(rule => ({ ...rule, root: rule.root ?? sourceRoot }))];
160
+ } } : {}),
137
161
  ownsReport: name => server.ownsReport(name) || client.ownsReport(name),
138
162
  configure(svc, info) {
139
163
  const configured = server.configure(svc, info);
@@ -1,5 +1,7 @@
1
+ import { type ChangeRule } from "./change-rules.js";
1
2
  import type { CoverageAdapter, CoverageSourceOptions } from "./coverage.js";
2
3
  export interface RustCoverageOptions extends CoverageSourceOptions {
4
+ changeRules?: readonly ChangeRule[] | false;
3
5
  /** Only for idle command containers where no Rust process has run yet. */
4
6
  allowEmpty?: boolean;
5
7
  /** Compiler workspace directory, when different from the runtime workdir.
@@ -1,3 +1,4 @@
1
+ import { RUST_CHANGE_RULES, COMMON_CHANGE_RULES, validateChangeRules } 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";
@@ -36,6 +37,9 @@ export async function flushCoverageSockets(dir, prefix, signal) {
36
37
  * The helper exports cumulative per-process LCOV before exit and on request;
37
38
  * no compiler tools or binaries need to be copied out of terminated pods. */
38
39
  export function rust(options = {}) {
40
+ const rules = options.changeRules === undefined ? RUST_CHANGE_RULES : options.changeRules;
41
+ if (rules !== false)
42
+ validateChangeRules(rules);
39
43
  const { sourceRoot, excludeSources, llvmCov, llvmProfdata, ignoreFilenameRegex, buildRoot } = options;
40
44
  if (buildRoot !== undefined && (!buildRoot.startsWith("/") || buildRoot === "/" || /[\0\r\n]/.test(buildRoot) || buildRoot.split("/").includes("..")))
41
45
  throw new Error("Rust buildRoot must be an absolute compiler workspace directory");
@@ -45,6 +49,22 @@ export function rust(options = {}) {
45
49
  }
46
50
  return {
47
51
  name: "rust", reports: "cumulative",
52
+ changeRules: rules === false ? [] : rules.map(rule => ({ ...rule })),
53
+ ...(options.changeRules === undefined ? { async resolveChangeRules(ctx) {
54
+ const root = sourceRoot ?? ctx.sourceRoot ?? ".";
55
+ const prefix = root === "." ? "" : root + "/";
56
+ const files = await ctx.projectFiles();
57
+ const crates = [...new Set(files.filter(file => file.startsWith(prefix) && (file === "Cargo.toml" || file.endsWith("/Cargo.toml"))).map(file => path.posix.dirname(file)))].sort();
58
+ // A workspace can contain many packages; their canonical test directories
59
+ // are relative to each Cargo.toml, not just the adapter's source root.
60
+ // Test fixture packages stay ignored even when they contain binary targets.
61
+ return [
62
+ ...RUST_CHANGE_RULES.map(rule => ({ ...rule, root: rule.root ?? root })),
63
+ ...crates.map(root => ({ root, pattern: "src/bin/**", action: "full" })),
64
+ ...crates.flatMap(root => ["tests/**", "benches/**"].map(pattern => ({ root, pattern, action: "ignore" }))),
65
+ ...COMMON_CHANGE_RULES.map(rule => ({ ...rule, root })),
66
+ ];
67
+ } } : {}),
48
68
  sourceOptions: { ...(sourceRoot !== undefined ? { sourceRoot } : {}), ...(excludeSources ? { excludeSources } : {}) },
49
69
  ownsReport: name => /^rust-.*\.lcov$/.test(name),
50
70
  configure: service => ({ ...service, env: { ...service.env,
@@ -1,3 +1,5 @@
1
+ import { type ChangeRule, type ChangePolicy } from "./change-rules.js";
2
+ export * from "./change-rules.js";
1
3
  import type { ServiceConfig } from "./index.js";
2
4
  import { COVERAGE_CONTAINER_DIR, type CoverageReportsMode } from "./harness/coverage.js";
3
5
  export { COVERAGE_CONTAINER_DIR, type CoverageReportsMode };
@@ -39,6 +41,10 @@ export interface CoverageLoadContext {
39
41
  * of the manifests that declare them (see {@link CoverageAdapter.inventory}).
40
42
  */
41
43
  export interface CoverageInventoryContext {
44
+ /** Effective repository-relative source root for build-specific policy resolution. */
45
+ sourceRoot?: string;
46
+ /** Selection overrides of the owning service, forwarded by container wrappers. */
47
+ changeRules?: readonly ChangeRule[] | false;
42
48
  /** The services-map key. */
43
49
  service: string;
44
50
  /** Run a shell command (`sh -c`) inside the container. Rejects on a
@@ -47,8 +53,8 @@ export interface CoverageInventoryContext {
47
53
  stdout: string;
48
54
  stderr: string;
49
55
  }>;
50
- /** Every file of the uploaded project, project-relative, `/`-separated,
51
- * `node_modules`, `.git` and the like skipped. */
56
+ /** Project-relative files; inventory calls omit paths ignored by effective
57
+ * change policy, as well as node_modules, .git and other upload exclusions. */
52
58
  projectFiles(): Promise<string[]>;
53
59
  /** One project file's text (project-relative path); `null` when absent. */
54
60
  readProjectFile(path: string): Promise<string | null>;
@@ -88,6 +94,12 @@ export type CoverageSourceContext = CoverageInventoryContext;
88
94
  export interface CoverageAdapter {
89
95
  /** Names the adapter in errors (`coverage adapter "node" on service …`). */
90
96
  name: string;
97
+ /** Ordered default rules; service rules are explicit overrides. */
98
+ changeRules?: readonly ChangeRule[];
99
+ /** Resolve build-specific rules once evidence is available; replaces static defaults. */
100
+ resolveChangeRules?(ctx: CoverageSourceContext): Promise<readonly ChangeRule[]>;
101
+ /** Wrappers retain separate policy groups for each nested adapter. */
102
+ changeRuleGroups?: readonly (readonly ChangeRule[])[];
91
103
  /** Archived on this adapter's reports; other adapters retain their own policy. */
92
104
  sourceOptions?: CoverageSourceOptions | ((ctx: CoverageSourceContext) => Promise<CoverageSourceOptions>);
93
105
  /** Generated scaffolding excluded in addition to explicit or legacy source policy. */
@@ -178,6 +190,13 @@ export declare function coverageAdapters(cov: ServiceCoverage | undefined): read
178
190
  * adapter names (`toJSON`), never as functions.
179
191
  */
180
192
  export declare function applyCoverageAdapters<S extends ServiceConfig>(key: string, svc: S): S;
193
+ export declare function resolveAdapterChangeRules(adapter: CoverageAdapter, ctx: CoverageSourceContext): Promise<void>;
194
+ export declare function serviceChangePolicy(svc: Pick<ServiceConfig, "coverage" | "changeRules">, options?: {
195
+ requireResolved?: boolean;
196
+ }): ChangePolicy;
197
+ /** Inventory fingerprints must not turn ignored fixture manifests into changes.
198
+ * Installed runtime dependency inventories remain independent of project files. */
199
+ export declare function inventoryProjectFiles(svc: Pick<ServiceConfig, "coverage" | "changeRules">, files: readonly string[]): string[];
181
200
  /** Resolve adapter policies once per capture. Invalid/overlapping ownership is
182
201
  * an error, so one adapter cannot silently reinterpret another's reports. */
183
202
  export declare function resolveCoverageSourceOptions(adapters: readonly CoverageAdapter[], ctx: CoverageSourceContext, fallback?: CoverageSourceOptions): Promise<{
@@ -305,6 +324,8 @@ export declare function npmManifestUnit(projectPath: string, text: string): stri
305
324
  */
306
325
  export declare const NODE_INVENTORY_SCRIPT = "\"use strict\";\nconst fs = require(\"node:fs\"), path = require(\"node:path\");\nconst out = new Set();\nfunction isRealDir(p) { try { return fs.lstatSync(p).isDirectory(); } catch (e) { return false; } }\nfunction readPkg(d) {\n try {\n const j = JSON.parse(fs.readFileSync(path.join(d, \"package.json\"), \"utf8\"));\n if (j && typeof j.name === \"string\" && j.name) out.add(\"npm:\" + j.name + (typeof j.version === \"string\" && j.version ? \"@\" + j.version : \"\"));\n } catch (e) {}\n}\nfunction visit(d, depth) {\n readPkg(d);\n if (depth < 3) { const nested = path.join(d, \"node_modules\"); if (isRealDir(nested)) walkNm(nested, depth + 1); }\n}\nfunction walkNm(nm, depth) {\n let names; try { names = fs.readdirSync(nm); } catch (e) { return; }\n for (const n of names) {\n if (n === \".bin\" || n === \".cache\" || n === \".modules.yaml\") continue;\n if (n === \".pnpm\") {\n let ps; try { ps = fs.readdirSync(path.join(nm, \".pnpm\")); } catch (e) { continue; }\n for (const p of ps) { const inner = path.join(nm, \".pnpm\", p, \"node_modules\"); if (isRealDir(inner)) walkNm(inner, depth + 1); }\n continue;\n }\n if (n.startsWith(\"@\")) {\n let ss; try { ss = fs.readdirSync(path.join(nm, n)); } catch (e) { continue; }\n for (const s of ss) { const d = path.join(nm, n, s); if (isRealDir(d)) visit(d, depth); }\n } else { const d = path.join(nm, n); if (isRealDir(d)) visit(d, depth); }\n }\n}\nlet d = process.cwd();\nfor (;;) {\n const nm = path.join(d, \"node_modules\");\n if (isRealDir(nm)) walkNm(nm, 0);\n const up = path.dirname(d);\n if (up === d) break;\n d = up;\n}\nprocess.stdout.write(Array.from(out).sort().join(\"\\n\") + (out.size ? \"\\n\" : \"\"));\n";
307
326
  export interface NodeCoverageOptions extends CoverageSourceOptions {
327
+ /** Replace this adapter's defaults, or false to disable them. */
328
+ changeRules?: readonly ChangeRule[] | false;
308
329
  /** Generated runtime entrypoints, relative to the service working directory.
309
330
  * Track their exact contents as units rather than inventing repository lines.
310
331
  * A content change changes the inventory and invalidates dependent tests. */
@@ -348,7 +369,10 @@ export declare function collectNodeReports(dir: string): Promise<NodeReportStats
348
369
  * harvested first, and only this service's totals are taken and cleared. Nothing ran ⇒
349
370
  * an lcov with no records, which is a report saying so.
350
371
  */
351
- export declare function browser(options?: CoverageSourceOptions): CoverageAdapter;
372
+ export interface BrowserCoverageOptions extends CoverageSourceOptions {
373
+ changeRules?: readonly ChangeRule[] | false;
374
+ }
375
+ export declare function browser(options?: BrowserCoverageOptions): CoverageAdapter;
352
376
  /**
353
377
  * Run `command` (via `sh -c`) in the container at every capture, so the
354
378
  * service writes a fresh report into `/spectest/coverage/`. The escape