fix(test): reduce changed-target import graph IO

This commit is contained in:
Vincent Koc
2026-05-16 03:39:30 +08:00
parent 9e9a825aa5
commit dd4613a268
2 changed files with 160 additions and 5 deletions

View File

@@ -1,3 +1,4 @@
import { spawnSync } from "node:child_process";
import fs from "node:fs";
import os from "node:os";
import path from "node:path";
@@ -741,13 +742,154 @@ function resolveImportSpecifier(importer, specifier, fileSet) {
let cachedImportGraph = null;
let cachedImportGraphCwd = null;
let cachedImportGraphFiles = null;
let cachedImportGraphFilesCwd = null;
function isImportableGraphFile(relative) {
return IMPORTABLE_FILE_EXTENSIONS.some((ext) => relative.endsWith(ext));
}
function listImportGraphFilesFromGit(cwd) {
const result = spawnSync("git", ["ls-files", "--", ...SOURCE_ROOTS_FOR_IMPORT_GRAPH], {
cwd,
encoding: "utf8",
stdio: ["ignore", "pipe", "pipe"],
});
if (result.status !== 0) {
return null;
}
return result.stdout
.split("\n")
.map((line) => normalizePathPattern(line.trim()))
.filter((line) => line.length > 0 && isImportableGraphFile(line));
}
function listImportGraphFilesForCwd(cwd) {
if (cachedImportGraphFiles && cachedImportGraphFilesCwd === cwd) {
return cachedImportGraphFiles;
}
cachedImportGraphFiles =
listImportGraphFilesFromGit(cwd) ??
SOURCE_ROOTS_FOR_IMPORT_GRAPH.flatMap((root) => listImportGraphFiles(cwd, root));
cachedImportGraphFilesCwd = cwd;
return cachedImportGraphFiles;
}
function stripImportableGraphExtension(relative) {
for (const ext of IMPORTABLE_FILE_EXTENSIONS) {
if (relative.endsWith(ext)) {
return relative.slice(0, -ext.length);
}
}
return relative;
}
function resolveImportGraphSearchTerm(relative) {
const basename = path.posix.basename(stripImportableGraphExtension(relative));
if (basename === "index" || basename.length < 3) {
return null;
}
return basename;
}
function listImportGraphGrepMatches(cwd, term) {
const result = spawnSync(
"git",
["grep", "-l", "--fixed-strings", term, "--", ...SOURCE_ROOTS_FOR_IMPORT_GRAPH],
{
cwd,
encoding: "utf8",
stdio: ["ignore", "pipe", "pipe"],
},
);
if (result.status === 1) {
return [];
}
if (result.status !== 0) {
return null;
}
return result.stdout
.split("\n")
.map((line) => normalizePathPattern(line.trim()))
.filter((line) => line.length > 0 && isImportableGraphFile(line));
}
function findDirectImportersWithGitGrep(cwd, importedFile, fileSet) {
const term = resolveImportGraphSearchTerm(importedFile);
if (!term) {
return null;
}
const candidates = listImportGraphGrepMatches(cwd, term);
if (!candidates || candidates.length > 800) {
return null;
}
const importers = [];
for (const file of candidates) {
if (file === importedFile || !fileSet.has(file)) {
continue;
}
let source = "";
try {
source = fs.readFileSync(path.join(cwd, file), "utf8");
} catch {
continue;
}
for (const match of source.matchAll(IMPORT_SPECIFIER_PATTERN)) {
const imported = resolveImportSpecifier(file, match[1] ?? match[2] ?? "", fileSet);
if (imported === importedFile) {
importers.push(file);
break;
}
}
}
return importers;
}
function resolveAffectedTestsFromTargetedImportScan(changedPath, cwd) {
const normalized = normalizePathPattern(changedPath);
const files = listImportGraphFilesForCwd(cwd);
const fileSet = new Set(files);
if (!fileSet.has(normalized)) {
return [];
}
const testFiles = new Set(
files.filter((file) => isTestFileTarget(file) && !file.endsWith(".live.test.ts")),
);
const queue = [normalized];
const seen = new Set(queue);
const targets = [];
for (let index = 0; index < queue.length; index += 1) {
const current = queue[index];
const importers = findDirectImportersWithGitGrep(cwd, current, fileSet);
if (importers === null) {
return null;
}
for (const importer of importers) {
if (seen.has(importer)) {
continue;
}
seen.add(importer);
if (testFiles.has(importer)) {
targets.push(importer);
}
queue.push(importer);
}
}
return [...new Set(targets)].toSorted((left, right) => left.localeCompare(right));
}
function getImportGraph(cwd) {
if (cachedImportGraph && cachedImportGraphCwd === cwd) {
return cachedImportGraph;
}
const files = SOURCE_ROOTS_FOR_IMPORT_GRAPH.flatMap((root) => listImportGraphFiles(cwd, root));
const files = listImportGraphFilesForCwd(cwd);
const fileSet = new Set(files);
const reverseImports = new Map();
const testFiles = new Set(
@@ -779,6 +921,11 @@ function getImportGraph(cwd) {
function resolveAffectedTestsFromImportGraph(changedPath, cwd) {
const normalized = normalizePathPattern(changedPath);
const targetedTargets = resolveAffectedTestsFromTargetedImportScan(normalized, cwd);
if (targetedTargets !== null) {
return targetedTargets;
}
const { reverseImports, testFiles } = getImportGraph(cwd);
const queue = [normalized];
const seen = new Set(queue);

View File

@@ -1,6 +1,7 @@
import fs from "node:fs";
import path from "node:path";
import fg from "fast-glob";
import { describe, expect, it } from "vitest";
import { describe, expect, it, vi } from "vitest";
import {
DEFAULT_TEST_PROJECTS_VITEST_NO_OUTPUT_TIMEOUT_MS,
applyDefaultMultiSpecVitestCachePaths,
@@ -836,9 +837,16 @@ describe("scripts/test-projects changed-target routing", () => {
});
it("uses import-graph targets in default changed mode", () => {
expect(resolveChangedTestTargetPlan(["test/helpers/normalize-text.ts"]).targets).toContain(
"src/auto-reply/status.test.ts",
);
const readFileSync = vi.spyOn(fs, "readFileSync");
const before = readFileSync.mock.calls.length;
const targets = resolveChangedTestTargetPlan(["test/helpers/normalize-text.ts"]).targets;
const repoSourceReads = readFileSync.mock.calls
.slice(before)
.filter(([file]) => typeof file === "string" && normalizeRepoPath(file).includes("/src/"));
readFileSync.mockRestore();
expect(targets).toContain("src/auto-reply/status.test.ts");
expect(repoSourceReads.length).toBeLessThan(100);
});
it.each([