mirror of
https://github.com/paperclipai/paperclip.git
synced 2026-11-09 07:10:34 +01:00
## Thinking Path > - Paperclip coordinates agents that make and verify repository changes. > - Local verification currently defaults to the complete Vitest suite. > - Contributor instructions also require broad local checks at each PR handoff. > - CI already owns conservative affected-test selection and broad final-revision checks. > - This pull request adds local selection and pass reuse from those existing contracts. > - Agents can get focused feedback and use CI to close the remaining coverage gaps. ## Linked Issues or Issue Description **Subsystem affected** Developer verification scripts and contributor guidance. Related selection work: #18398 and #18464. **Problem or motivation** An agent that repeats an unchanged local verification step starts the complete suite again. The handoff guidance also duplicates broad CI coverage locally. **Proposed solution** Add `pnpm verify` for the actual working tree. Select affected Vitest specifications with the CI task policy and dependency graph. Reuse successful checks whose inputs are modeled. Run opaque checks fresh. Report exit 2 when broader verification is required. Update the contributor contract to accept applicable final-revision CI coverage. **Alternatives considered** Plain `vitest related` omits filesystem and opaque dependencies. Commit-only cache keys miss dirty files. Running full local suites at every handoff repeats CI work. **Roadmap alignment** This is repository development tooling. It does not change the product roadmap. ## What Changed - Add `pnpm verify`, `--explain`, `--base`, `--full`, and `--no-cache`. - Reuse CI selection and exact runner ownership for dirty, staged, committed, deleted, renamed, and untracked inputs. - Keep native server suites and serialized suites in their local owners. Keep six sequential chat shards. - Cache selected passes within one worktree. Hash source, fixtures, installed package contents, dependency bindings and installation metadata, dotenv files, child environment, toolchains, platform, and selection. - Run opaque consumers fresh. Preserve failed, interrupted, mutated-input, and partial-coverage outcomes. - Update AGENTS.md, CONTRIBUTING.md, and DEVELOPING.md guidance. Record the shared `verify` command as the sole exception to the package-script rule. Register the new tests in the existing CI contract-test command. - Add optional root/environment inputs to shared graph helpers. Existing CI defaults stay the same. ## Verification - Fourteen new integration tests pass locally across focused runs with real Vitest selection and execution; CI colors are covered. - 51 existing targeted selector/input, project-isolation, and stable-runner checks passed. Four relevant runner contracts also pass after the workspace change. - Repeated unchanged execution skips reusable tests. Source, environment, installed code, dependency bindings, and new untracked tests invalidate results. - Installed-code mutation with a restored modification time still invalidates a cached pass. Internal skill-directory links preserve reuse. - Workspace B/C tests prove discovery, reusable/fresh partition execution, pass reuse, and external-input failure. Chat selection produces exactly six single-worker partitions. - A deliberate unrelated failing test is omitted by narrow selection and caught by `--full`. - Red/green regressions prove fingerprints preserve file boundaries and reject edits made between diff discovery and snapshotting. - Opaque external-file changes still fail on a subsequent run. Source mutation and process interruption cannot publish reusable passes. - `pnpm verify --explain` works in the checkout and requests broad CI coverage for this tooling change. No duplicate full local suite was started. - [Final-revision CI](https://github.com/paperclipai/paperclip/actions/runs/42117291789) passed on `ed85d96ddff82c05c325948680b593b0f48c473d` (attempt 2). The scoped Canary retry preserved all passing test/build results. - [Greptile review](https://github.com/paperclipai/paperclip/pull/19345#issuecomment-6670050175) is 5/5; all four review threads are resolved. - The additional Paperclip review concluded neutral because its execution stopped; it produced no assessment. ## Risks - Pass reuse depends on conservative dependency classification. Opaque consumers, external database/live settings, and non-isolated projects retain fresh or broad coverage. - Local passes cover selected Vitest checks. Required CI, build/typecheck, release, browser, and platform acceptance remain separate obligations. - Cross-worktree caching and cross-agent scheduling are outside this change. - The initial installation scan reads dependency contents; stat-validated, worktree-local digest memoization avoids rereading unchanged contents. Subsequent scans still inspect file identity and nanosecond change/modification times. - No dependency lockfile or workflow changes. ## Model Used OpenAI Codex, `gpt-6.1-sol`, max reasoning, with local shell and file tools. Context window size is not exposed by this host. ## 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 - [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>
335 lines
19 KiB
JavaScript
335 lines
19 KiB
JavaScript
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.
|
|
const purePackages = new Set([
|
|
"vitest", "@vitest/expect", "@testing-library/react", "@testing-library/dom",
|
|
"@testing-library/user-event", "@testing-library/jest-dom", "react", "react-dom",
|
|
"react-router", "react-router-dom", "@tanstack/react-query", "@tanstack/query-core",
|
|
"zod", "lodash", "lodash-es", "date-fns", "clsx", "tailwind-merge", "class-variance-authority",
|
|
"lucide-react", "immer", "nanoid", "uuid", "semver",
|
|
"radix-ui", "motion", "@dnd-kit/core", "@dnd-kit/sortable", "@dnd-kit/utilities",
|
|
"i18next", "react-i18next",
|
|
]);
|
|
const pureBuiltins = new Set(["assert", "assert/strict", "path", "path/posix", "path/win32",
|
|
"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
|
|
// following local imports even for these files; helper changes affect all tests.
|
|
const reviewedSetup = new Map([
|
|
["server/src/__tests__/setup-supertest.ts", {
|
|
hash: "99d7193927468e98b41255ea9a5e37b1a681f02b259c304f3af1a52304eed2bb",
|
|
external: ["supertest/lib/test.js"],
|
|
}],
|
|
["server/src/__tests__/helpers/isolated-test-home.ts", {
|
|
hash: "bd5645dc590087f26b473beab06d5c3ddd7956a79949f0b3e9713f11897a0c1b",
|
|
external: [],
|
|
}],
|
|
]);
|
|
const packageName = (specifier) => specifier.startsWith("@")
|
|
? specifier.split("/").slice(0, 2).join("/") : specifier.split("/")[0];
|
|
const inside = (root, file) => file.startsWith(`${root}${path.sep}`) && !file.includes(`${path.sep}node_modules${path.sep}`);
|
|
|
|
// Parse source, including imports erased by mocks/transforms. Unknown loaders
|
|
// retain their consumers. This is intentionally conservative, not a regex-based
|
|
// promise that arbitrary JavaScript has no effects.
|
|
export function sourceDependencies(file, source) {
|
|
const ast = parseSync(file, source);
|
|
const imports = new Set();
|
|
const reasons = new Set();
|
|
const mockBindings = new Set(ast.program.body.filter((node) => node.type === "ImportDeclaration" && node.source.value === "vitest")
|
|
.flatMap((node) => node.specifiers.filter((s) => s.type === "ImportSpecifier" && ["vi", "vitest"].includes(s.imported.name)).map((s) => s.local.name)));
|
|
const hoistedBindings = new Set(ast.program.body.filter((node) => node.type === "ImportDeclaration" && node.source.value === "vitest")
|
|
.flatMap((node) => node.specifiers.filter((s) => s.type === "ImportSpecifier" && ["vi", "vitest"].includes(s.imported.name)
|
|
&& s.local.name === s.imported.name).map((s) => s.local.name)));
|
|
const mocks = [];
|
|
if (ast.errors.length) reasons.add("unparsed source");
|
|
const literal = (node) => node?.type === "Literal" && typeof node.value === "string";
|
|
const importSource = (node) => {
|
|
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;
|
|
const factory = call?.arguments?.[1];
|
|
if (callee?.type === "MemberExpression" && !callee.computed && callee.property.name === "mock"
|
|
&& hoistedBindings.has(callee.object.name) && literal(call.arguments[0])
|
|
&& ["ArrowFunctionExpression", "FunctionExpression"].includes(factory?.type) && factory.params.length === 0) {
|
|
mocks.push(call.arguments[0].value);
|
|
}
|
|
}
|
|
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" && !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)$/.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"
|
|
&& !(parent.type === "MemberExpression" && parent.object === node && !parent.computed)) {
|
|
reasons.add("escaped or dynamic mocking API");
|
|
}
|
|
if (node.type === "CallExpression") {
|
|
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]);
|
|
}
|
|
if (/\brequire\b/.test(name)) {
|
|
reasons.add("CommonJS loader");
|
|
importSource(node.arguments[0]);
|
|
}
|
|
const viteGlob = name === "import.meta.glob" && (literal(node.arguments[0])
|
|
|| (node.arguments[0]?.type === "ArrayExpression" && node.arguments[0].elements.every(literal)));
|
|
// Vite expands literal globs into dependency edges. New files appear in
|
|
// that expansion; deleted/renamed and non-source inputs force full lanes.
|
|
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, parent));
|
|
else if (value && typeof value === "object" && value.type) visit(value, node, parent);
|
|
}
|
|
}
|
|
visit(ast.program);
|
|
return { imports: [...imports], reasons: [...reasons], mocks, fileInputs: repositoryFileInputs(file, ast.program) };
|
|
}
|
|
|
|
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));
|
|
const ctx = await createVitest({ root, watch: false, project: [project],
|
|
exclude: ["**/dist/**"], reporters: [], related: absoluteChanges });
|
|
try {
|
|
if (ctx.projects.length !== 1 || ctx.projects[0].name !== project) throw new Error(`Unknown project: ${project}`);
|
|
const owner = ctx.projects[0];
|
|
const environment = owner.vite.environments.ssr;
|
|
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("/");
|
|
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);
|
|
if (!resolved || resolved.external || !path.isAbsolute(resolved.id) || resolved.id.includes("?")) return null;
|
|
return realpathSync(resolved.id);
|
|
} catch { return null; } // A conditional/require-only export is opaque.
|
|
};
|
|
// Setup dependencies are not test-import edges. A change anywhere in their
|
|
// local import closure invalidates the entire project, including helpers.
|
|
const setup = new Set();
|
|
async function setupClosure(file) {
|
|
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:/, "")) && !coveredBuiltins.includes(specifier.replace(/^node:/, ""))) throw new Error(`Opaque setup builtin: ${specifier}`);
|
|
continue;
|
|
}
|
|
if (reviewed && review.external.includes(specifier)) continue;
|
|
const dependency = await resolve(specifier, file);
|
|
if (dependency && inside(root, dependency)) await setupClosure(dependency);
|
|
else if (!dependency) throw new Error(`Unresolved setup dependency: ${specifier}`);
|
|
else if (!purePackages.has(packageName(specifier))) throw new Error(`Opaque setup package: ${specifier}`);
|
|
}
|
|
}
|
|
for (const file of [...(owner.config.setupFiles ?? []), ...(owner.config.globalSetup ?? []),
|
|
...(owner.config.snapshotSerializers ?? [])]) {
|
|
const resolved = path.isAbsolute(file) ? file : await resolve(file, owner.vite.config.configFile);
|
|
if (resolved && inside(root, resolved)) await setupClosure(resolved);
|
|
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)
|
|
|| [...setup].some(module => touchesInputs(fileInputs.get(module) ?? [], file)));
|
|
const raw = await ctx.getRelevantTestSpecifications();
|
|
const modules = environment.moduleGraph.idToModuleMap;
|
|
const resolvedDependencies = new Map();
|
|
const localImports = new Map();
|
|
const dependencyPath = (dep) => {
|
|
if (!resolvedDependencies.has(dep)) {
|
|
const file = dep.startsWith("/@fs/") ? dep.slice(process.platform === "win32" ? 5 : 4)
|
|
: path.join(owner.config.root, dep);
|
|
try { resolvedDependencies.set(dep, realpathSync(file)); }
|
|
catch { resolvedDependencies.set(dep, null); }
|
|
}
|
|
return resolvedDependencies.get(dep);
|
|
};
|
|
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 = [];
|
|
localImports.set(id, local);
|
|
// Use the same resolved runtime edges as Vitest, including literal dynamic
|
|
// imports. Resolving the original syntax again uses the wrong conditions
|
|
// for require-only exports and invents edges for erased type imports.
|
|
const transformed = module.transformResult;
|
|
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:/, ""))
|
|
&& !coveredBuiltins.some(source => source.replace(/^node:/, "") === dependencyId.replace(/^node:/, ""))) reasons.push(`builtin ${dependencyId}`);
|
|
continue;
|
|
}
|
|
// Vite's exact ?raw form reads a file as text, not executable code.
|
|
// Record that input explicitly; do not treat arbitrary query loaders,
|
|
// generated files, or paths outside the checkout as independent.
|
|
if (dependencyId.endsWith("?raw") && dependencyId.indexOf("?") === dependencyId.length - 4) {
|
|
const input = dependencyPath(dependencyId.slice(0, -4));
|
|
if (input && inside(root, input) && statSync(input).isFile()
|
|
&& tracked.has(path.relative(root, input).split(path.sep).join("/"))) {
|
|
fileInputs.set(id, [...(fileInputs.get(id) ?? []), { path: input, directory: false }]);
|
|
continue;
|
|
}
|
|
}
|
|
const dependency = dependencyPath(dependencyId);
|
|
if (dependency && inside(root, dependency)) {
|
|
local.push(dependency);
|
|
// Vitest intentionally skips some edges (e.g. CJS, externalized
|
|
// workspaces). Never regard an untracked local edge as independent.
|
|
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)) && !reviewedDependency(dependency, external))) {
|
|
reasons.push(`opaque dependency ${external ? packageName(external) : dependencyId}`);
|
|
}
|
|
}
|
|
}
|
|
if (!module.transformResult) reasons.push("untransformed module");
|
|
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
|
|
// importOriginal parameter. Automocks, spies, unmock/importActual, dynamic
|
|
// mocking and escaped vi bindings stay conservative. The related-file
|
|
// graph remains uncut: edits to a mocked module can still select its tests.
|
|
if (owner.config.isolate !== false) {
|
|
for (const name of parse(spec.moduleId).mocks) {
|
|
const target = await resolve(name, spec.moduleId);
|
|
if (target && inside(root, target)) mocked.add(target);
|
|
}
|
|
}
|
|
const queue = [spec.moduleId], visited = new Set();
|
|
let opaque = setupChanged || owner.config.isolate === false || !localImports.has(spec.moduleId);
|
|
while (queue.length && !opaque) {
|
|
const file = queue.pop();
|
|
if (visited.has(file)) continue;
|
|
visited.add(file);
|
|
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, ...inputRelated, ...retained].map(relative))].sort();
|
|
return {
|
|
project,
|
|
isolated: owner.config.isolate !== false,
|
|
inventory: inventory.map(relative).sort(),
|
|
selected,
|
|
related: raw.map(relative).sort(),
|
|
inputRelated: [...new Set(inputRelated.map(relative))].sort(),
|
|
alwaysRun: retained.map(relative).sort(),
|
|
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,
|
|
};
|
|
} finally {
|
|
await ctx.close();
|
|
}
|
|
}
|