diff --git a/README.md b/README.md index 59244d99..1aaa92f5 100644 --- a/README.md +++ b/README.md @@ -87,6 +87,9 @@ node ./dist/cli.js review --base HEAD --head WORKTREE --summary # broader blast-radius map when the review packet needs expansion node ./dist/cli.js impact --base HEAD --head WORKTREE --pretty +# focused affected-test list for current edits +node ./dist/cli.js affected --base HEAD --head WORKTREE --quiet + # one-call answer for a concrete repo question node ./dist/cli.js explore "how does auth reach db?" --root . --pretty @@ -138,6 +141,7 @@ Use these as starting points, then see [docs/cli.md](./docs/cli.md) for all flag # fastest code-review handoff for current edits codegraph review --base HEAD --head WORKTREE --summary codegraph impact --base HEAD --head WORKTREE --pretty +codegraph affected --base HEAD --head WORKTREE --quiet # repo question, orientation, and bounded follow-up codegraph explore "how does auth reach db?" --root . --pretty diff --git a/codegraph-skill/codegraph/SKILL.md b/codegraph-skill/codegraph/SKILL.md index 942cc853..ae3d2c7c 100644 --- a/codegraph-skill/codegraph/SKILL.md +++ b/codegraph-skill/codegraph/SKILL.md @@ -9,7 +9,7 @@ Use Codegraph for structure-aware repo questions: - repo overview, hotspots, cycles, unresolved imports, and public API surface - symbol navigation with definitions, references, dependencies, and paths -- PR or worktree impact review with candidate tests and risk signals +- PR or worktree impact review with candidate tests, affected test lists, and risk signals - duplicate cleanup and refactor-risk triage - bounded agent context through explore, orientation, search, packets, explain, and MCP @@ -40,6 +40,7 @@ Then choose the smallest useful follow-up: - references: `codegraph refs --file --line --col --pretty` - duplicates: `codegraph duplicates --root . ./src --profile cleanup` - impact: `codegraph impact --base HEAD --head WORKTREE --pretty` +- affected tests: `codegraph affected --base HEAD --head WORKTREE --quiet` - review: `codegraph review --base HEAD --head WORKTREE --summary` - drift: `codegraph drift ./src --base origin/main --head HEAD --pretty --graph-edges summary --public-api removals` - installer: `codegraph install --target codex,claude --dry-run` @@ -64,6 +65,7 @@ Current high-value surfaces: - `orient --pretty`: ranked first-turn focus targets with copyable follow-ups - `impact --pretty`: ranked "what could this break?" map - `review --summary`: compact reviewer handoff +- `affected --quiet`: deterministic path-only test selection for a changed-file set - `duplicates --profile cleanup`: refactor ROI ordering - `duplicates --json`: full grouped duplicate data diff --git a/docs/cli.md b/docs/cli.md index eee5577b..1df78910 100644 --- a/docs/cli.md +++ b/docs/cli.md @@ -14,6 +14,7 @@ Default workflow: - code review: `codegraph review --base HEAD --head WORKTREE --summary` - blast-radius follow-up: `codegraph impact --base HEAD --head WORKTREE --pretty` +- affected tests: `codegraph affected --base HEAD --head WORKTREE --quiet` - unfamiliar repo: `codegraph explore "how does auth reach db?" --root . --pretty` - first-turn map: `codegraph orient --root . --budget small --pretty` - targeted follow-up: `codegraph search "" --json` then `codegraph explain ` @@ -56,6 +57,7 @@ Cache and manifest reuse is rooted at `--root`. Reusing a project root lets comm # Fast code-review handoff for current local edits codegraph review --base HEAD --head WORKTREE --summary codegraph impact --base HEAD --head WORKTREE --pretty +codegraph affected --base HEAD --head WORKTREE --quiet # First-pass repo summary and next-step suggestions codegraph orient --root . --budget small --pretty @@ -125,6 +127,19 @@ Graph, index, and review reports include `backend.native.byLanguage` so native u - `uninit` removes only recognized lifecycle state by default. It refuses unknown `.codegraph/` entries unless `--force` is passed. - Lifecycle commands accept either a positional project path or `--root `. They reject using both together because lifecycle manifests always describe one project boundary, not include-root subsets. +### Affected tests + +- `affected` maps changed source files to likely test files by traversing reverse dependencies through the project graph. It also includes directly changed test files at depth 0. +- Inputs can be positional files, newline-delimited `--stdin`, or a Git range with `--base --head `. Paths are normalized under `--root` and output as project-root-relative paths. +- Use `--depth ` to expand transitive reverse dependencies, `--filter ` to restrict returned test paths, `--quiet` for path-only output, or `--json` for `schemaVersion: 1`, `changedFiles`, `affectedTests`, and `omittedCounts`. + +```bash +codegraph affected src/auth.ts src/db.ts +codegraph affected --stdin --quiet +codegraph affected --base main --head HEAD --json +codegraph affected --base HEAD --head WORKTREE --filter "tests/**/*.test.ts" --quiet +``` + ### Symbols, navigation, grep, and chunking ```bash diff --git a/src/cli.ts b/src/cli.ts index c4223ff7..be288e7c 100644 --- a/src/cli.ts +++ b/src/cli.ts @@ -27,6 +27,7 @@ import { type CliRuntime, type CommandReport, } from "./cli/context.js"; +import { handleAffectedCommand } from "./cli/affected.js"; import { buildDoctorReport } from "./cli/doctor.js"; import { handleDriftCommand } from "./cli/drift.js"; import { handleDuplicatesCommand } from "./cli/duplicates.js"; @@ -911,6 +912,23 @@ async function runCliWithActiveRuntime(rawArgs: string[]) { return; } + if (cmd === "affected") { + await handleAffectedCommand({ + projectRootFs, + buildOptions: buildAgentOptions(), + positionals: parsed.positionals, + getOpt, + hasFlag, + parsedOptions: parsed.options, + readStdin: readCliStdin, + writeJSONLine, + writeStdoutLine, + writeStderrLine, + exit: exitCli, + }); + return; + } + // Review entry point: CLI workflow for review reports. if (cmd === "review") { const commandReport: CommandReport | undefined = reportEnabled ? { command: "review", timings: {} } : undefined; diff --git a/src/cli/affected.ts b/src/cli/affected.ts new file mode 100644 index 00000000..dca1dc43 --- /dev/null +++ b/src/cli/affected.ts @@ -0,0 +1,348 @@ +import pm from "picomatch"; +import { buildProjectIndex } from "../indexer/build-index.js"; +import type { BuildOptions, ProjectIndex } from "../indexer/types.js"; +import { getReverseNeighbors, graphAdjacencyFor } from "../graphs/adjacency.js"; +import { createGraphFileResolver, normalizeImpactFileChange } from "../impact/path.js"; +import { getDiff } from "../impact/providers/base.js"; +import { compileTestPatterns, createIndexTestFileMatcher, isTestFilePath } from "../impact/testPatterns.js"; +import type { FileChange } from "../impact/types.js"; +import { listDirectDeletedFileImporters } from "../review/deleted.js"; +import type { FileId } from "../types.js"; +import { normalizePath, resolveFilePathWithinRoot, toProjectDisplayPath } from "../util/paths.js"; +import { parseNonNegativeIntegerOption } from "./options.js"; + +export type AffectedTestEntry = { + file: string; + reasons: string[]; + depth: number; +}; + +export type AffectedOmittedCounts = { + changedFiles: number; + filteredTests: number; +}; + +export type AffectedTestsReport = { + schemaVersion: 1; + root: string; + changedFiles: string[]; + affectedTests: AffectedTestEntry[]; + omittedCounts: AffectedOmittedCounts; +}; + +export type AffectedCommandContext = { + projectRootFs: string; + buildOptions: BuildOptions; + positionals: readonly string[]; + getOpt: (name: string) => string | undefined; + hasFlag: (name: string) => boolean; + parsedOptions: ReadonlyMap; + readStdin: () => Promise; + writeJSONLine: (value: unknown) => void; + writeStdoutLine: (message: string) => void; + writeStderrLine: (message: string) => void; + exit: (code: number) => never; +}; + +type AffectedTestAccumulator = { + file: string; + reasons: Set; + depth: number; +}; + +type ChangedFileInputs = { + files: string[]; + deletedFiles: string[]; +}; + +type AffectedTraversalState = { + index: ProjectIndex; + projectRoot: string; + maxDepth: number; + matchesTestFile: (file: FileId) => boolean; + passesFilter: (file: string) => boolean; +}; + +function parseStdinFiles(raw: string): string[] { + return raw + .split(/\r?\n/) + .map((line) => line.trim()) + .filter(Boolean); +} + +function normalizeChangedFileInput(projectRoot: string, input: string): string { + const resolved = resolveFilePathWithinRoot(projectRoot, input, "Changed file"); + if (resolved.status === "error") { + throw new Error(resolved.error); + } + return normalizePath(resolved.file); +} + +function createTestFilter(projectRoot: string, filters: readonly string[]): (file: string) => boolean { + if (!filters.length) { + return () => true; + } + const matchers = filters.map((filter) => pm(filter, { dot: true })); + return (file: string): boolean => { + const displayPath = toProjectDisplayPath(projectRoot, file); + return matchers.some((matcher) => matcher(displayPath)); + }; +} + +function addAffectedTest( + affected: Map, + file: string, + reason: string, + depth: number, +): void { + const existing = affected.get(file); + if (existing) { + existing.reasons.add(reason); + existing.depth = Math.min(existing.depth, depth); + return; + } + affected.set(file, { file, reasons: new Set([reason]), depth }); +} + +function maybeAddTest( + affected: Map, + state: AffectedTraversalState, + file: string, + reason: string, + depth: number, + omittedCounts: AffectedOmittedCounts, +): void { + if (!state.matchesTestFile(file)) return; + if (!state.passesFilter(file)) { + omittedCounts.filteredTests += 1; + return; + } + addAffectedTest(affected, file, reason, depth); +} + +async function addDeletedImporterTests( + affected: Map, + state: AffectedTraversalState, + deletedFiles: readonly string[], + omittedCounts: AffectedOmittedCounts, +): Promise { + if (!state.maxDepth) return; + + const adjacency = state.index.graphAdjacency ?? graphAdjacencyFor(state.index.graph); + for (const deletedFile of deletedFiles) { + const reasonSource = toProjectDisplayPath(state.projectRoot, deletedFile); + const directImporters = await listDirectDeletedFileImporters(state.index, [deletedFile], state.projectRoot); + const visited = new Set(); + const queue: Array<{ file: string; depth: number }> = []; + for (const importer of directImporters) { + if (visited.has(importer.file)) continue; + visited.add(importer.file); + queue.push({ file: importer.file, depth: 1 }); + } + + let queueIndex = 0; + while (queueIndex < queue.length) { + const current = queue[queueIndex]!; + queueIndex += 1; + maybeAddTest( + affected, + state, + current.file, + `deleted import from ${reasonSource}, depth ${current.depth}`, + current.depth, + omittedCounts, + ); + if (current.depth >= state.maxDepth) continue; + const nextDepth = current.depth + 1; + for (const neighbor of getReverseNeighbors(adjacency, current.file)) { + if (visited.has(neighbor)) continue; + visited.add(neighbor); + queue.push({ file: neighbor, depth: nextDepth }); + } + } + } +} + +async function collectAffectedTests( + changedFiles: readonly string[], + deletedFiles: readonly string[], + state: AffectedTraversalState, +): Promise<{ affected: Map; omittedCounts: AffectedOmittedCounts }> { + const affected = new Map(); + const omittedCounts: AffectedOmittedCounts = { changedFiles: 0, filteredTests: 0 }; + const resolver = createGraphFileResolver(state.index.graph.nodes); + const graphNodes = new Set(Array.from(state.index.graph.nodes, (node) => normalizePath(node))); + const adjacency = state.index.graphAdjacency ?? graphAdjacencyFor(state.index.graph); + const pathPatterns = compileTestPatterns(undefined); + + for (const changedFile of changedFiles) { + const changedDisplayPath = toProjectDisplayPath(state.projectRoot, changedFile); + const changedLooksLikeTest = isTestFilePath(changedDisplayPath, pathPatterns); + if (changedLooksLikeTest) { + maybeAddTest(affected, state, changedFile, "changed test file", 0, omittedCounts); + } + + if (!state.maxDepth) continue; + + const startFile = resolver(changedFile); + if (!graphNodes.has(normalizePath(startFile))) { + omittedCounts.changedFiles += 1; + continue; + } + + const visited = new Set([startFile]); + const queue: Array<{ file: string; depth: number }> = [{ file: startFile, depth: 0 }]; + let queueIndex = 0; + while (queueIndex < queue.length) { + const current = queue[queueIndex]!; + queueIndex += 1; + if (current.depth >= state.maxDepth) continue; + const nextDepth = current.depth + 1; + for (const neighbor of getReverseNeighbors(adjacency, current.file)) { + if (visited.has(neighbor)) continue; + visited.add(neighbor); + queue.push({ file: neighbor, depth: nextDepth }); + maybeAddTest( + affected, + state, + neighbor, + `reverse dependency from ${changedDisplayPath}, depth ${nextDepth}`, + nextDepth, + omittedCounts, + ); + } + } + } + + await addDeletedImporterTests(affected, state, deletedFiles, omittedCounts); + return { affected, omittedCounts }; +} + +function formatAffectedEntry(projectRoot: string, entry: AffectedTestAccumulator): AffectedTestEntry { + return { + file: toProjectDisplayPath(projectRoot, entry.file), + reasons: Array.from(entry.reasons).sort(), + depth: entry.depth, + }; +} + +function buildAffectedReport( + projectRoot: string, + changedFiles: readonly string[], + affected: Map, + omittedCounts: AffectedOmittedCounts, +): AffectedTestsReport { + const affectedTests = Array.from(affected.values()) + .map((entry) => formatAffectedEntry(projectRoot, entry)) + .sort((left, right) => left.file.localeCompare(right.file)); + return { + schemaVersion: 1, + root: normalizePath(projectRoot), + changedFiles: changedFiles.map((file) => toProjectDisplayPath(projectRoot, file)).sort(), + affectedTests, + omittedCounts, + }; +} + +function formatPrettyReport(report: AffectedTestsReport): string { + const lines = ["Affected tests"]; + if (!report.affectedTests.length) { + lines.push("- None detected."); + return lines.join("\n"); + } + for (const entry of report.affectedTests) { + lines.push(`- ${entry.file} (${entry.reasons.join("; ")})`); + } + return lines.join("\n"); +} + +async function collectChangedFileInputs(context: AffectedCommandContext): Promise { + const inputs: string[] = [...context.positionals]; + const deletedFiles: string[] = []; + if (context.hasFlag("--stdin")) { + inputs.push(...parseStdinFiles(await context.readStdin())); + } + + const base = context.getOpt("--base"); + const head = context.getOpt("--head"); + if ((base && !head) || (head && !base)) { + throw new Error("--base and --head must be provided together for affected git diff input."); + } + if (base && head) { + const diff = await getDiff({ provider: "git", base, head, cwd: context.projectRootFs }); + for (const change of diff.files) { + const normalizedChange = normalizeImpactFileChange(context.projectRootFs, change); + inputs.push(normalizedChange.path); + for (const deletedFile of deletedPathsForChange(normalizedChange)) { + deletedFiles.push(deletedFile); + } + } + } + + return { files: inputs, deletedFiles }; +} + +function deletedPathsForChange(change: FileChange): string[] { + if (change.kind === "deleted") { + return [change.path]; + } + if (change.kind === "renamed" && change.oldPath) { + return [change.oldPath]; + } + return []; +} + +async function buildAffectedReportFromContext(context: AffectedCommandContext): Promise { + const inputs = await collectChangedFileInputs(context); + if (!inputs.files.length) { + throw new Error("Usage: codegraph affected [--stdin] [--base --head ] [--root ]"); + } + + const normalizedChangedFiles = Array.from( + new Set(inputs.files.map((input) => normalizeChangedFileInput(context.projectRootFs, input))), + ).sort(); + const normalizedDeletedFiles = Array.from( + new Set(inputs.deletedFiles.map((input) => normalizeChangedFileInput(context.projectRootFs, input))), + ).sort(); + const depth = parseNonNegativeIntegerOption(context.getOpt("--depth"), "--depth", 1); + const index = await buildProjectIndex(context.projectRootFs, context.buildOptions); + const testPatterns = compileTestPatterns(undefined); + const matchesTestFile = createIndexTestFileMatcher( + index, + testPatterns, + context.projectRootFs, + normalizedChangedFiles, + ); + const passesFilter = createTestFilter(context.projectRootFs, context.parsedOptions.get("--filter") ?? []); + const { affected, omittedCounts } = await collectAffectedTests(normalizedChangedFiles, normalizedDeletedFiles, { + index, + projectRoot: context.projectRootFs, + maxDepth: depth, + matchesTestFile, + passesFilter, + }); + return buildAffectedReport(context.projectRootFs, normalizedChangedFiles, affected, omittedCounts); +} + +export async function handleAffectedCommand(context: AffectedCommandContext): Promise { + try { + if (context.hasFlag("--json") && context.hasFlag("--quiet")) { + throw new Error("Use either --json or --quiet for affected output, not both."); + } + const report = await buildAffectedReportFromContext(context); + if (context.hasFlag("--json")) { + context.writeJSONLine(report); + return; + } + if (context.hasFlag("--quiet")) { + for (const entry of report.affectedTests) { + context.writeStdoutLine(entry.file); + } + return; + } + context.writeStdoutLine(formatPrettyReport(report)); + } catch (error) { + context.writeStderrLine(error instanceof Error ? error.message : String(error)); + context.exit(2); + } +} diff --git a/src/cli/help.ts b/src/cli/help.ts index b721b510..029f94a1 100644 --- a/src/cli/help.ts +++ b/src/cli/help.ts @@ -9,6 +9,7 @@ Commands: packet Retrieve bounded evidence packets by file path or stable target search Ranked agent search across files, symbols, chunks, SQL, and graph context explain Explain a file, symbol, SQL object, or search handle + affected List tests likely affected by changed files impact Analyze PR impact inspect Summarize repo structure and recommend next commands graph Build dependency graph (default) @@ -76,6 +77,7 @@ Recommended review commands: codegraph impact --base HEAD --head WORKTREE --pretty (optional blast-radius follow-up) codegraph search "auth user" --json codegraph explain src/auth.ts --json + codegraph affected --base HEAD --head WORKTREE --quiet Unfamiliar repo: codegraph orient --root . --budget small --pretty @@ -87,6 +89,7 @@ Examples: codegraph search "auth user" --json codegraph explore "how does auth reach db?" --pretty codegraph explain src/auth.ts --json + codegraph affected src/auth.ts --quiet codegraph impact --provider git --base HEAD --head WORKTREE codegraph init --root . codegraph status --root . --json @@ -119,6 +122,7 @@ Examples: `; const knownCliCommands = new Set([ + "affected", "apisurface", "artifact", "chunk", @@ -179,6 +183,18 @@ State: Positional paths and --root are alternatives for lifecycle commands; do not combine them. `; +export const AFFECTED_HELP_TEXT = `codegraph affected - List tests likely affected by changed files + +Usage: + codegraph affected [file...] [--stdin] [--base --head ] [--root ] [--depth ] [--filter ] [--json | --quiet] + +Options: + --depth Reverse dependency traversal depth (default: 1) + --filter Restrict returned test files by project-root-relative glob + --stdin Read newline-delimited changed files from stdin + --quiet Print affected test paths only +`; + export const INSTALL_HELP_TEXT = `codegraph install - Configure Codegraph for supported agent clients Usage: codegraph install [target] [--target ] [--yes | --dry-run] [--print-config ] [--detect] [--json] @@ -411,6 +427,7 @@ Options: `; export function helpTextForCommand(command: string, positionals: readonly string[]): string | undefined { + if (command === "affected") return AFFECTED_HELP_TEXT; if (command === "search") return SEARCH_HELP_TEXT; if (command === "orient") return ORIENT_HELP_TEXT; if (command === "packet") return PACKET_HELP_TEXT; diff --git a/src/cli/options.ts b/src/cli/options.ts index 7b75b952..ca827f06 100644 --- a/src/cli/options.ts +++ b/src/cli/options.ts @@ -37,6 +37,7 @@ const CLI_VALUE_OPTIONS = new Set([ "--repo", "--max-refs", "--depth", + "--filter", "--sort", "--scope", "--ref-context", @@ -164,6 +165,16 @@ function graphCommandSchema(positionals: CliPositionalPolicy): CliCommandSchema } const CLI_COMMAND_SCHEMAS = new Map([ + [ + "affected", + commandSchema( + [...SHARED_BUILD_FLAGS, "--json", "--quiet", "--stdin"], + [...SHARED_BUILD_OPTIONS, "--base", "--depth", "--filter", "--head"], + { + kind: "any", + }, + ), + ], [ "apisurface", commandSchema([...SHARED_BUILD_FLAGS, ...JSON_OUTPUT_FLAGS], SHARED_BUILD_OPTIONS, { diff --git a/src/review/deleted.ts b/src/review/deleted.ts index 6f818eeb..52d32c4f 100644 --- a/src/review/deleted.ts +++ b/src/review/deleted.ts @@ -27,6 +27,11 @@ export type DeletedFileSnapshot = { module: ModuleIndex; }; +export type DeletedFileImporter = { + file: FileId; + deletedFile: FileId; +}; + type ReviewableExportEntry = Exclude; function normalizeSpecifierBase(fromFile: string, spec: string): string { @@ -114,18 +119,15 @@ async function resolveDeletedAliasImportTarget( .find((candidate) => candidate === deletedTarget); } -export async function listDirectDeletedFileTestImporters( +export async function listDirectDeletedFileImporters( index: ProjectIndex, deletedFiles: readonly string[], - testPatterns: string[] = [], projectRoot?: string, -): Promise { +): Promise { if (!deletedFiles.length) return []; const deletedFileSet = new Set(deletedFiles.map((file) => normalizePath(file))); - const compiledPatterns = compileTestPatterns(testPatterns); - const isIndexTestFile = createIndexTestFileMatcher(index, compiledPatterns, projectRoot); - const candidates = new Map(); + const candidates = new Map(); const importsByFile = new Map>(); const workspaceConfig = projectRoot ? await loadWorkspaceConfig(projectRoot) : undefined; @@ -142,7 +144,6 @@ export async function listDirectDeletedFileTestImporters( } for (const mod of index.byFile.values()) { - if (!isIndexTestFile(mod.file)) continue; const uniqueImports = new Map(); for (const entry of importsByFile.get(mod.file) ?? []) { uniqueImports.set(`${entry.spec}::${entry.resolved ?? ""}`, entry); @@ -164,10 +165,9 @@ export async function listDirectDeletedFileTestImporters( if (!matchesDeletedImportTarget(mod.file, entry.spec, resolvedAliasTarget, deletedFile)) { continue; } - candidates.set(mod.file, { + candidates.set(`${mod.file}::${deletedFile}`, { file: mod.file, - confidence: "high", - reason: "importsChanged", + deletedFile, }); } } @@ -176,6 +176,23 @@ export async function listDirectDeletedFileTestImporters( return Array.from(candidates.values()); } +export async function listDirectDeletedFileTestImporters( + index: ProjectIndex, + deletedFiles: readonly string[], + testPatterns: string[] = [], + projectRoot?: string, +): Promise { + const compiledPatterns = compileTestPatterns(testPatterns); + const isIndexTestFile = createIndexTestFileMatcher(index, compiledPatterns, projectRoot); + return (await listDirectDeletedFileImporters(index, deletedFiles, projectRoot)) + .filter((candidate) => isIndexTestFile(candidate.file)) + .map((candidate) => ({ + file: candidate.file, + confidence: "high", + reason: "importsChanged", + })); +} + async function readGitFileAtRevision(projectRoot: string, revision: string, file: string): Promise { const relativeFile = normalizePath(path.relative(projectRoot, file)); if (!relativeFile || relativeFile.startsWith("..")) return null; diff --git a/tests/affected.test.ts b/tests/affected.test.ts new file mode 100644 index 00000000..f9a072ac --- /dev/null +++ b/tests/affected.test.ts @@ -0,0 +1,458 @@ +import fsp from "node:fs/promises"; +import path from "node:path"; +import { describe, expect, it } from "vitest"; + +import { captureCli, runCliOrThrow } from "./helpers/cli.js"; +import { mkTmpDir, normalizeTestPath } from "./helpers/filesystem.js"; +import { runGit } from "./helpers/git.js"; + +type ProjectFile = { + path: string; + contents: string; +}; + +type AffectedTestEntry = { + file: string; + reasons: string[]; + depth: number; +}; + +type AffectedJsonReport = { + schemaVersion: 1; + root: string; + changedFiles: string[]; + affectedTests: AffectedTestEntry[]; + omittedCounts: { + changedFiles: number; + filteredTests: number; + }; +}; + +const affectedCliTimeoutMs = 30_000; + +async function writeProjectFile(root: string, file: ProjectFile): Promise { + const absolutePath = path.join(root, file.path); + await fsp.mkdir(path.dirname(absolutePath), { recursive: true }); + await fsp.writeFile(absolutePath, file.contents, "utf8"); +} + +async function createTypescriptProject(prefix: string, files: readonly ProjectFile[]): Promise { + const root = await mkTmpDir(prefix); + await Promise.all(files.map(async (file) => await writeProjectFile(root, file))); + return root; +} + +async function runAffectedJson( + root: string, + args: readonly string[], + stdin?: string, + cwd = root, +): Promise { + const result = await runCliOrThrow(["affected", "--root", root, "--cache", "memory", "--json", ...args], { + cwd, + stdin, + }); + expect(result.stderr).toBe(""); + return JSON.parse(result.stdout) as AffectedJsonReport; +} + +async function runAffectedQuiet(root: string, args: readonly string[]): Promise { + const result = await runCliOrThrow(["affected", "--root", root, "--cache", "memory", "--quiet", ...args], { + cwd: root, + }); + expect(result.stderr).toBe(""); + return result.stdout.trimEnd().split("\n").filter(Boolean); +} + +function expectReasonMentions(entry: AffectedTestEntry | undefined, expectedPath: string): void { + expect(entry).toBeDefined(); + expect(entry?.reasons).toEqual(expect.arrayContaining([expect.stringContaining(expectedPath)])); +} + +describe("affected CLI", () => { + it( + "maps positional source files to direct importing tests and emits sorted root-relative JSON", + async () => { + const root = await createTypescriptProject("cg-affected-direct-", [ + { + path: "src/format.ts", + contents: "export function formatName(name: string) { return name.trim().toUpperCase(); }\n", + }, + { + path: "src/math.ts", + contents: "export function add(left: number, right: number) { return left + right; }\n", + }, + { + path: "tests/z-format.test.ts", + contents: + "import { formatName } from '../src/format';\nif (formatName(' Ada ') !== 'ADA') throw new Error('bad format');\n", + }, + { + path: "tests/a-math.test.ts", + contents: "import { add } from '../src/math';\nif (add(1, 2) !== 3) throw new Error('bad math');\n", + }, + { + path: "tests/unrelated.test.ts", + contents: "const untouched = 1;\nif (untouched !== 1) throw new Error('unreachable');\n", + }, + ]); + + const report = await runAffectedJson(root, ["src/math.ts", "src/format.ts"]); + + expect(report.schemaVersion).toBe(1); + expect(normalizeTestPath(report.root)).toBe(normalizeTestPath(root)); + expect(report.changedFiles).toEqual(["src/format.ts", "src/math.ts"]); + expect(report.affectedTests.map(({ file, depth }) => ({ file, depth }))).toEqual([ + { file: "tests/a-math.test.ts", depth: 1 }, + { file: "tests/z-format.test.ts", depth: 1 }, + ]); + const sortedAffectedFiles = report.affectedTests.map((entry) => entry.file).sort(); + expect(report.affectedTests.map((entry) => entry.file)).toEqual(sortedAffectedFiles); + expectReasonMentions(report.affectedTests[0], "src/math.ts"); + expectReasonMentions(report.affectedTests[1], "src/format.ts"); + expect(report.omittedCounts).toEqual({ changedFiles: 0, filteredTests: 0 }); + }, + affectedCliTimeoutMs, + ); + + it( + "walks transitive reverse dependencies up to the requested depth", + async () => { + const root = await createTypescriptProject("cg-affected-transitive-", [ + { + path: "src/core.ts", + contents: "export function loadUser(id: string) { return { id, name: 'Ada' }; }\n", + }, + { + path: "src/service.ts", + contents: + "import { loadUser } from './core';\nexport function renderUser(id: string) { return loadUser(id).name; }\n", + }, + { + path: "tests/service.test.ts", + contents: + "import { renderUser } from '../src/service';\nif (renderUser('42') !== 'Ada') throw new Error('bad user');\n", + }, + ]); + + const shallowReport = await runAffectedJson(root, ["src/core.ts", "--depth", "1"]); + const report = await runAffectedJson(root, ["src/core.ts", "--depth", "2"]); + + expect(shallowReport.changedFiles).toEqual(["src/core.ts"]); + expect(shallowReport.affectedTests).toEqual([]); + expect(report.changedFiles).toEqual(["src/core.ts"]); + expect(report.affectedTests.map(({ file, depth }) => ({ file, depth }))).toEqual([ + { file: "tests/service.test.ts", depth: 2 }, + ]); + expectReasonMentions(report.affectedTests[0], "src/core.ts"); + }, + affectedCliTimeoutMs, + ); + + it( + "with --depth 0 reports directly changed tests but not tests that only import changed sources", + async () => { + const root = await createTypescriptProject("cg-affected-depth-zero-", [ + { + path: "src/core.ts", + contents: "export function value() { return 42; }\n", + }, + { + path: "tests/core.test.ts", + contents: "import { value } from '../src/core';\nif (value() !== 42) throw new Error('bad value');\n", + }, + { + path: "tests/changed.test.ts", + contents: "const changedTestStillRuns = true;\nif (!changedTestStillRuns) throw new Error('bad test');\n", + }, + ]); + + const report = await runAffectedJson(root, ["src/core.ts", "tests/changed.test.ts", "--depth", "0"]); + + expect(report.changedFiles).toEqual(["src/core.ts", "tests/changed.test.ts"]); + expect(report.affectedTests.map(({ file, depth }) => ({ file, depth }))).toEqual([ + { file: "tests/changed.test.ts", depth: 0 }, + ]); + expect(report.affectedTests[0]?.reasons).toEqual(["changed test file"]); + }, + affectedCliTimeoutMs, + ); + + it( + "reads newline-delimited changed paths from --stdin", + async () => { + const root = await createTypescriptProject("cg-affected-stdin-", [ + { + path: "src/parser.ts", + contents: "export function parseFlag(input: string) { return input === 'yes'; }\n", + }, + { + path: "tests/parser.test.ts", + contents: + "import { parseFlag } from '../src/parser';\nif (!parseFlag('yes')) throw new Error('bad parser');\n", + }, + ]); + + const report = await runAffectedJson(root, ["--stdin"], "\nsrc/parser.ts\n\n"); + + expect(report.changedFiles).toEqual(["src/parser.ts"]); + expect(report.affectedTests.map(({ file, depth }) => ({ file, depth }))).toEqual([ + { file: "tests/parser.test.ts", depth: 1 }, + ]); + expectReasonMentions(report.affectedTests[0], "src/parser.ts"); + }, + affectedCliTimeoutMs, + ); + + it( + "resolves --root as the project boundary when invoked from another cwd", + async () => { + const root = await createTypescriptProject("cg-affected-root-cwd-", [ + { + path: "src/widget.ts", + contents: "export function renderWidget() { return 'widget'; }\n", + }, + { + path: "tests/widget.test.ts", + contents: + "import { renderWidget } from '../src/widget';\nif (renderWidget() !== 'widget') throw new Error('bad widget');\n", + }, + ]); + const cwd = await mkTmpDir("cg-affected-outside-cwd-"); + + const report = await runAffectedJson(root, ["src/widget.ts"], undefined, cwd); + + expect(normalizeTestPath(report.root)).toBe(normalizeTestPath(root)); + expect(report.changedFiles).toEqual(["src/widget.ts"]); + expect(report.affectedTests.map(({ file, depth }) => ({ file, depth }))).toEqual([ + { file: "tests/widget.test.ts", depth: 1 }, + ]); + expectReasonMentions(report.affectedTests[0], "src/widget.ts"); + }, + affectedCliTimeoutMs, + ); + + it( + "rejects --base without --head with a nonzero usage error", + async () => { + const root = await createTypescriptProject("cg-affected-base-without-head-", [ + { + path: "src/api.ts", + contents: "export const api = 1;\n", + }, + ]); + + const result = await captureCli(["affected", "--root", root, "--cache", "memory", "--json", "--base", "HEAD"], { + cwd: root, + }); + + expect(result.exitCode).toBe(2); + expect(result.stdout).toBe(""); + expect(result.stderr).toContain("--base and --head must be provided together"); + }, + affectedCliTimeoutMs, + ); + + it( + "applies --filter to the affected test set after graph traversal", + async () => { + const root = await createTypescriptProject("cg-affected-filter-", [ + { + path: "src/user.ts", + contents: "export function displayUser(name: string) { return `user:${name}`; }\n", + }, + { + path: "tests/integration/user.spec.ts", + contents: + "import { displayUser } from '../../src/user';\nif (displayUser('Ada') !== 'user:Ada') throw new Error('bad integration');\n", + }, + { + path: "tests/unit/user.test.ts", + contents: + "import { displayUser } from '../../src/user';\nif (displayUser('Ada') !== 'user:Ada') throw new Error('bad unit');\n", + }, + ]); + + const report = await runAffectedJson(root, ["src/user.ts", "--filter", "tests/unit/**"]); + + expect(report.changedFiles).toEqual(["src/user.ts"]); + expect(report.affectedTests.map(({ file, depth }) => ({ file, depth }))).toEqual([ + { file: "tests/unit/user.test.ts", depth: 1 }, + ]); + expectReasonMentions(report.affectedTests[0], "src/user.ts"); + expect(report.omittedCounts).toEqual({ changedFiles: 0, filteredTests: 1 }); + }, + affectedCliTimeoutMs, + ); + + it( + "derives changed files from --base/--head git diff without mutating the checkout", + async () => { + const root = await createTypescriptProject("cg-affected-git-", [ + { + path: ".gitignore", + contents: ".codegraph-cache/\n", + }, + { + path: "src/api.ts", + contents: "export function answer() { return 41; }\n", + }, + { + path: "tests/api.test.ts", + contents: "import { answer } from '../src/api';\nif (answer() !== 42) throw new Error('old api');\n", + }, + ]); + runGit(root, ["init"]); + runGit(root, ["add", "."]); + runGit(root, ["commit", "-m", "base"]); + await writeProjectFile(root, { + path: "src/api.ts", + contents: "export function answer() { return 42; }\n", + }); + runGit(root, ["add", "."]); + runGit(root, ["commit", "-m", "head"]); + const headBefore = runGit(root, ["rev-parse", "HEAD"]); + const statusBefore = runGit(root, ["status", "--short"]); + + const report = await runAffectedJson(root, ["--base", "HEAD~1", "--head", "HEAD"]); + const headAfter = runGit(root, ["rev-parse", "HEAD"]); + const statusAfter = runGit(root, ["status", "--short"]); + + expect(report.changedFiles).toEqual(["src/api.ts"]); + expect(report.affectedTests.map(({ file, depth }) => ({ file, depth }))).toEqual([ + { file: "tests/api.test.ts", depth: 1 }, + ]); + expectReasonMentions(report.affectedTests[0], "src/api.ts"); + expect(headAfter).toBe(headBefore); + expect(statusAfter).toBe(statusBefore); + }, + affectedCliTimeoutMs, + ); + + it( + "honors --depth for existing tests that import a source file deleted across --base/--head", + async () => { + const root = await createTypescriptProject("cg-affected-git-deleted-", [ + { + path: ".gitignore", + contents: ".codegraph-cache/\n", + }, + { + path: "src/legacy.ts", + contents: "export function legacyValue() { return 7; }\n", + }, + { + path: "tests/legacy.test.ts", + contents: + "import { legacyValue } from '../src/legacy';\nif (legacyValue() !== 7) throw new Error('bad legacy');\n", + }, + ]); + runGit(root, ["init"]); + runGit(root, ["add", "."]); + runGit(root, ["commit", "-m", "base"]); + runGit(root, ["rm", "src/legacy.ts"]); + runGit(root, ["commit", "-m", "delete legacy"]); + + const depthZeroReport = await runAffectedJson(root, ["--base", "HEAD~1", "--head", "HEAD", "--depth", "0"]); + const depthOneReport = await runAffectedJson(root, ["--base", "HEAD~1", "--head", "HEAD", "--depth", "1"]); + + expect(depthZeroReport.changedFiles).toEqual(["src/legacy.ts"]); + expect(depthZeroReport.affectedTests).toEqual([]); + expect(depthOneReport.changedFiles).toEqual(["src/legacy.ts"]); + expect(depthOneReport.affectedTests.map(({ file, depth }) => ({ file, depth }))).toEqual([ + { file: "tests/legacy.test.ts", depth: 1 }, + ]); + expectReasonMentions(depthOneReport.affectedTests[0], "src/legacy.ts"); + + await writeProjectFile(root, { + path: "tests/legacy.test.ts", + contents: "const legacyTestStillRuns = true;\nif (!legacyTestStillRuns) throw new Error('bad legacy test');\n", + }); + runGit(root, ["add", "tests/legacy.test.ts"]); + runGit(root, ["commit", "-m", "update legacy test"]); + + const depthZeroWithChangedTestReport = await runAffectedJson(root, [ + "--base", + "HEAD~2", + "--head", + "HEAD", + "--depth", + "0", + ]); + + expect(depthZeroWithChangedTestReport.changedFiles).toEqual(["src/legacy.ts", "tests/legacy.test.ts"]); + expect(depthZeroWithChangedTestReport.affectedTests.map(({ file, depth }) => ({ file, depth }))).toEqual([ + { file: "tests/legacy.test.ts", depth: 0 }, + ]); + expect(depthZeroWithChangedTestReport.affectedTests[0]?.reasons).toEqual(["changed test file"]); + }, + affectedCliTimeoutMs, + ); + + it( + "walks transitive reverse dependencies from a source file deleted across --base/--head only within --depth", + async () => { + const root = await createTypescriptProject("cg-affected-git-deleted-transitive-", [ + { + path: ".gitignore", + contents: ".codegraph-cache/\n", + }, + { + path: "src/core.ts", + contents: "export function coreValue() { return 11; }\n", + }, + { + path: "src/service.ts", + contents: "import { coreValue } from './core';\nexport function serviceValue() { return coreValue() + 1; }\n", + }, + { + path: "tests/service.test.ts", + contents: + "import { serviceValue } from '../src/service';\nif (serviceValue() !== 12) throw new Error('bad service');\n", + }, + ]); + runGit(root, ["init"]); + runGit(root, ["add", "."]); + runGit(root, ["commit", "-m", "base"]); + runGit(root, ["rm", "src/core.ts"]); + runGit(root, ["commit", "-m", "delete core"]); + + const depthOneReport = await runAffectedJson(root, ["--base", "HEAD~1", "--head", "HEAD", "--depth", "1"]); + const depthTwoReport = await runAffectedJson(root, ["--base", "HEAD~1", "--head", "HEAD", "--depth", "2"]); + + expect(depthOneReport.changedFiles).toEqual(["src/core.ts"]); + expect(depthOneReport.affectedTests).toEqual([]); + expect(depthTwoReport.changedFiles).toEqual(["src/core.ts"]); + expect(depthTwoReport.affectedTests.map(({ file, depth }) => ({ file, depth }))).toEqual([ + { file: "tests/service.test.ts", depth: 2 }, + ]); + expectReasonMentions(depthTwoReport.affectedTests[0], "src/core.ts"); + }, + affectedCliTimeoutMs, + ); + + it( + "prints only stable sorted test paths with --quiet", + async () => { + const root = await createTypescriptProject("cg-affected-quiet-", [ + { + path: "src/shared.ts", + contents: "export function shared() { return 'shared'; }\n", + }, + { + path: "tests/z-shared.test.ts", + contents: "import { shared } from '../src/shared';\nif (shared() !== 'shared') throw new Error('bad z');\n", + }, + { + path: "tests/a-shared.spec.ts", + contents: "import { shared } from '../src/shared';\nif (shared() !== 'shared') throw new Error('bad a');\n", + }, + ]); + + const lines = await runAffectedQuiet(root, ["src/shared.ts"]); + + expect(lines).toEqual(["tests/a-shared.spec.ts", "tests/z-shared.test.ts"]); + }, + affectedCliTimeoutMs, + ); +});