fix(ci): track repository inputs across test projects (#18398)

## Thinking Path

> - Paperclip is the open source app people use to manage AI agents for
work.
> - PR CI selects affected tests before allocating parallel runners.
> - Recent audits retained about 96% of test files despite narrow
changes.
> - Unchanged consumer packages kept full coverage, and source scans
confused data fields and message text with runtime loaders.
> - File reads need explicit edges that imports alone cannot provide.
> - This change follows consumer graphs and known file inputs while
retaining unknown effects.
> - The AWS CODEOWNERS gate stays in front of runner allocation.

## Linked Issues or Issue Description

Refs #18376 and #18362. Narrow source changes still selected most tests
because consumer packages kept full coverage and known file reads were
treated as arbitrary input. Expected behavior is to retain affected
import/file consumers and unknown inputs, then partition only the
selected files.

The shared storage failure found during the audit was fixed upstream in
#18357. The candidate merge ran all 18 managed-skill storage tests
successfully in [Linux
qualification](https://github.com/paperclipai/paperclip/actions/runs/41140209697/job/124352365547).
This branch includes that upstream commit; no additional storage
implementation or permanent diagnostic job is included.

## What Changed

- Assess every Vitest project in the coarse plan, including unchanged
consumer workspaces.
- Add dependency edges for statically resolved module-relative
filesystem reads and directory listings. Keep unknown paths, writes,
escaped APIs, mutable path objects and untracked inputs conservative.
- Distinguish data keys, erased types and message strings from runtime
globals, loaders and mock bypasses. Retain destructured globals, aliased
object reads and mocked filesystem/path resolution.
- Normalize `fs` and `node:fs` before recording coverage so a known read
cannot hide an unknown one.
- Review the Drizzle SQL/schema entry points by hashes of their complete
import closures. CommonJS/dynamic loaders reject the fingerprint;
drivers and migration readers remain opaque, and dependency updates
revoke the review.
- Record file input edges and per-test retention reasons in selection
audit artifacts.
- Add cross-workspace, mixed-import, symlink retargeting, setup-input
and dependency-review regression cases. Object-valued URL constants are
explicitly opaque with and without mutation. Update the developer guide.
- Preserve the AWS CODEOWNERS authorization gate, runner-group
restrictions and existing lane ownership.

## Verification

- Final head `eedad362e84d6b68f0f048c92db6032e45622c46` passed [complete
Actions
CI](https://github.com/paperclipai/paperclip/actions/runs/41144233520)
on attempt 1. Overall status: 57 successful checks, three intended
skips, and one neutral Paperclip review. Both Greptile and Paperclip
rated this head 5/5; zero unresolved review threads.
- All 17 local selector tests passed: `node --test
scripts/ci/test-selection*.test.mjs`. Final [Linux
qualification](https://github.com/paperclipai/paperclip/actions/runs/41144233203/job/124365428832)
passed all 18 selector/impact tests without skips, plus real workspace
selection and cache/routing checks.
- Same-tree local comparison against the previous selector at
`d331d8481a`: Inbox changes selected 466 rather than 700 files (33%
fewer); auth middleware changes selected 2,141 rather than 2,788 files
(23% fewer). The sampled plans retained the same 2 and 24 jobs under
existing weighted budgets. Final Linux qualification selected 466 files
for Inbox and 1,949 for auth/config-test changes; platform-dependent
graphs differ, so these are not a Linux before/after comparison.
Chat/native Runner lanes retain their existing ownership and are
excluded from these counts.
- Local `pnpm -r typecheck`, `pnpm build` and `git diff --check` passed.
The complete local `pnpm test:run` was attempted and stopped after
repeated embedded PostgreSQL startup failures; initdb reported exhausted
macOS semaphore capacity. Full Linux CI passed on the final combined
tree.
- The storage regression was corrected upstream in #18357. [Focused
Linux
verification](https://github.com/paperclipai/paperclip/actions/runs/41140209697/job/124352365547)
ran all 18 managed-skill tests successfully, with no skips; the final
full CI run also passed its server lane.

## Risks

- Unknown effects must retain their consumers. Regression cases cover
mixed builtin spellings, dynamic paths, escaped/mocked APIs, global
aliases, reviewed dependency changes and cross-package file reads.
- Graph analysis now visits unchanged consumers. Its added work must be
included in rollout latency measurements; fewer test files do not by
themselves prove shorter end-to-end PR time.
- The weighted shard budget deliberately remains unchanged. These
samples reduce file execution without reducing allocated jobs.

## Model Used

OpenAI Codex, based on GPT-6, with reasoning, repository inspection,
code execution and GitHub tools. The exact serving variant and context
window are not exposed in this session. No subagents were used.

## Checklist

- [x] I have included a thinking path that traces from project context
to this change
- [x] I have specified the model used (with version and capability
details)
- [x] I have checked ROADMAP.md and confirmed this PR does not duplicate
planned core work
- [x] I have searched GitHub for duplicate or related PRs and linked
them above
- [x] I have either (a) linked existing issues with `Fixes: #` / `Closes
#` / `Refs #` OR (b) described the issue in-PR following the relevant
issue template
- [x] I have not referenced internal/instance-local Paperclip issues or
links (only public GitHub `#NNN` / `github.com/paperclipai/paperclip`
URLs)
- [x] My branch name describes the change (e.g. `docs/...`, `fix/...`)
and contains no internal Paperclip ticket id or instance-derived details
- [x] I have run tests locally and they pass — targeted selector suite;
full local suite limitation documented above
- [x] I have added or updated tests where applicable
- [x] I have updated relevant documentation to reflect my changes
- [x] I have considered and documented any risks above
- [x] All Paperclip CI gates are green
- [x] Greptile is 5/5 with no open P2s, recommendations, or follow-ups
- [x] I will address all Greptile and reviewer comments before
requesting merge

---------

Co-authored-by: Paperclip <noreply@paperclip.ing>
This commit is contained in:
DottaandPaperclip authored and GitHub committed 2026-10-30 21:37:39 -05:00
1 parent 4eaa435bf4
commit 7114582c63
7 files changed
+447 -30

No files matched your search

+23 -4
View File
@@ -207,11 +207,20 @@ of exempt prose, update this policy as well as the task inputs.
For ordinary source PRs (including the audited prose inputs above), a separate `Select Vitest files` job refines the
general and serialized test lanes before their runners are allocated. It runs
on the gate-approved test fleet, loads the real Vitest projects, and follows
imports across workspace packages. Only projects with their own source changes
are narrowed; unchanged consumer projects retain their complete coarse lane
coverage without paying for another graph traversal. Filesystem, subprocess, network, CommonJS,
imports across workspace packages. Every project in the coarse plan is assessed,
including unchanged consumers. Projects without affected tests drop out of the
matrix; an unchanged directory alone does not prove independence. Filesystem, subprocess, network, CommonJS,
dynamic-loader, unresolved, and unapproved external-package consumers are
retained conservatively, including indirect consumers through local helpers.
Module-relative filesystem reads add explicit dependency edges when the path and
read API can be resolved without running code. This includes directory listings
and transitive helper reads. Dynamic paths, writes, escaped filesystem APIs,
mutable or shadowed bindings, generated inputs and paths outside the checkout
remain opaque. Setup-file reads invalidate the whole project when their input
changes. Object-valued constants, including URL objects, remain opaque even without
an observed mutation; inline URL construction and immutable string constants can
be resolved. Symlink reads track both the checked-in link and its physical target.
Missing inputs retain coverage.
The selector explicitly tracks setup/configuration dependencies as well as test
imports. Ordinary source names such as `session-config-metadata.test.ts` or
`setup-state.ts` remain eligible; the words `config` and `setup` alone do not
@@ -220,6 +229,15 @@ inputs. Other resolved setup/config dependencies invalidate their entire project
The small pure-dependency allowlist lives in
`scripts/ci/vitest-selection-graph.mjs`; new external packages remain opaque
until reviewed. This is deliberately broader than `vitest related` alone.
The reviewed Drizzle SQL/schema entry points have no ambient repository inputs.
Their full import closures are content-hashed in
`scripts/ci/reviewed-test-dependencies.mjs`; changed installed contents revoke the
exception. CommonJS and dynamic loaders reject a fingerprint rather than leave
unhashed children outside the closure. Database drivers and migration readers remain opaque. OS information,
in-memory streams, timers and compression do not read repository files themselves;
filesystem or process operations using their outputs retain their normal guards.
The syntax audit distinguishes data keys and erased types from runtime globals.
It examines the called member, not string arguments earlier in a chained call.
For conservative retention, explicit complete local mock factories cut the
mocked implementation's I/O edges. Partial mocks, spies, automocks, mock bypasses,
and dynamic or escaped mocking APIs do not. Edits to a mocked module still use
@@ -238,7 +256,8 @@ jobs; empty lanes allocate none. Serialized files remain serialized, and chat's
line shards, native Runner lanes, Rust, builds, typechecking and E2E retain their
existing selection/execution rules. The job summary reports files and jobs
selected per lane, and the `vitest-selection` artifact records the manifest and
dependency audit. Selected jobs execute exact Vitest test specifications, so
dependency audit, including file-read edges and the first opaque dependency that
retained each test. Selected jobs execute exact Vitest test specifications, so
similarly named `.test.ts` and `.test.tsx` files cannot overlap between shards.
The wrapper consumes and validates the selection before launching Vitest, then
removes selection environment variables from the test process. Nested runner
+147
View File
@@ -0,0 +1,147 @@
import path from "node:path";
import { fileURLToPath, pathToFileURL } from "node:url";
const filesystem = /^(?:node:)?fs(?:\/promises)?$/;
const readers = new Set(["readFile", "readFileSync", "existsSync", "stat", "statSync", "lstat", "lstatSync", "readdir", "readdirSync"]);
export function repositoryFileInputs(file, program) {
if (!program.body.some(node => node.type === "ImportDeclaration" && filesystem.test(node.source.value))) {
return { covered: [], inputs: [] };
}
const parents = new Map(), nodes = [], declarations = new Map(), constants = new Map(), imports = new Map();
const bind = (node) => {
if (!node) return;
if (node.type === "Identifier") declarations.set(node.name, (declarations.get(node.name) ?? 0) + 1);
else if (node.type === "ObjectPattern") node.properties.forEach(p => bind(p.value ?? p.argument));
else if (node.type === "ArrayPattern") node.elements.forEach(bind);
else if (node.type === "AssignmentPattern") bind(node.left);
else if (node.type === "RestElement") bind(node.argument);
};
function visit(node, parent) {
if (!node || typeof node !== "object" || !node.type) return;
parents.set(node, parent); nodes.push(node);
if (node.type === "ImportDeclaration") {
for (const spec of node.specifiers) {
bind(spec.local);
const source = filesystem.test(node.source.value) ? node.source.value.replace(/^node:/, "") : node.source.value;
imports.set(spec.local.name, { source, name: spec.type === "ImportSpecifier" ? spec.imported.name : "*" });
}
}
if (node.type === "VariableDeclarator") {
bind(node.id);
if (parent.kind === "const" && node.id.type === "Identifier") constants.set(node.id.name, node.init);
}
if (/Function/.test(node.type) || node.type === "ArrowFunctionExpression") {
bind(node.id); node.params?.forEach(bind);
}
if (node.type === "ClassDeclaration" || node.type === "ClassExpression") bind(node.id);
if (node.type === "CatchClause") bind(node.param);
if (node.type === "AssignmentExpression") bind(node.left);
if (node.type === "UpdateExpression") bind(node.argument);
for (const value of Object.values(node)) {
if (Array.isArray(value)) value.forEach(child => visit(child, node));
else if (value && typeof value === "object" && value.type) visit(value, node);
}
}
visit(program);
const tainted = new Set();
for (const node of nodes) {
if (node.type === "MetaProperty" && node.meta.name === "import" && node.property.name === "meta") {
const member = parents.get(node), use = parents.get(member);
if (member?.type !== "MemberExpression" || member.computed || !["url", "dirname", "filename"].includes(member.property.name)
|| (use?.type === "AssignmentExpression" && use.left === member)
|| use?.type === "UpdateExpression" || (use?.type === "UnaryExpression" && use.operator === "delete")) tainted.add("import.meta");
}
if (node.type !== "Identifier") continue;
const parent = parents.get(node);
const binding = imports.get(node.name);
if (binding?.name === "*" && /^(?:node:)?(?:path|url)$/.test(binding.source)
&& !parent?.type.startsWith("Import")) {
if (parent?.type !== "MemberExpression" || parent.object !== node
|| parents.get(parent)?.type !== "CallExpression" || parents.get(parent).callee !== parent) tainted.add(node.name);
}
if (node.name === "URL" && !(parent?.type === "NewExpression" && parent.callee === node)) tainted.add("URL");
}
const unique = (name) => declarations.get(name) === 1;
const member = (node, name) => node?.type === "MemberExpression" && !node.computed && node.property.name === name;
const importMeta = node => node?.type === "MetaProperty" && node.meta.name === "import" && node.property.name === "meta";
function imported(node, source, name) {
if (node?.type === "Identifier" && unique(node.name) && !tainted.has(node.name)) {
const entry = imports.get(node.name);
return entry && source.test(entry.source) && entry.name === name;
}
if (member(node, name) && node.object.type === "Identifier" && unique(node.object.name) && !tainted.has(node.object.name)) {
const entry = imports.get(node.object.name);
return entry && source.test(entry.source) && entry.name === "*";
}
return false;
}
function evaluate(node, seen = new Set()) {
if (!node) return undefined;
if (node.type === "Literal" && typeof node.value === "string") return node.value;
if (node.type === "TemplateLiteral" && !node.expressions.length) return node.quasis[0].value.cooked;
if (node.type === "Identifier" && unique(node.name) && constants.has(node.name) && !seen.has(node.name)) {
const value = evaluate(constants.get(node.name), new Set([...seen, node.name]));
return typeof value === "string" ? value : undefined;
}
if (node.type === "Identifier" && !declarations.has(node.name)) {
if (node.name === "__dirname") return path.dirname(file);
if (node.name === "__filename") return file;
}
if (node.type === "MemberExpression" && importMeta(node.object) && !node.computed && !tainted.has("import.meta")) {
if (node.property.name === "url") return pathToFileURL(file).href;
if (node.property.name === "dirname") return path.dirname(file);
if (node.property.name === "filename") return file;
}
if (node.type === "NewExpression" && node.callee.name === "URL" && !declarations.has("URL") && !tainted.has("URL") && node.arguments.length === 2) {
const args = node.arguments.map(arg => evaluate(arg, seen));
if (args.every(arg => typeof arg === "string")) {
try { return new URL(...args); } catch { return undefined; }
}
}
if (node.type === "CallExpression") {
const args = node.arguments.map(arg => evaluate(arg, seen));
if (imported(node.callee, /^(?:node:)?url$/, "fileURLToPath") && args.length === 1) {
try { return fileURLToPath(args[0]); } catch { return undefined; }
}
if (args.every(arg => typeof arg === "string")) {
for (const method of ["resolve", "join", "dirname"]) {
if (!imported(node.callee, /^(?:node:)?path$/, method)) continue;
if (!args.length || !path.isAbsolute(args[0]) || (method === "dirname" && args.length !== 1)) return undefined;
return path[method](...args);
}
}
}
return undefined;
}
const covered = new Set(), invalid = new Set(), inputs = new Map();
for (const node of nodes) {
if (node.type !== "Identifier" || !filesystem.test(imports.get(node.name)?.source ?? "")) continue;
const binding = imports.get(node.name), parent = parents.get(node);
if (parent?.type.startsWith("Import")) continue;
let target = node, method = binding.name;
if (!unique(node.name)) { invalid.add(binding.source); continue; }
if (method === "*" || method === "promises") {
target = parent;
if (method === "*" && member(target, "promises")) target = parents.get(target);
if (target?.type !== "MemberExpression" || target.computed) { invalid.add(binding.source); continue; }
method = target.property.name;
}
const call = parents.get(target);
if (!readers.has(method) || call?.type !== "CallExpression" || call.callee !== target) {
invalid.add(binding.source); continue;
}
let input = evaluate(call.arguments[0]);
if (input instanceof URL) {
try { input = fileURLToPath(input); } catch { input = undefined; }
}
if (typeof input !== "string" || !path.isAbsolute(input)) { invalid.add(binding.source); continue; }
covered.add(binding.source);
const values = inputs.get(binding.source) ?? new Set();
values.add(path.resolve(input)); inputs.set(binding.source, values);
}
return {
covered: [...covered].filter(source => !invalid.has(source)),
inputs: [...new Set([...inputs.values()].flatMap(values => [...values]))],
};
}
+54
View File
@@ -0,0 +1,54 @@
import { createHash } from "node:crypto";
import { readFileSync, realpathSync } from "node:fs";
import path from "node:path";
import { parseSync } from "oxc-parser";
const contracts = new Map([
["drizzle-orm/index.js", "96c38000daaf94419eddbfd168f7dc509962d6088664e96882979bf851e0dca5"],
["drizzle-orm/pg-core/index.js", "7a6f61b63d19ff5feb4403d8f4afdba90c25364d33769c7a4b3be507c0130357"],
]);
export function dependencyFingerprint(file, packageRoot) {
const files = new Map();
function visit(filename) {
filename = realpathSync(filename);
if (!filename.startsWith(`${packageRoot}${path.sep}`)) throw new Error("Dependency escapes reviewed package");
if (files.has(filename)) return;
const source = readFileSync(filename, "utf8"), ast = parseSync(filename, source);
if (ast.errors.length) throw new Error("Unparsed dependency");
const inspect = node => {
if (!node || typeof node !== "object") return;
if (node.type === "ImportExpression" || (node.type === "Identifier"
&& /^(require|createRequire|getBuiltinModule|eval|Function)$/.test(node.name))) throw new Error("Unreviewed runtime loader");
for (const value of Object.values(node)) {
if (Array.isArray(value)) value.forEach(inspect);
else if (value && typeof value === "object" && value.type) inspect(value);
}
};
inspect(ast.program);
files.set(filename, createHash("sha256").update(source).digest("hex"));
for (const node of ast.program.body) {
if (!node.source) continue;
const specifier = node.source.value;
if (!specifier.startsWith(".")) throw new Error("Unreviewed external import");
visit(path.resolve(path.dirname(filename), specifier));
}
}
visit(file);
return createHash("sha256").update(JSON.stringify([...files].map(([file, hash]) =>
[path.relative(packageRoot, file).split(path.sep).join("/"), hash]).sort())).digest("hex");
}
export function dependencyReviews() {
const cache = new Map();
return (file, relative) => {
if (!contracts.has(relative)) return false;
if (!cache.has(file)) {
try {
const packageRoot = file.slice(0, -relative.slice("drizzle-orm/".length).length - 1);
cache.set(file, dependencyFingerprint(file, packageRoot) === contracts.get(relative));
} catch { cache.set(file, false); }
}
return cache.get(file);
};
}
+1 -1
View File
@@ -59,7 +59,7 @@ export async function createTestSelection(plan, head) {
const graphs = [];
const changed = productInputs(plan.changed);
for (const project of projects) {
const graph = await selectProjectTests({ project, changed, onlyOwnChanges: true });
const graph = await selectProjectTests({ project, changed });
graphs.push(graph);
console.log(`${project}: ${graph.selected.length}/${graph.inventory.length} files, ${graph.alwaysRun.length} retained conservatively, ${graph.seconds.toFixed(1)}s`);
for (const owner of Object.values(owners)) if (owner[project] === null) owner[project] = graph.inventory;
+134
View File
@@ -0,0 +1,134 @@
import assert from "node:assert/strict";
import { execFileSync } from "node:child_process";
import { mkdirSync, mkdtempSync, realpathSync, rmSync, symlinkSync, writeFileSync } from "node:fs";
import { tmpdir } from "node:os";
import path from "node:path";
import test from "node:test";
import { parseSync } from "oxc-parser";
import { repositoryFileInputs } from "./repository-file-inputs.mjs";
import { selectProjectTests, sourceDependencies } from "./vitest-selection-graph.mjs";
import { dependencyFingerprint, dependencyReviews } from "./reviewed-test-dependencies.mjs";
const file = "/repo/ui/src/contract.test.ts";
const analyze = source => repositoryFileInputs(file, parseSync(file, source).program);
test("data properties and erased types are not global loaders or mock bypasses", () => {
const data = sourceDependencies(file, 'import {vi} from "vitest"; const schema = {arguments: {}, fetch: false, Function: "name"}; type Factory = Function;');
assert.deepEqual(data.reasons, []);
const chained = sourceDependencies(file, 'import {z} from "zod"; const schema = z.object({message:z.literal("routes require auth; fetch input")}).refine(value => true);');
assert.deepEqual(chained.reasons, []);
for (const source of [
'fetch("https://example.invalid");',
'const load = globalThis.fetch; load("https://example.invalid");',
'const make = globalThis.Function; make("return 1");',
'const scope = globalThis; const make = scope.Function; make("return 1");',
'const {Function: make} = globalThis; make("return 1");',
'const {fetch: request} = globalThis; request("https://example.invalid");',
'import {vi as mocker} from "vitest"; mocker.mock("node:path", () => ({join: () => "/other.ts"}));',
'import {vi} from "vitest"; vi.mock("./input", function() { return arguments[0](); });',
'import {vi} from "vitest"; vi.importActual("./input");',
'const result = (fetch("https://example.invalid") as unknown);',
]) assert.ok(sourceDependencies(file, source).reasons.length, source);
});
test("reviewed SQL builders exclude drivers and revoke approval when imported source changes", t => {
const root = realpathSync("server/node_modules/drizzle-orm");
const review = dependencyReviews();
for (const entry of ["index.js", "pg-core/index.js"]) assert.equal(review(path.join(root, entry), `drizzle-orm/${entry}`), true);
assert.equal(review(path.join(root, "postgres-js/index.js"), "drizzle-orm/postgres-js/index.js"), false);
const fixture = realpathSync(mkdtempSync(path.join(tmpdir(), "ci-review-")));
t.after(() => rmSync(fixture, { recursive: true, force: true }));
const entry = path.join(fixture, "index.js"), leaf = path.join(fixture, "leaf.js");
writeFileSync(entry, 'export * from "./leaf.js";'); writeFileSync(leaf, 'export const value = 1;');
const before = dependencyFingerprint(entry, fixture);
writeFileSync(leaf, 'export const value = 2;');
assert.notEqual(dependencyFingerprint(entry, fixture), before);
assert.equal(dependencyReviews()(entry, "drizzle-orm/index.js"), false);
writeFileSync(leaf, 'import "node:fs";');
assert.throws(() => dependencyFingerprint(entry, fixture), /Unreviewed external import/);
for (const source of ['import("./child.js");', 'require("./child.js");', 'const load = require; load("./child.js");']) {
writeFileSync(leaf, source);
assert.throws(() => dependencyFingerprint(entry, fixture), /Unreviewed runtime loader/);
}
});
test("file input edges require module-relative paths and non-escaping read APIs", () => {
for (const source of [
'import {readFileSync as read} from "node:fs"; read(new URL("./leaf.ts", import.meta.url));',
'import fs from "node:fs"; import path from "node:path"; const root = path.dirname(import.meta.filename); const input = path.join(root, "leaf.ts"); fs.readFileSync(input);',
'import {promises as fs} from "node:fs"; import {fileURLToPath} from "node:url"; fs.readFile(fileURLToPath(new URL("./leaf.ts", import.meta.url)));',
]) {
assert.deepEqual(analyze(source), { covered: ["fs"], inputs: ["/repo/ui/src/leaf.ts"] });
}
for (const source of [
'import fs from "node:fs"; fs.readFileSync(process.env.INPUT);',
'import {readFileSync as known} from "node:fs"; import {readFileSync as unknown} from "fs"; known(new URL("./leaf.ts", import.meta.url)); unknown(process.env.INPUT);',
'import {readFileSync as known} from "fs"; import {readFileSync as unknown} from "node:fs"; known(new URL("./leaf.ts", import.meta.url)); unknown(process.env.INPUT);',
'import fs from "node:fs"; fs.readFileSync("src/leaf.ts");',
'import fs from "node:fs"; const read = fs.readFileSync; read(new URL("./leaf.ts", import.meta.url));',
'import {readFileSync as read} from "node:fs"; function load(read) { read(new URL("./leaf.ts", import.meta.url)); }',
'import fs from "node:fs"; const input = new URL("./leaf.ts", import.meta.url); function load(input) { fs.readFileSync(input); }',
'import fs from "node:fs"; let input = new URL("./leaf.ts", import.meta.url); fs.readFileSync(input);',
'import fs from "node:fs"; fs.writeFileSync(new URL("./leaf.ts", import.meta.url), "value");',
'import fs from "node:fs"; consume(fs); fs.readFileSync(new URL("./leaf.ts", import.meta.url));',
'import fs from "node:fs"; const URL = custom; fs.readFileSync(new URL("./leaf.ts", import.meta.url));',
'import fs from "node:fs"; fs.promises[method](new URL("./leaf.ts", import.meta.url));',
'import fs from "node:fs"; fs.readFileSync(new URL("https://example.invalid/source.ts"));',
'import fs from "node:fs"; const input = new URL("./leaf.ts", import.meta.url); fs.readFileSync(input);',
'import fs from "node:fs"; const input = new URL("./leaf.ts", import.meta.url); input.pathname = "/other.ts"; fs.readFileSync(input);',
'import fs from "node:fs"; import path from "node:path"; path.join = custom; fs.readFileSync(path.join(__dirname,"leaf.ts"));',
'import fs from "node:fs"; import path from "node:path"; mutate(path); fs.readFileSync(path.join(__dirname,"leaf.ts"));',
'import fs from "node:fs"; import.meta.url = "file:///other/test.ts"; fs.readFileSync(new URL("./leaf.ts", import.meta.url));',
'import fs from "node:fs"; const meta = import.meta; meta.url = "file:///other/test.ts"; fs.readFileSync(new URL("./leaf.ts", import.meta.url));',
]) assert.deepEqual(analyze(source).covered, [], source);
});
test("unchanged consumer workspaces follow cross-package imports and file inputs", async t => {
const root = realpathSync(mkdtempSync(path.join(tmpdir(), "ci-inputs-")));
t.after(() => rmSync(root, { recursive: true, force: true }));
symlinkSync(path.join(process.cwd(), "node_modules"), path.join(root, "node_modules"), "dir");
const write = (file, source) => {
mkdirSync(path.dirname(path.join(root, file)), { recursive: true });
writeFileSync(path.join(root, file), source);
};
write("vitest.config.mjs", 'export default {test:{projects:["consumer", "independent"]}}');
for (const name of ["consumer", "independent"]) {
write(`${name}/vitest.config.mjs`, `export default {test:{name:"${name}",include:["*.test.ts"]}}`);
write(`${name}/unrelated.test.ts`, 'export const value = 1;');
}
write("producer/leaf.ts", "export const value = 1;");
write("producer/other.ts", "export const other = 1;");
write("consumer/import.test.ts", 'import {value} from "../producer/leaf"; console.log(value);');
write("consumer/read.test.ts", 'import {readFileSync} from "node:fs"; readFileSync(new URL("../producer/leaf.ts",import.meta.url));');
write("consumer/directory.test.ts", 'import {readdirSync} from "node:fs"; readdirSync(new URL("../producer/",import.meta.url));');
write("consumer/dynamic.test.ts", 'import {readFileSync} from "node:fs"; readFileSync(process.env.INPUT);');
write("consumer/mixed.test.ts", 'import {readFileSync as known} from "node:fs"; import {readFileSync as unknown} from "fs"; known(new URL("../producer/leaf.ts",import.meta.url)); unknown(process.env.INPUT);');
write("consumer/helper.ts", 'import {readFileSync} from "node:fs"; export const source = readFileSync(new URL("../producer/leaf.ts",import.meta.url));');
write("consumer/helper.test.ts", 'import {source} from "./helper"; console.log(source);');
write("consumer/outside.test.ts", 'import {readFileSync} from "node:fs"; readFileSync(new URL("/etc/hosts",import.meta.url));');
const git = (...args) => execFileSync("git", args, { cwd: root, stdio: "ignore" });
git("init", "-q"); git("add", ".");
const select = changed => selectProjectTests({ root, project: "consumer", changed });
const affected = await select(["producer/leaf.ts"]);
assert.deepEqual(affected.related, ["consumer/import.test.ts"]);
assert.deepEqual(affected.inputRelated, ["consumer/directory.test.ts", "consumer/helper.test.ts", "consumer/mixed.test.ts", "consumer/read.test.ts"]);
assert.deepEqual(affected.alwaysRun, ["consumer/dynamic.test.ts", "consumer/mixed.test.ts", "consumer/outside.test.ts"]);
assert.ok(!affected.selected.includes("consumer/unrelated.test.ts"));
const other = await select(["producer/other.ts"]);
assert.deepEqual(other.selected, ["consumer/directory.test.ts", "consumer/dynamic.test.ts", "consumer/mixed.test.ts", "consumer/outside.test.ts"]);
const independent = await selectProjectTests({ root, project: "independent", changed: ["producer/leaf.ts"] });
assert.deepEqual(independent.selected, [], "a project without imports or opaque inputs can drop out entirely");
write("producer/new.ts", "export const added = true;"); git("add", ".");
assert.ok((await select(["producer/new.ts"])).inputRelated.includes("consumer/directory.test.ts"));
symlinkSync("leaf.ts", path.join(root, "producer/link.ts"));
write("consumer/symlink.test.ts", 'import {readFileSync} from "node:fs"; readFileSync(new URL("../producer/link.ts",import.meta.url));');
git("add", ".");
assert.ok((await select(["producer/leaf.ts"])).inputRelated.includes("consumer/symlink.test.ts"));
rmSync(path.join(root, "producer/link.ts"));
symlinkSync("other.ts", path.join(root, "producer/link.ts"));
assert.ok((await select(["producer/link.ts"])).inputRelated.includes("consumer/symlink.test.ts"));
write("generated/unknown.ts", "export const generated = true;");
write("consumer/generated.test.ts", 'import {readdirSync} from "node:fs"; readdirSync(new URL("../generated/",import.meta.url));');
git("add", "consumer/generated.test.ts");
assert.ok((await select(["producer/other.ts"])).alwaysRun.includes("consumer/generated.test.ts"));
});
@@ -51,7 +51,9 @@ test("real graph retains direct and indirect filesystem, process and opaque depe
const select = (changed) => selectProjectTests({ root: directory, project: "fixture", changed });
const result = await select(["ui/src/leaf.ts"]);
assert.deepEqual(result.related, ["ui/src/import.test.ts"]);
for (const file of ["reader", "indirect", "process", "dynamic", "cjs", "builtin-loader", "opaque", "partial", "automock", "bypass", "spy", "remock", "arguments"]) {
assert.ok(result.inputRelated.includes("ui/src/reader.test.ts"), "literal file reads add the missing source dependency");
assert.ok(!result.alwaysRun.includes("ui/src/reader.test.ts"));
for (const file of ["indirect", "process", "dynamic", "cjs", "builtin-loader", "opaque", "partial", "automock", "bypass", "spy", "remock", "arguments"]) {
assert.ok(result.alwaysRun.includes(`ui/src/${file}.test.ts`), `${file} must never be dropped`);
}
assert.ok(!result.selected.includes("ui/src/independent.test.ts"));
@@ -73,6 +75,9 @@ test("real graph retains direct and indirect filesystem, process and opaque depe
assert.ok((await select(["ui/src/leaf.ts"])).alwaysRun.includes("ui/src/generated.test.ts"),
"generated inputs have no trustworthy source-to-output graph and retain their consumers");
write("ui/src/environment.ts", 'import {readFileSync} from "node:fs"; readFileSync(new URL("./leaf.ts", import.meta.url));');
const fileSetup = await select(["ui/src/leaf.ts"]);
assert.deepEqual(fileSetup.selected, fileSetup.inventory, "an explicit setup file read affects the entire project");
write("ui/src/environment.ts", 'import {readFileSync} from "node:fs"; readFileSync(process.env.INPUT);');
await assert.rejects(select(["ui/src/leaf.ts"]), /Opaque setup builtin/, "setup filesystem reads can affect every test outside the import graph");
mkdirSync(path.join(directory, "server/src/__tests__/helpers"), { recursive: true });
for (const file of ["server/src/__tests__/setup-supertest.ts", "server/src/__tests__/helpers/isolated-test-home.ts"]) {
+82 -24
View File
@@ -1,10 +1,12 @@
import { readFileSync, realpathSync } from "node:fs";
import { readFileSync, realpathSync, statSync } from "node:fs";
import { execFileSync } from "node:child_process";
import { createHash } from "node:crypto";
import path from "node:path";
import { isBuiltin } from "node:module";
import { parseSync } from "oxc-parser";
import { createVitest } from "vitest/node";
import { repositoryFileInputs } from "./repository-file-inputs.mjs";
import { dependencyReviews } from "./reviewed-test-dependencies.mjs";
// Everything else is opaque by default. Do not add IO clients, module loaders,
// filesystem helpers or process launchers here to improve selection counts.
@@ -18,7 +20,8 @@ const purePackages = new Set([
"i18next", "react-i18next",
]);
const pureBuiltins = new Set(["assert", "assert/strict", "path", "path/posix", "path/win32",
"url", "util", "util/types", "buffer", "events", "string_decoder", "crypto"]);
"url", "util", "util/types", "buffer", "events", "string_decoder", "crypto",
"os", "timers", "timers/promises", "stream", "stream/promises", "stream/web", "async_hooks", "zlib"]);
// Reviewed setup performs only temporary-home IO and Supertest socket setup,
// not reads of arbitrary repository inputs. Pin contents, not just paths: any
// edit disables this exception until its effects are reviewed again. Continue
@@ -56,6 +59,15 @@ export function sourceDependencies(file, source) {
if (literal(node)) imports.add(node.value);
else reasons.add("dynamic module name");
};
const calleeName = (node) => {
if (node?.type === "Identifier") return node.name;
if (node?.type === "MetaProperty") return `${node.meta.name}.${node.property.name}`;
if (node?.type === "MemberExpression") {
const member = node.computed ? (literal(node.property) ? node.property.value : "") : node.property.name;
return `${calleeName(node.object)}.${member}`;
}
return "";
};
for (const statement of ast.program.body) {
const call = statement.type === "ExpressionStatement" ? statement.expression : null;
const callee = call?.type === "CallExpression" ? call.callee : null;
@@ -66,20 +78,30 @@ export function sourceDependencies(file, source) {
mocks.push(call.arguments[0].value);
}
}
function visit(node, parent = null) {
function visit(node, parent = null, grandparent = null) {
if (!node || typeof node !== "object") return;
if (node.type?.startsWith("TS") && !["TSImportEqualsDeclaration", "TSEnumDeclaration", "TSEnumMember",
"TSModuleDeclaration", "TSModuleBlock", "TSExportAssignment", "TSParameterProperty"].includes(node.type)) {
if (node.expression) visit(node.expression, node);
return;
}
const dataKey = parent && ((["Property", "ObjectProperty", "MethodDefinition", "PropertyDefinition"].includes(parent.type)
&& grandparent?.type !== "ObjectPattern" && parent.key === node && !parent.computed && !parent.shorthand)
|| (parent.type === "MemberExpression" && parent.property === node && !parent.computed
&& !/^(require|fetch|eval|Function|WebSocket|XMLHttpRequest|Worker)$/.test(node.name)));
if ((node.type === "ImportDeclaration" && node.importKind !== "type")
|| (["ExportNamedDeclaration", "ExportAllDeclaration"].includes(node.type) && node.exportKind !== "type")) {
if (node.source) importSource(node.source);
}
if (node.type === "ImportExpression") importSource(node.source);
if (node.type === "TSImportEqualsDeclaration") reasons.add("CommonJS import");
if (node.type === "Identifier" && /^(require|fetch|eval|Function|WebSocket|XMLHttpRequest|Worker)$/.test(node.name)) {
if (node.type === "Identifier" && !dataKey && /^(require|fetch|eval|Function|WebSocket|XMLHttpRequest|Worker)$/.test(node.name)) {
reasons.add("external input or dynamic code");
}
if ((node.type === "Identifier" && node.name === "getBuiltinModule")
|| (literal(node) && node.value === "getBuiltinModule")) reasons.add("dynamic builtin loader");
if ((node.type === "Identifier" && /^(importActual|unmock|doUnmock|doMock|arguments)$/.test(node.name))
if ((node.type === "Identifier" && (/^(importActual|unmock|doUnmock|doMock)$/.test(node.name)
|| (mockBindings.size > 0 && node.name === "arguments" && !dataKey)))
|| (literal(node) && /^(importActual|unmock|doUnmock|doMock)$/.test(node.value))) reasons.add("mock bypass");
if (node.type === "Identifier" && mockBindings.has(node.name) && parent
&& parent.type !== "ImportSpecifier"
@@ -87,7 +109,12 @@ export function sourceDependencies(file, source) {
reasons.add("escaped or dynamic mocking API");
}
if (node.type === "CallExpression") {
const name = source.slice(node.callee.start, node.callee.end);
const name = calleeName(node.callee);
if (node.callee.type === "MemberExpression" && mockBindings.has(node.callee.object.name)
&& /^(mock|doMock)$/.test(node.callee.property.name)
&& literal(node.arguments[0]) && /^(?:node:)?(?:fs(?:\/promises)?|path(?:\/posix|\/win32)?|url)$/.test(node.arguments[0].value)) {
reasons.add("mocked filesystem path resolution");
}
if (/^(?:vi|vitest)\.(?:mock|doMock|importActual|importMock)$/.test(name)) {
importSource(node.arguments[0]);
}
@@ -102,15 +129,15 @@ export function sourceDependencies(file, source) {
if (!viteGlob && /\b(?:fetch|eval|Function|glob|globSync)\b/.test(name)) reasons.add("external input or dynamic code");
}
for (const value of Object.values(node)) {
if (Array.isArray(value)) value.forEach((child) => visit(child, node));
else if (value && typeof value === "object" && value.type) visit(value, node);
if (Array.isArray(value)) value.forEach((child) => visit(child, node, parent));
else if (value && typeof value === "object" && value.type) visit(value, node, parent);
}
}
visit(ast.program);
return { imports: [...imports], reasons: [...reasons], mocks };
return { imports: [...imports], reasons: [...reasons], mocks, fileInputs: repositoryFileInputs(file, ast.program) };
}
export async function selectProjectTests({ root = process.cwd(), project, changed, onlyOwnChanges = false }) {
export async function selectProjectTests({ root = process.cwd(), project, changed }) {
root = realpathSync(root);
const started = performance.now();
const absoluteChanges = changed.map((file) => path.resolve(root, file));
@@ -123,22 +150,37 @@ export async function selectProjectTests({ root = process.cwd(), project, change
const inventory = await ctx.globTestSpecifications();
if (!inventory.length) throw new Error(`Empty inventory: ${project}`);
const relative = (spec) => path.relative(root, spec.moduleId).split(path.sep).join("/");
if (onlyOwnChanges && !absoluteChanges.some((file) => file.startsWith(`${owner.config.root}${path.sep}`))) {
// Keep coarse dependency coverage, but avoid spending a graph traversal
// on an unchanged consumer project merely to retain most of its IO tests.
const files = inventory.map(relative).sort();
return { project, inventory: files, selected: files, related: [], alwaysRun: files, coveredChanges: [],
opaqueModules: {}, retainedReason: "Unchanged project retains complete lane coverage", seconds: (performance.now() - started) / 1000 };
}
const tracked = new Set(execFileSync("git", ["ls-files", "--cached", "-z"], {
cwd: root, encoding: "utf8", maxBuffer: 16 * 1024 * 1024,
}).split("\0").filter(Boolean));
const audit = new Map();
const reviewedDependency = dependencyReviews();
const parsed = new Map();
const parse = (file) => {
if (!parsed.has(file)) parsed.set(file, sourceDependencies(file, readFileSync(file, "utf8")));
return parsed.get(file);
};
const fileInputs = new Map();
const declaredInputs = (file, source) => {
const paths = [];
try {
for (const input of source.fileInputs.inputs) {
const resolved = realpathSync(input);
const relative = path.relative(root, resolved).split(path.sep).join("/");
const logical = path.relative(root, input).split(path.sep).join("/");
const directory = statSync(resolved).isDirectory();
if (!inside(root, input) || !inside(root, resolved) || (input !== resolved && !tracked.has(logical)) || (directory
? ![...tracked].some(file => file.startsWith(`${relative}/`))
: !tracked.has(relative))) return [];
paths.push({ path: resolved, directory });
if (input !== resolved) paths.push({ path: input, directory });
}
} catch { return []; }
fileInputs.set(file, paths);
return source.fileInputs.covered;
};
const touchesInputs = (inputs, change) => inputs.some(input => change === input.path
|| (input.directory && change.startsWith(`${input.path}${path.sep}`)));
const resolve = async (specifier, importer) => {
try {
const resolved = await environment.pluginContainer.resolveId(specifier, importer);
@@ -153,12 +195,13 @@ export async function selectProjectTests({ root = process.cwd(), project, change
if (setup.has(file)) return;
setup.add(file);
const source = parse(file);
const coveredBuiltins = declaredInputs(file, source);
const review = reviewedSetup.get(path.relative(root, file).split(path.sep).join("/"));
const reviewed = review?.hash === createHash("sha256").update(readFileSync(file)).digest("hex");
if (!reviewed && source.reasons.length) throw new Error(`Opaque setup dependency: ${file}`);
for (const specifier of source.imports) {
if (isBuiltin(specifier)) {
if (!reviewed && !pureBuiltins.has(specifier.replace(/^node:/, ""))) throw new Error(`Opaque setup builtin: ${specifier}`);
if (!reviewed && !pureBuiltins.has(specifier.replace(/^node:/, "")) && !coveredBuiltins.includes(specifier.replace(/^node:/, ""))) throw new Error(`Opaque setup builtin: ${specifier}`);
continue;
}
if (reviewed && review.external.includes(specifier)) continue;
@@ -175,7 +218,8 @@ export async function selectProjectTests({ root = process.cwd(), project, change
else if (!resolved) throw new Error(`Unresolved setup module: ${file}`);
}
for (const file of owner.vite.config.configFileDependencies ?? []) setup.add(realpathSync(file));
const setupChanged = absoluteChanges.some((file) => setup.has(file));
const setupChanged = absoluteChanges.some(file => setup.has(file)
|| [...setup].some(module => touchesInputs(fileInputs.get(module) ?? [], file)));
const raw = await ctx.getRelevantTestSpecifications();
const modules = environment.moduleGraph.idToModuleMap;
const resolvedDependencies = new Map();
@@ -192,6 +236,7 @@ export async function selectProjectTests({ root = process.cwd(), project, change
for (const [id, module] of modules) {
if (!inside(root, id) || !/\.[cm]?[jt]sx?$/.test(id)) continue;
const source = parse(id);
const coveredBuiltins = declaredInputs(id, source);
const reasons = [...source.reasons];
if (!tracked.has(path.relative(root, id).split(path.sep).join("/"))) reasons.push("generated or untracked source");
const local = [];
@@ -203,7 +248,8 @@ export async function selectProjectTests({ root = process.cwd(), project, change
if (!Array.isArray(transformed?.deps)) reasons.push("unknown transform shape");
for (const dependencyId of [...(transformed?.deps ?? []), ...(transformed?.dynamicDeps ?? [])]) {
if (isBuiltin(dependencyId)) {
if (!pureBuiltins.has(dependencyId.replace(/^node:/, ""))) reasons.push(`builtin ${dependencyId}`);
if (!pureBuiltins.has(dependencyId.replace(/^node:/, ""))
&& !coveredBuiltins.some(source => source.replace(/^node:/, "") === dependencyId.replace(/^node:/, ""))) reasons.push(`builtin ${dependencyId}`);
continue;
}
const dependency = dependencyPath(dependencyId);
@@ -214,7 +260,8 @@ export async function selectProjectTests({ root = process.cwd(), project, change
if (!modules.get(dependency)?.transformResult) reasons.push(`untracked dependency ${path.relative(root, dependency)}`);
} else {
const external = dependency?.split(`${path.sep}node_modules${path.sep}`).at(-1).split(path.sep).join("/");
if (!dependency || !dependency.includes(`${path.sep}node_modules${path.sep}`) || !purePackages.has(packageName(external))) {
if (!dependency || !dependency.includes(`${path.sep}node_modules${path.sep}`)
|| (!purePackages.has(packageName(external)) && !reviewedDependency(dependency, external))) {
reasons.push(`opaque dependency ${external ? packageName(external) : dependencyId}`);
}
}
@@ -223,6 +270,8 @@ export async function selectProjectTests({ root = process.cwd(), project, change
if (reasons.length) audit.set(id, [...new Set(reasons)]);
}
const retained = [];
const inputRelated = [];
const retentionReasons = {};
for (const spec of inventory) {
const mocked = new Set();
// Honor only unconditional, explicitly supplied mock factories without an
@@ -241,19 +290,28 @@ export async function selectProjectTests({ root = process.cwd(), project, change
const file = queue.pop();
if (visited.has(file)) continue;
visited.add(file);
if (audit.has(file)) { opaque = true; break; }
if (absoluteChanges.some(change => touchesInputs(fileInputs.get(file) ?? [], change))) inputRelated.push(spec);
if (audit.has(file)) {
retentionReasons[relative(spec)] = { module: path.relative(root, file), reasons: audit.get(file) };
opaque = true; break;
}
for (const dependency of localImports.get(file) ?? []) if (!mocked.has(dependency)) queue.push(dependency);
}
if (opaque) retained.push(spec);
}
const selected = [...new Set([...raw, ...retained].map(relative))].sort();
const selected = [...new Set([...raw, ...inputRelated, ...retained].map(relative))].sort();
return {
project,
inventory: inventory.map(relative).sort(),
selected,
related: raw.map(relative).sort(),
inputRelated: [...new Set(inputRelated.map(relative))].sort(),
alwaysRun: retained.map(relative).sort(),
coveredChanges: changed.filter((file, index) => modules.has(absoluteChanges[index]) || setup.has(absoluteChanges[index])),
retentionReasons,
coveredChanges: changed.filter((file, index) => modules.has(absoluteChanges[index]) || setup.has(absoluteChanges[index])
|| [...fileInputs.values()].some(inputs => touchesInputs(inputs, absoluteChanges[index]))),
fileInputs: Object.fromEntries([...fileInputs].filter(([, inputs]) => inputs.length)
.map(([file, inputs]) => [path.relative(root, file), inputs.map(input => ({ ...input, path: path.relative(root, input.path) }))])),
opaqueModules: Object.fromEntries([...audit].map(([file, reasons]) => [path.relative(root, file), reasons])),
setupChanged,
seconds: (performance.now() - started) / 1000,