From dd4613a26870e6b0b1183becd7ebb17432a60ca5 Mon Sep 17 00:00:00 2001 From: Vincent Koc Date: Sat, 16 May 2026 03:39:30 +0800 Subject: [PATCH] fix(test): reduce changed-target import graph IO --- scripts/test-projects.test-support.mjs | 149 ++++++++++++++++++++++++- test/scripts/test-projects.test.ts | 16 ++- 2 files changed, 160 insertions(+), 5 deletions(-) diff --git a/scripts/test-projects.test-support.mjs b/scripts/test-projects.test-support.mjs index 04239fc4eac..89b9ab80715 100644 --- a/scripts/test-projects.test-support.mjs +++ b/scripts/test-projects.test-support.mjs @@ -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); diff --git a/test/scripts/test-projects.test.ts b/test/scripts/test-projects.test.ts index 1f2646363f7..c6ba2e1410a 100644 --- a/test/scripts/test-projects.test.ts +++ b/test/scripts/test-projects.test.ts @@ -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([