diff --git a/README.md b/README.md index 59244d99..178af37c 100644 --- a/README.md +++ b/README.md @@ -57,11 +57,18 @@ This repo keeps test fixtures out of default Codegraph scans with `codegraph.con { "discovery": { "ignoreGlobs": ["tests/samples/**", "tests/languages/samples/**"] + }, + "languages": { + "extensions": { + ".tpl": "php" + } } } ``` -Use this pattern in other repos when large fixture, generated, or vendored trees should not participate in search, unresolved-import checks, graphing, indexing, inspect, impact, or review runs. Config globs are project-root-relative. CLI `--include-glob` and `--ignore-glob` stay relative to each active scan root. +Use discovery globs in other repos when large fixture, generated, or vendored trees should not participate in search, unresolved-import checks, graphing, indexing, inspect, impact, or review runs. Config globs are project-root-relative, while CLI `--include-glob` and `--ignore-glob` stay relative to each active scan root. + +`languages.extensions` maps additional or built-in literal file suffixes to supported language IDs; longer suffixes win, and built-ins remain active unless explicitly remapped. Suffixes may contain letters, digits, `.`, `_`, `+`, and `-`. The `.vue` and `.svelte` suffixes are always handled as single-file components and cannot be remapped. ## Quick start @@ -87,6 +94,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 +148,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..bf3e97b3 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 @@ -78,7 +80,8 @@ Fall back to CLI when MCP is unavailable. ## Discovery -Durable repo-local ignores belong in `codegraph.config.json`. +Durable repo-local ignores and custom language suffixes belong in `codegraph.config.json`. +Use `languages.extensions` for literal suffix-to-language mappings such as `.tpl` to `php`; keys start with `.`, may contain letters, digits, `.`, `_`, `+`, and `-`, and values are supported language IDs. The longest suffix wins; `.vue` and `.svelte` are always handled as single-file components and cannot be remapped. One-off CLI filters use scan-root-relative `--include-glob` and `--ignore-glob`. Use `--no-gitignore` only when ignored files are intentionally in scope. diff --git a/docs/cli.md b/docs/cli.md index eee5577b..a19312a1 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 ` @@ -35,18 +36,27 @@ Commands that scan a project read `codegraph.config.json` from `--root` when it "includeGlobs": ["src/**/*.ts"], "ignoreGlobs": ["tests/samples/**", "tests/languages/samples/**"], "useGitignore": true + }, + "languages": { + "extensions": { + ".tpl": "php", + ".inc.php": "php", + ".build.ts": "ts" + } } } ``` - `discovery.includeGlobs` and `discovery.ignoreGlobs` are project-root-relative, even when a command scans child include roots. - `discovery.ignoreGlobs` is for large fixture, generated, or vendored folders that should not be indexed. +- `languages.extensions` maps additional or built-in literal suffixes to supported language IDs; keys must start with `.` and may contain letters, digits, `.`, `_`, `+`, and `-`, values must name a supported language, and the longest suffix wins. +- Built-in suffixes remain active unless explicitly remapped by `languages.extensions`; `.vue` and `.svelte` are always handled as single-file components and cannot be remapped. - CLI `--include-glob` and `--ignore-glob` values are one-off additions relative to each scanned root. - `inspect` follow-up commands preserve the selected `--root` and include roots. - `--no-gitignore` overrides `useGitignore`. Config globs and one-off CLI globs apply at different layers. `codegraph.config.json` globs are durable and project-root-relative. CLI scan-root globs are additive for a single command and are evaluated relative to each active scan root. `--no-gitignore` disables `.gitignore` filtering for that command only; it does not change config. -Cache and manifest reuse is rooted at `--root`. Reusing a project root lets commands share compatible index and graph entries when the file signatures, config, graph options, and relevant build options still match. Changing `--root`, changing discovery config, or changing graph options creates a different reuse boundary. Child include-root scans can reuse project-root cache entries, but command summaries and follow-up commands stay scoped to the selected include roots. +Configured language extensions automatically extend discovery for matching files and participate in cache compatibility checks. Reusing a project root lets commands share compatible index and graph entries when the file signatures, config, graph options, and relevant build options still match. Changing `--root`, changing discovery or language-extension config, or changing graph options creates a different reuse boundary. Child include-root scans can reuse project-root cache entries, but command summaries and follow-up commands stay scoped to the selected include roots. ## Core commands @@ -56,6 +66,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 +136,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/docs/library-api.md b/docs/library-api.md index bc92ef03..b05076fa 100644 --- a/docs/library-api.md +++ b/docs/library-api.md @@ -28,7 +28,7 @@ For repeated calls, prefer one warm session instead of rebuilding indexes ad hoc - `createCodeReviewSession()` for repeated navigation and impact work in library code - `createAgentSession()` or MCP for repeated orient/search/explain/packet work in agent hosts -CLI commands and agent sessions read `codegraph.config.json` from the project root when it exists. Core indexing APIs keep discovery explicit, so pass `discovery` options directly when you want the same scan scope in custom code: +CLI commands and agent sessions read `codegraph.config.json` from the project root when it exists. Core indexing APIs keep discovery and language mappings explicit, so pass both options directly when you want the same behavior in custom code: ```ts import { buildProjectIndex, loadCodegraphConfig } from "@lzehrung/codegraph"; @@ -37,9 +37,12 @@ const root = process.cwd(); const config = await loadCodegraphConfig(root); const index = await buildProjectIndex(root, { ...(config.discovery ? { discovery: config.discovery } : {}), + ...(config.languages?.extensions ? { languageExtensions: config.languages.extensions } : {}), }); ``` +`languageExtensions` uses normalized literal suffixes beginning with `.`, supported language IDs, and longest-suffix matching. Suffixes may contain letters, digits, `.`, `_`, `+`, and `-`; `.vue` and `.svelte` remain single-file components and cannot be remapped. + ## Public API Boundary The npm package exposes these supported entry points: diff --git a/docs/plans/2026-07-03-16-config-extension-mapping.md b/docs/plans/2026-07-03-16-config-extension-mapping.md index becb0232..a0102b24 100644 --- a/docs/plans/2026-07-03-16-config-extension-mapping.md +++ b/docs/plans/2026-07-03-16-config-extension-mapping.md @@ -35,10 +35,10 @@ type CodegraphConfig = { Rules: -- Extension keys must start with `.`. +- Extension keys must be literal suffixes starting with `.` and containing only letters, digits, `.`, `_`, `+`, or `-`. - Values must be supported language ids. - Longer extension keys win first, so `.inc.php` beats `.php`. -- Built-in extensions remain unless explicitly remapped. +- Built-in extensions remain unless explicitly remapped; `.vue` and `.svelte` remain single-file components and cannot be remapped. - Invalid mappings fail config validation with actionable errors. ## Integration diff --git a/src/agent/session.ts b/src/agent/session.ts index 616c10fe..bfd6c301 100644 --- a/src/agent/session.ts +++ b/src/agent/session.ts @@ -5,8 +5,9 @@ import type { BuildOptions, BuildReport, ProjectIndex } from "../indexer/types.j import { buildSymbolGraphDetailed } from "../graphs/symbol-graph-detailed.js"; import { type SymbolGraph } from "../graphs/symbol-graph.js"; import type { Graph } from "../types.js"; -import { listProjectFiles, type ProjectFileDiscoveryOptions } from "../util/projectFiles.js"; +import { DEFAULT_PROJECT_PATTERNS, listProjectFiles, type ProjectFileDiscoveryOptions } from "../util/projectFiles.js"; import { hasDiscoveryOptions, loadCodegraphConfig, mergeDiscoveryOptions } from "../config.js"; +import { languageExtensionPatterns, normalizeLanguageExtensions } from "../languages.js"; import { createAgentFileLookup } from "./normalize.js"; import { summarizeAnalysis, type AnalysisSummary } from "../analysisSummary.js"; @@ -81,6 +82,7 @@ export type AgentFileSignature = { type AgentDiscoverySettings = { discoveryOptions?: ProjectFileDiscoveryOptions; + languageExtensions?: BuildOptions["languageExtensions"]; }; type AgentFreshnessDiff = { @@ -96,12 +98,19 @@ async function resolveAgentDiscoverySettings(options: AgentSessionOptions): Prom const discoveryOptions = hasDiscoveryOptions(discovery) ? { ...discovery, globRoot: discovery.globRoot ?? options.root } : undefined; - return discoveryOptions ? { discoveryOptions } : {}; + const languageExtensions = + normalizeLanguageExtensions(options.buildOptions?.languageExtensions) ?? config.languages?.extensions; + return { + ...(discoveryOptions ? { discoveryOptions } : {}), + ...(languageExtensions ? { languageExtensions } : {}), + }; } export async function listAgentSessionFiles(options: AgentSessionOptions): Promise { - const { discoveryOptions } = await resolveAgentDiscoverySettings(options); - return await listProjectFiles(options.root, undefined, discoveryOptions); + const { discoveryOptions, languageExtensions } = await resolveAgentDiscoverySettings(options); + const customPatterns = languageExtensionPatterns(languageExtensions); + const patterns = customPatterns.length ? [...DEFAULT_PROJECT_PATTERNS, ...customPatterns] : undefined; + return await listProjectFiles(options.root, patterns, discoveryOptions); } function isMissingStatRace(error: unknown): boolean { @@ -200,7 +209,7 @@ export function createAgentSession(options: AgentSessionOptions): AgentSession { const loadBase = async (): Promise => { if (cachedBase) return cachedBase; const loadPromise = (async () => { - const { discoveryOptions } = await resolveAgentDiscoverySettings(options); + const { discoveryOptions, languageExtensions } = await resolveAgentDiscoverySettings(options); const files = await loadFiles(); cachedFileSignatures = await collectAgentFileSignatures(files); const buildOptions: BuildOptions & { files: string[] } = { @@ -209,6 +218,7 @@ export function createAgentSession(options: AgentSessionOptions): AgentSession { keepParsed: options.buildOptions?.keepParsed ?? true, files, ...(discoveryOptions ? { discovery: discoveryOptions } : {}), + ...(languageExtensions ? { languageExtensions } : {}), }; if ( options.buildOptions?.useNativeWorkers === undefined && diff --git a/src/cli.ts b/src/cli.ts index c4223ff7..c8301a38 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"; @@ -51,6 +52,7 @@ import { handleReviewCommand } from "./cli/review.js"; import { handleSearchCommand } from "./cli/search.js"; import { handleSkillCommand } from "./cli/skill.js"; import { hasDiscoveryOptions, loadCodegraphConfig, mergeDiscoveryOptions } from "./config.js"; +import { languageExtensionPatterns } from "./languages.js"; import { listChangedFiles } from "./util/git.js"; import { DEFAULT_PROJECT_PATTERNS, listProjectFiles, type ProjectFileDiscoveryOptions } from "./util/projectFiles.js"; import { normalizePath, resolveFilePathFromRoot, toProjectDisplayPath } from "./util/paths.js"; @@ -190,6 +192,7 @@ async function runCliWithActiveRuntime(rawArgs: string[]) { return { ...(progressHandler ? { onProgress: progressHandler } : {}), discovery: discoveryOptions, + ...(config.languages?.extensions ? { languageExtensions: config.languages.extensions } : {}), ...(cache !== undefined ? { cache } : {}), ...(hasFlag("--cache-strict") ? { cacheStrict: true } : {}), ...(hasFlag("--cache-verify") ? { cacheVerify: true } : {}), @@ -437,7 +440,11 @@ async function runCliWithActiveRuntime(rawArgs: string[]) { }; const resolveFilesFromRoots = async (): Promise => { - const patterns = cmd === "duplicates" ? DUPLICATE_PROJECT_PATTERNS : undefined; + const basePatterns = cmd === "duplicates" ? DUPLICATE_PROJECT_PATTERNS : undefined; + const customPatterns = languageExtensionPatterns(config.languages?.extensions); + const patterns = customPatterns.length + ? [...(basePatterns ?? DEFAULT_PROJECT_PATTERNS), ...customPatterns] + : basePatterns; if (!includeRootsAbs.length) { const diagnosticFiles = await listProjectFiles(projectRootFs, patterns, { ...diagnosticDiscoveryOptions, @@ -699,6 +706,7 @@ async function runCliWithActiveRuntime(rawArgs: string[]) { await handleGraphDeltaCommand({ projectRootFs, files, + languageExtensions: config.languages?.extensions, getOpt, hasFlag, cwd: getCwd, @@ -755,6 +763,7 @@ async function runCliWithActiveRuntime(rawArgs: string[]) { discoveryOptions, nativeMode, workerOpts, + languageExtensions: config.languages?.extensions, progressHandler, graphOptions: hasGraphOverrides ? buildGraphOptions() : undefined, reportEnabled, @@ -784,6 +793,7 @@ async function runCliWithActiveRuntime(rawArgs: string[]) { onProgress: progressHandler, discovery: discoveryOptions, ...(nativeMode !== "auto" ? { native: nativeMode } : {}), + ...(config.languages?.extensions ? { languageExtensions: config.languages.extensions } : {}), ...workerOpts, }, writeJSONLine, @@ -806,6 +816,7 @@ async function runCliWithActiveRuntime(rawArgs: string[]) { discovery: discoveryOptions, ...(hasGraphOverrides ? { graph: buildGraphOptions() } : {}), ...(nativeMode !== "auto" ? { native: nativeMode } : {}), + ...(config.languages?.extensions ? { languageExtensions: config.languages.extensions } : {}), ...workerOpts, }, writeJSONLine, @@ -911,6 +922,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; @@ -938,6 +966,7 @@ async function runCliWithActiveRuntime(rawArgs: string[]) { discovery: discoveryOptions, ...(graphOptions ? { graph: graphOptions } : {}), ...(nativeMode !== "auto" ? { native: nativeMode } : {}), + ...(config.languages?.extensions ? { languageExtensions: config.languages.extensions } : {}), ...workerOpts, }); @@ -990,6 +1019,7 @@ async function runCliWithActiveRuntime(rawArgs: string[]) { projectRootFs, includeRootsAbs, discoveryOptions, + languageExtensions: config.languages?.extensions, graphOptions: hasGraphOverrides || nativeMode !== "auto" ? buildGraphOptions() : undefined, nativeMode, workerOpts, @@ -1009,6 +1039,7 @@ async function runCliWithActiveRuntime(rawArgs: string[]) { projectRootFs, includeRootsAbs, discoveryOptions, + languageExtensions: config.languages?.extensions, graphOptions: hasGraphOverrides || nativeMode !== "auto" ? buildGraphOptions() : undefined, nativeMode, workerOpts, @@ -1040,6 +1071,7 @@ async function runCliWithActiveRuntime(rawArgs: string[]) { onProgress: progressHandler, discovery: discoveryOptions, ...(nativeMode !== "auto" ? { native: nativeMode } : {}), + ...(config.languages?.extensions ? { languageExtensions: config.languages.extensions } : {}), ...workerOpts, }, }); 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/graphDelta.ts b/src/cli/graphDelta.ts index 399ad650..54e5bfc4 100644 --- a/src/cli/graphDelta.ts +++ b/src/cli/graphDelta.ts @@ -15,6 +15,7 @@ export type GraphDeltaCommandContext = { nativeMode: NativeRuntimeMode; workerOpts: { useNativeWorkers: true } | Record; graphOptions: GraphBuildOptions | undefined; + languageExtensions: IncrementalBuildOptions["languageExtensions"]; gitBase: string | undefined; gitHead: string | undefined; changedSince: string | undefined; @@ -36,6 +37,7 @@ export async function handleGraphDeltaCommand(context: GraphDeltaCommandContext) incrementalStrict, files: context.files, }; + if (context.languageExtensions) deltaOptions.languageExtensions = context.languageExtensions; if (context.nativeMode !== "auto") deltaOptions.native = context.nativeMode; if (cache !== undefined) deltaOptions.cache = cache; if (context.gitBase) deltaOptions.gitBase = context.gitBase; 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/index.ts b/src/cli/index.ts index 453689ec..5d630cdb 100644 --- a/src/cli/index.ts +++ b/src/cli/index.ts @@ -26,6 +26,7 @@ export type IndexCommandContext = { discoveryOptions: ProjectFileDiscoveryOptions; nativeMode: NativeRuntimeMode; workerOpts: { useNativeWorkers: true } | Record; + languageExtensions: BuildOptions["languageExtensions"]; progressHandler: ((update: { current: number; total: number }) => void) | undefined; graphOptions: GraphBuildOptions | undefined; reportEnabled: boolean; @@ -66,6 +67,7 @@ export async function handleIndexCommand(context: IndexCommandContext): Promise< threads, discovery: context.discoveryOptions, ...(context.nativeMode !== "auto" ? { native: context.nativeMode } : {}), + ...(context.languageExtensions ? { languageExtensions: context.languageExtensions } : {}), ...context.workerOpts, ...(cache !== undefined ? { cache } : {}), cacheStrict, diff --git a/src/cli/inspect.ts b/src/cli/inspect.ts index b335b586..20c547da 100644 --- a/src/cli/inspect.ts +++ b/src/cli/inspect.ts @@ -1,23 +1,25 @@ import fs from "node:fs"; import path from "node:path"; -import { findDuplicates, type DuplicateConfidence, type DuplicateGroup } from "../duplicates.js"; +import { findDuplicates } from "../duplicates.js"; +import type { DuplicateConfidence, DuplicateGroup } from "../duplicates.js"; import { collectGraph } from "../graph-builder.js"; import { findDetailedCycles, getUnresolvedImports } from "../graphs/queries.js"; import { getHotspots } from "../graphs/hotspots.js"; -import { type GraphBuildOptions } from "../graphs/types.js"; +import type { GraphBuildOptions } from "../graphs/types.js"; import { buildProjectIndexIncremental } from "../indexer/build-index.js"; -import { type BuildReport } from "../indexer/types.js"; +import type { BuildReport } from "../indexer/types.js"; import { getNativeTreeSitterLoadError, getNativeTreeSitterSupportedLanguageIds, isNativeTreeSitterAvailable, - type NativeRuntimeMode, } from "../native/treeSitterNative.js"; +import type { NativeRuntimeMode } from "../native/treeSitterNative.js"; import type { Graph } from "../types.js"; import { restrictGraphToIncludeRoots } from "../util/includeRoots.js"; import { supportForFile } from "../languages.js"; +import type { LanguageExtensionMap } from "../languages.js"; import { toProjectDisplayPath } from "../util/paths.js"; -import { type ProjectFileDiscoveryOptions } from "../util/projectFiles.js"; +import type { ProjectFileDiscoveryOptions } from "../util/projectFiles.js"; import { parseCacheModeOption, parsePositiveIntegerOption } from "./options.js"; type CacheMode = "off" | "memory" | "disk"; @@ -94,6 +96,7 @@ export type InspectCommandContext = { projectRootFs: string; includeRootsAbs: string[]; discoveryOptions: ProjectFileDiscoveryOptions; + languageExtensions: LanguageExtensionMap | undefined; graphOptions: GraphBuildOptions | undefined; nativeMode: NativeRuntimeMode; workerOpts: { useNativeWorkers: true } | Record; @@ -149,6 +152,7 @@ async function buildScopedReportGraph( opts: { cache?: CacheMode; discovery?: ProjectFileDiscoveryOptions; + languageExtensions?: LanguageExtensionMap; graphOptions?: GraphBuildOptions; nativeMode?: NativeRuntimeMode; workerOpts?: { useNativeWorkers: true } | Record; @@ -165,6 +169,7 @@ async function buildScopedReportGraph( files, cache: "disk", ...(opts.discovery ? { discovery: opts.discovery } : {}), + ...(opts.languageExtensions ? { languageExtensions: opts.languageExtensions } : {}), ...(opts.progressHandler ? { onProgress: opts.progressHandler } : {}), ...(opts.nativeMode && opts.nativeMode !== "auto" ? { native: opts.nativeMode } : {}), ...(opts.workerOpts ?? {}), @@ -179,6 +184,7 @@ async function buildScopedReportGraph( const sourceGraph = await collectGraph(projectRoot, files, { ...(opts.graphOptions ?? {}), + ...(opts.languageExtensions ? { languageExtensions: opts.languageExtensions } : {}), ...(opts.report ? { report: opts.report } : {}), }); return { @@ -208,10 +214,13 @@ function summarizeDuplicateGroup(group: DuplicateGroup): DuplicateOpportunitySum }; } -function countFilesByLanguage(files: Iterable): Record { +function countFilesByLanguage( + files: Iterable, + languageExtensions?: LanguageExtensionMap, +): Record { const byLanguage: Record = {}; for (const file of files) { - const languageId = supportForFile(file)?.id ?? "other"; + const languageId = supportForFile(file, languageExtensions)?.id ?? "other"; byLanguage[languageId] = (byLanguage[languageId] ?? 0) + 1; } return byLanguage; @@ -248,6 +257,7 @@ async function buildInspectReport( files: string[], discovery: ProjectFileDiscoveryOptions, graphOptions: GraphBuildOptions | undefined, + languageExtensions: LanguageExtensionMap | undefined, cache: CacheMode | undefined, nativeMode: NativeRuntimeMode, workerOpts: { useNativeWorkers: true } | Record, @@ -264,6 +274,7 @@ async function buildInspectReport( files, cache: cache ?? "disk", discovery, + ...(languageExtensions ? { languageExtensions } : {}), ...(progressHandler ? { onProgress: progressHandler } : {}), ...(nativeMode !== "auto" ? { native: nativeMode } : {}), ...workerOpts, @@ -297,7 +308,7 @@ async function buildInspectReport( }, files: { total: files.length, - byLanguage: countFilesByLanguage(files), + byLanguage: countFilesByLanguage(files, languageExtensions), }, hotspots, unresolved: { @@ -343,6 +354,7 @@ export async function handleInspectCommand(context: InspectCommandContext): Prom files, context.discoveryOptions, context.graphOptions, + context.languageExtensions, cache, context.nativeMode, context.workerOpts, @@ -361,6 +373,7 @@ export async function handleHotspotsCommand(context: InspectCommandContext): Pro const { graph } = await buildScopedReportGraph(context.projectRootFs, context.includeRootsAbs, files, { ...(cache ? { cache } : {}), discovery: context.discoveryOptions, + ...(context.languageExtensions ? { languageExtensions: context.languageExtensions } : {}), ...(context.graphOptions ? { graphOptions: context.graphOptions } : {}), nativeMode: context.nativeMode, workerOpts: context.workerOpts, 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/config.ts b/src/config.ts index 9b671345..4787a9e4 100644 --- a/src/config.ts +++ b/src/config.ts @@ -1,12 +1,21 @@ import fsp from "node:fs/promises"; import path from "node:path"; import { z } from "zod"; -import { type ProjectFileDiscoveryOptions } from "./util/projectFiles.js"; +import { + isLiteralLanguageExtension, + isRemappableLanguageExtension, + normalizeLanguageExtensions, + supportById, +} from "./languages.js"; +import type { LanguageExtensionMap } from "./languages.js"; +import type { ProjectFileDiscoveryOptions } from "./util/projectFiles.js"; export const CODEGRAPH_CONFIG_FILE = "codegraph.config.json"; const stringArraySchema = z.array(z.string().trim().min(1)); +const languageExtensionsSchema = z.record(z.string().trim().min(1), z.string().trim().min(1)); + const codegraphConfigSchema = z .object({ discovery: z @@ -17,6 +26,12 @@ const codegraphConfigSchema = z }) .strict() .optional(), + languages: z + .object({ + extensions: languageExtensionsSchema.optional(), + }) + .strict() + .optional(), }) .strict(); @@ -24,6 +39,9 @@ type ParsedCodegraphConfig = z.infer; export type CodegraphConfig = { discovery?: ProjectFileDiscoveryOptions; + languages?: { + extensions?: LanguageExtensionMap; + }; }; function uniq(values: readonly string[]): string[] { @@ -78,6 +96,33 @@ function normalizeDiscoveryConfig( return hasDiscoveryOptions(normalized) ? normalized : undefined; } +function normalizeConfigLanguageExtensions( + extensions: Record | undefined, +): LanguageExtensionMap | undefined { + if (!extensions) return undefined; + for (const [rawKey, rawLanguageId] of Object.entries(extensions)) { + const key = rawKey.trim().toLowerCase(); + const languageId = rawLanguageId.trim().toLowerCase(); + if (!key.startsWith(".")) { + throw new Error(`Invalid ${CODEGRAPH_CONFIG_FILE}: languages.extensions key "${rawKey}" must start with ".".`); + } + if (!isLiteralLanguageExtension(key)) { + throw new Error( + `Invalid ${CODEGRAPH_CONFIG_FILE}: languages.extensions key "${rawKey}" must be a literal suffix containing only letters, digits, ".", "_", "+", or "-".`, + ); + } + if (!isRemappableLanguageExtension(key)) { + throw new Error(`Invalid ${CODEGRAPH_CONFIG_FILE}: languages.extensions key "${rawKey}" cannot be remapped.`); + } + if (!supportById(languageId)) { + throw new Error( + `Invalid ${CODEGRAPH_CONFIG_FILE}: languages.extensions["${rawKey}"] references unknown language "${languageId}".`, + ); + } + } + return normalizeLanguageExtensions(extensions); +} + export async function loadCodegraphConfig(projectRoot: string): Promise { const configPath = path.join(projectRoot, CODEGRAPH_CONFIG_FILE); let raw: string; @@ -102,8 +147,10 @@ export async function loadCodegraphConfig(projectRoot: string): Promise; logLevel?: LogLevel; allFiles?: string[]; + languageExtensions?: LanguageExtensionMap; }, ): Promise { const normalizePath = (file: string) => file.replace(/\\/g, "/"); const normalizedFiles = files.map(normalizePath); const normalizedAllFiles = (opts?.allFiles ?? files).map(normalizePath); + const isSqlFile = (file: string): boolean => supportForFile(file, opts?.languageExtensions)?.id === "sql"; const hasExplicitReplace = !!opts?.replaceFiles; const requestedReplaceSet = hasExplicitReplace ? new Set(Array.from(opts.replaceFiles ?? [], (file) => normalizePath(file))) @@ -95,7 +94,9 @@ export async function collectGraph( const shouldReplace = hasExplicitReplace && replaceSet.has(file); return shouldReplace || !canReuseSqlEdgeCache(file, sqlCorpusSig, opts?.fileSignatures, opts?.cachedFileEdges); }); - const sqlFactCache = sqlFactCacheNeeded ? await buildSqlFactCache(normalizedAllFiles) : undefined; + const sqlFactCache = sqlFactCacheNeeded + ? await buildSqlFactCache(normalizedAllFiles, opts?.languageExtensions) + : undefined; const conc = Math.max(1, Math.min(Number(opts?.threads || 0) || 32, 128)); @@ -146,6 +147,7 @@ export async function collectGraph( ...(opts?.report ? { report: opts.report } : {}), allFiles: normalizedAllFiles, ...(sqlFactCache ? { sqlFactCache } : {}), + ...(opts?.languageExtensions ? { languageExtensions: opts.languageExtensions } : {}), }); addEdgeTargetsToGraph(edges); return edges; diff --git a/src/graph-edge-collector.ts b/src/graph-edge-collector.ts index 64c81818..f2fb32aa 100644 --- a/src/graph-edge-collector.ts +++ b/src/graph-edge-collector.ts @@ -1,30 +1,28 @@ -import path from "node:path"; -import { type ParserLanguage } from "./parserBackend.js"; +import type { ParserLanguage } from "./parserBackend.js"; import { prepareSourceInput } from "./languages/filePrep.js"; -import { type LanguageSupport } from "./languages.js"; +import { supportForFile, type LanguageExtensionMap, type LanguageSupport } from "./languages.js"; import type { Edge } from "./types.js"; import { loadNearestTsconfigFor } from "./util/resolution.js"; -import { type WorkspaceConfig } from "./util/workspace.js"; +import type { WorkspaceConfig } from "./util/workspace.js"; import { extractJsTsDynamicSpecifiers } from "./util/specifiers.js"; -import { logWithLevel, type LogLevel } from "./logging.js"; +import { logWithLevel } from "./logging.js"; +import type { LogLevel } from "./logging.js"; import { graphOnlyLanguageSupportsImportAliases, graphOnlySpecifierNeedsResolutionConfig, isGraphOnlyLanguage, } from "./documentLinks.js"; -import { - getCompactImportsExecution, - type NativeRuntimeMode, - type CompactQueryResults, - type NativeQueryResults, -} from "./native/treeSitterNative.js"; +import { getCompactImportsExecution } from "./native/treeSitterNative.js"; +import type { NativeRuntimeMode, CompactQueryResults, NativeQueryResults } from "./native/treeSitterNative.js"; import { recordNativeExecutionOutcome } from "./native/nativeBackendReport.js"; -import { collectModuleSpecifiersFromSource, type FallbackImportExtractionEvent } from "./graphs/specifiers.js"; +import { collectModuleSpecifiersFromSource } from "./graphs/specifiers.js"; +import type { FallbackImportExtractionEvent } from "./graphs/specifiers.js"; import { collectPhpComposerImplicitEdges, resolveModuleSpecifierEdges } from "./graphs/edgeResolution.js"; import type { GraphCacheEntry } from "./graphs/types.js"; import type { BuildReport } from "./indexer/types.js"; import type { SyntaxTreeLike } from "./languages/types.js"; -import { collectSqlEdgesForFile, type SqlFactCache } from "./sql/sourceGraph.js"; +import { collectSqlEdgesForFile } from "./sql/sourceGraph.js"; +import type { SqlFactCache } from "./sql/sourceGraph.js"; const cloneEdge = (edge: Edge): Edge => ({ ...edge, @@ -52,6 +50,7 @@ export async function collectEdgesForFile( fileSignature?: { sig: string; gitSig?: string; cacheSig?: string }; sqlCorpusSig?: string; cachedFileEdges?: GraphCacheEntry; + languageExtensions?: LanguageExtensionMap; onFileEdges?: (file: string, entry: GraphCacheEntry) => void; onFallbackImportExtraction?: (event: FallbackImportExtractionEvent) => void; report?: BuildReport; @@ -64,7 +63,7 @@ export async function collectEdgesForFile( const sigEntry = opts.fileSignature; const sig = sigEntry?.sig; const gitSig = sigEntry?.gitSig; - const sqlFile = path.extname(normalizedFile).toLowerCase() === ".sql"; + const sqlFile = supportForFile(normalizedFile, opts.languageExtensions)?.id === "sql"; const emitCacheEntry = (edges: Edge[]) => { if (!sig || !opts.onFileEdges) return; @@ -96,7 +95,7 @@ export async function collectEdgesForFile( let compactNativeImports: CompactQueryResults | null = null; let graphOnlyLanguage = sup ? isGraphOnlyLanguage(sup.id) : false; if (!sup || src === undefined) { - const prep = await prepareSourceInput(file); + const prep = await prepareSourceInput(file, { languageExtensions: opts.languageExtensions }); sup = prep.sup; src = prep.source; graphOnlyLanguage = isGraphOnlyLanguage(sup.id); @@ -118,7 +117,7 @@ export async function collectEdgesForFile( if (sup.id === "sql") { const allFiles = opts.allFiles ?? [normalizedFile]; - const sqlEdges = await collectSqlEdgesForFile(normalizedFile, allFiles, opts.sqlFactCache); + const sqlEdges = await collectSqlEdgesForFile(normalizedFile, allFiles, opts.sqlFactCache, opts.languageExtensions); emitCacheEntry(sqlEdges); return sqlEdges; } diff --git a/src/indexer/build-cache.ts b/src/indexer/build-cache.ts index b1781b1a..9ce08333 100644 --- a/src/indexer/build-cache.ts +++ b/src/indexer/build-cache.ts @@ -30,6 +30,7 @@ export { diffBuildOptions, graphOptionsEqual, normalizeGraphOptions, + normalizeLanguageExtensions, summarizeBuildOptions, type ManifestBuildOptions, } from "./build-cache/options.js"; diff --git a/src/indexer/build-cache/module-cache.ts b/src/indexer/build-cache/module-cache.ts index e3a513f9..ea95f418 100644 --- a/src/indexer/build-cache/module-cache.ts +++ b/src/indexer/build-cache/module-cache.ts @@ -208,10 +208,11 @@ export async function cacheSignatureForFile(file: string, sigInfo: FileSignature export async function buildBloomFilterForFile( file: string, + opts?: Pick, ): Promise { try { const source = await fsp.readFile(file, "utf8"); - const support = supportForFile(file); + const support = supportForFile(file, opts?.languageExtensions); if (!support) return null; return buildBloomFilterFromSource(source, support.id); } catch { diff --git a/src/indexer/build-cache/options.ts b/src/indexer/build-cache/options.ts index 2956bb85..7631b8a4 100644 --- a/src/indexer/build-cache/options.ts +++ b/src/indexer/build-cache/options.ts @@ -1,7 +1,8 @@ import path from "node:path"; +import { normalizeLanguageExtensions, type LanguageExtensionMap } from "../../languages.js"; import type { GraphBuildOptions } from "../../graphs/types.js"; import { normalizePath, normalizeResolutionHints } from "../../util/paths.js"; -import { type ProjectFileDiscoveryOptions } from "../../util/projectFiles.js"; +import type { ProjectFileDiscoveryOptions } from "../../util/projectFiles.js"; import type { BuildOptions } from "../types.js"; export type ManifestBuildOptions = { @@ -17,9 +18,11 @@ export type ManifestBuildOptions = { gitignoreRoot?: string; useGitignore: boolean; }; + languageExtensions?: LanguageExtensionMap; }; function normalizeManifestBuildOptions(opts?: ManifestBuildOptions): ManifestBuildOptions { + const languageExtensions = normalizeLanguageExtensions(opts?.languageExtensions); return { cache: opts?.cache ?? "off", cacheStrict: opts?.cacheStrict ?? true, @@ -27,6 +30,7 @@ function normalizeManifestBuildOptions(opts?: ManifestBuildOptions): ManifestBui preset: opts?.preset, incrementalStrict: opts?.incrementalStrict ?? false, ...(opts?.discovery ? { discovery: opts.discovery } : {}), + ...(languageExtensions ? { languageExtensions } : {}), }; } @@ -50,8 +54,11 @@ function normalizeDiscoveryOptions(discovery?: ProjectFileDiscoveryOptions): Man }; } +export { normalizeLanguageExtensions } from "../../languages.js"; + function normalizeBuildOptions(opts?: BuildOptions): ManifestBuildOptions { const discovery = normalizeDiscoveryOptions(opts?.discovery); + const languageExtensions = normalizeLanguageExtensions(opts?.languageExtensions); return { cache: opts?.cache ?? "off", cacheStrict: opts?.cacheStrict ?? true, @@ -59,6 +66,7 @@ function normalizeBuildOptions(opts?: BuildOptions): ManifestBuildOptions { preset: opts?.preset, incrementalStrict: opts?.incrementalStrict ?? false, ...(discovery ? { discovery } : {}), + ...(languageExtensions ? { languageExtensions } : {}), }; } @@ -101,6 +109,19 @@ function normalizedDiscoveryOptionsEqual( return true; } +function normalizedLanguageExtensionsEqual( + a: Record | undefined, + b: Record | undefined, +): boolean { + const normalizedA = normalizeLanguageExtensions(a) ?? {}; + const normalizedB = normalizeLanguageExtensions(b) ?? {}; + const keys = Array.from(new Set([...Object.keys(normalizedA), ...Object.keys(normalizedB)])).sort(); + for (const key of keys) { + if (normalizedA[key] !== normalizedB[key]) return false; + } + return true; +} + export function diffBuildOptions( manifestOpts: ManifestBuildOptions | undefined, currentOpts: BuildOptions | undefined, @@ -123,6 +144,9 @@ export function diffBuildOptions( if (!normalizedDiscoveryOptionsEqual(normalizedManifest.discovery, normalizedCurrent.discovery)) { diffs.push("discovery"); } + if (!normalizedLanguageExtensionsEqual(normalizedManifest.languageExtensions, normalizedCurrent.languageExtensions)) { + diffs.push("languageExtensions"); + } return diffs; } diff --git a/src/indexer/build-index.ts b/src/indexer/build-index.ts index 02bc00a6..81d5cf1d 100644 --- a/src/indexer/build-index.ts +++ b/src/indexer/build-index.ts @@ -1,9 +1,15 @@ +import crypto from "node:crypto"; import fs from "node:fs"; import path from "node:path"; import { performance } from "node:perf_hooks"; -import { supportForFile, type LanguageSupport } from "../languages.js"; +import { languageExtensionPatterns, supportForFile, type LanguageSupport } from "../languages.js"; import { loadWorkspaceConfig, resolveWorkspacePackage } from "../util/workspace.js"; -import { discoverProjectFiles, listProjectFiles, type ProjectFileInfo } from "../util/projectFiles.js"; +import { + DEFAULT_PROJECT_PATTERNS, + discoverProjectFiles, + listProjectFiles, + type ProjectFileInfo, +} from "../util/projectFiles.js"; import { getGitHead, isGitRepo, getGitBlobHashes, listChangedFiles } from "../util/git.js"; import { clearImportResolutionCaches, resolveSpecifier } from "../util/resolution.js"; import { assertFilePathWithinRoot, normalizePath } from "../util/paths.js"; @@ -40,6 +46,7 @@ import { loadManifest, normalizeGraphOptions, normalizeIndexedFileInputs, + normalizeLanguageExtensions, projectSnapshotFilesSignature, recordConfigHashResult, recordFileFailure, @@ -239,6 +246,7 @@ async function buildIndexedModuleForFile(args: { graphOptions: args.graphOptions, ...(args.opts?.native ? { native: args.opts.native } : {}), ...(args.opts?.logLevel ? { logLevel: args.opts.logLevel } : {}), + ...(args.opts?.languageExtensions ? { languageExtensions: args.opts.languageExtensions } : {}), ...(args.onFallbackImportExtraction ? { onFallbackImportExtraction: args.onFallbackImportExtraction } : {}), }); collectJsonDependencies(imports, args.jsonDependencies); @@ -268,7 +276,9 @@ async function buildIndexedModuleForFile(args: { const sigInfo = args.fileSignatures.get(args.file); if (sigInfo) { - const cacheSig = args.cacheEnabled ? await cacheSignatureForFile(args.file, sigInfo) : sigInfo.cacheSig; + const cacheSig = args.cacheEnabled + ? await moduleCacheSignatureForFile(args.file, sigInfo, args.opts) + : sigInfo.cacheSig; writeToCache(args.projectRoot, args.file, cacheSig, mod, args.opts); } @@ -320,7 +330,28 @@ function graphEdgeKey(edge: Edge): string { return `${edge.from}::${target}::${edge.raw ?? ""}::${edge.typeOnly ? 1 : 0}`; } -function expandStarImports(modules: Map): void { +async function moduleCacheSignatureForFile(file: string, sigInfo: FileSignature, opts?: BuildOptions): Promise { + const baseSignature = await cacheSignatureForFile(file, sigInfo); + const normalizedExtensions = normalizeLanguageExtensions(opts?.languageExtensions); + if (!normalizedExtensions) return baseSignature; + // Combine via a hash rather than raw concatenation: the disk cache stores this string in a + // SQLite TEXT column, and node:sqlite's DatabaseSync silently truncates TEXT bind parameters + // at embedded NUL bytes, so a raw separator character risks the stored and freshly-computed + // signatures never matching (permanent cache miss) if either baseSignature or the serialized + // extensions ever contained one. + const hash = crypto.createHash("sha1"); + hash.update(baseSignature); + hash.update(JSON.stringify(Object.entries(normalizedExtensions))); + return hash.digest("hex"); +} + +function projectPatternsForLanguageExtensions(opts?: BuildOptions): string[] | undefined { + const customPatterns = languageExtensionPatterns(opts?.languageExtensions); + if (!customPatterns.length) return undefined; + return [...DEFAULT_PROJECT_PATTERNS, ...customPatterns]; +} + +function expandStarImports(modules: Map, opts?: BuildOptions): void { const importAlreadyPresent = (imports: ImportBinding[], candidate: ImportBinding): boolean => imports.some((existing) => { if (existing.kind !== candidate.kind) return false; @@ -343,7 +374,7 @@ function expandStarImports(modules: Map): void { if (imp.kind !== "star" || typeof imp.resolved !== "string") continue; const target = modules.get(imp.resolved); if (!target) continue; - const targetSupport = supportForFile(imp.resolved); + const targetSupport = supportForFile(imp.resolved, opts?.languageExtensions); const exportedSymbols = target.exports.filter((entry) => entry.type === "local").length ? target.exports .filter((entry): entry is Extract => entry.type === "local") @@ -489,6 +520,9 @@ async function buildIndexFromFileListShared( const manifestStart = performance.now(); const manifest = useManifest && !helperOpts?.ignoreExistingManifest ? await loadManifest(projectRoot, opts) : null; const manifestFiles = sanitizeManifestEntriesForRoot(projectRoot, manifest?.files); + const languageExtensionsChanged = manifest + ? diffBuildOptions(manifest.buildOptions, opts).includes("languageExtensions") + : false; if (timings && useManifest) { timings.manifestMs = Math.round(performance.now() - manifestStart); } @@ -501,7 +535,7 @@ async function buildIndexFromFileListShared( } } const cachedGraphEntries = - manifest && graphOptionsEqual(manifest.graphOptions, graphOptions) + manifest && !languageExtensionsChanged && graphOptionsEqual(manifest.graphOptions, graphOptions) ? new Map( Object.entries(manifestFiles).filter(([file]) => !staleCachedEdgeFiles.has(file)), ) @@ -521,7 +555,7 @@ async function buildIndexFromFileListShared( : new Map(); const conc = buildConcurrency(opts); const sqlFiles = normalizedFiles - .filter((file) => path.extname(file).toLowerCase() === ".sql") + .filter((file) => supportForFile(file, opts?.languageExtensions)?.id === "sql") .sort((left, right) => left.localeCompare(right)); const fileSignatures = await prepareFileSignatures({ files: sqlFiles, @@ -533,7 +567,7 @@ async function buildIndexFromFileListShared( const sqlCorpusSig = sqlCorpusSignature(sqlFiles, fileSignatures); let sqlFactCachePromise: Promise | undefined; const getSqlFactCache = (): Promise => { - sqlFactCachePromise ??= buildSqlFactCache(normalizedFiles); + sqlFactCachePromise ??= buildSqlFactCache(normalizedFiles, opts?.languageExtensions); return sqlFactCachePromise; }; const shouldProvideSqlFactCache = ( @@ -541,7 +575,7 @@ async function buildIndexFromFileListShared( sigInfo: FileSignature, cachedEdgesEntry: ManifestFileEntry | undefined, ): boolean => { - if (path.extname(file).toLowerCase() !== ".sql") return false; + if (supportForFile(file, opts?.languageExtensions)?.id !== "sql") return false; if (!cachedEdgesEntry || !sqlCorpusSig || cachedEdgesEntry.sqlCorpusSig !== sqlCorpusSig) return true; const matchesGitSig = !!sigInfo.gitSig && !!cachedEdgesEntry.gitSig && cachedEdgesEntry.gitSig === sigInfo.gitSig; return !(matchesGitSig || cachedEdgesEntry.sig === sigInfo.sig); @@ -576,7 +610,7 @@ async function buildIndexFromFileListShared( fileSignatures.set(file, sigInfo); } manifestEntriesForIndex.set(file, toProjectIndexManifestEntry(sigInfo)); - const cacheSig = cacheEnabled ? await cacheSignatureForFile(file, sigInfo) : sigInfo.cacheSig; + const cacheSig = cacheEnabled ? await moduleCacheSignatureForFile(file, sigInfo, opts) : sigInfo.cacheSig; let mod: ModuleIndex | null = cacheEnabled ? tryLoadFromCache(projectRoot, file, cacheSig, opts, report) : null; if (mod && fileReport) { fileReport.cached = (fileReport.cached ?? 0) + 1; @@ -599,6 +633,7 @@ async function buildIndexFromFileListShared( resolveNodeModules: !!graphOptions.resolveNodeModules, dynamicImportHeuristics: !!graphOptions.dynamicImportHeuristics, ...(opts?.native ? { native: opts.native } : {}), + ...(opts?.languageExtensions ? { languageExtensions: opts.languageExtensions } : {}), ...(opts?.logLevel ? { logLevel: opts.logLevel } : {}), ...(graphOptions.resolutionHints ? { resolutionHints: graphOptions.resolutionHints } : {}), fileSignature: sigInfo, @@ -610,13 +645,13 @@ async function buildIndexFromFileListShared( ...(sqlFactCache ? { sqlFactCache } : {}), }); if (bloomFilterCache) { - const filter = await buildBloomFilterForFile(file); + const filter = await buildBloomFilterForFile(file, opts); if (filter) bloomFilterCache.set(file, filter); } return [file, mod, edges] as const; } if (fileReport) fileReport.parsed = (fileReport.parsed ?? 0) + 1; - const support = supportForFile(file); + const support = supportForFile(file, opts?.languageExtensions); if (!support) return [file, createEmptyModuleIndex(file), []] as const; let graphContext: IndexedFileGraphContext | undefined; if (!mod) { @@ -651,6 +686,7 @@ async function buildIndexFromFileListShared( resolveNodeModules: !!graphOptions.resolveNodeModules, dynamicImportHeuristics: !!graphOptions.dynamicImportHeuristics, ...(opts?.native ? { native: opts.native } : {}), + ...(opts?.languageExtensions ? { languageExtensions: opts.languageExtensions } : {}), ...(opts?.logLevel ? { logLevel: opts.logLevel } : {}), ...(graphOptions.resolutionHints ? { resolutionHints: graphOptions.resolutionHints } : {}), fileSignature: sigInfo, @@ -709,7 +745,7 @@ async function buildIndexFromFileListShared( for (const jsonPath of jsonDependencies) { ensureJsonModule(modules, jsonPath); } - expandStarImports(modules); + expandStarImports(modules, opts); if (manifestEntries) { await writeIndexManifestSnapshot({ projectRoot, @@ -750,7 +786,7 @@ async function buildProjectIndexWithManifestOptions( ): Promise { try { const [files, projectFiles] = await Promise.all([ - listProjectFiles(projectRoot, undefined, { + listProjectFiles(projectRoot, projectPatternsForLanguageExtensions(opts), { ...opts?.discovery, ...(opts?.logLevel ? { logLevel: opts.logLevel } : {}), }), @@ -848,7 +884,7 @@ export async function buildProjectIndexIncremental( const currentConfigHashResult = await computeConfigHash(projectRoot, opts?.logLevel); const currentConfigHash = recordConfigHashResult(manifestReport, currentConfigHashResult, opts?.logLevel); const configChanged = !!currentConfigHash && (!manifest?.configHash || currentConfigHash !== manifest.configHash); - const requiresFullRebuild = optionDiffs.includes("discovery"); + const requiresFullRebuild = optionDiffs.some((diff) => diff === "discovery" || diff === "languageExtensions"); if (!manifest || !graphOptionsEqual(manifest.graphOptions, graphOptions) || configChanged || requiresFullRebuild) { if (configChanged) { logWithLevel(opts?.logLevel, "warn", "Configuration changed, rebuilding index..."); @@ -1043,14 +1079,14 @@ export async function buildProjectIndexIncremental( for (const file of allFiles) { if (changedFiles.has(file)) continue; const sigInfo = fileSignatures.get(file)!; - const cacheSig = cacheEnabled ? await cacheSignatureForFile(file, sigInfo) : sigInfo.cacheSig; + const cacheSig = cacheEnabled ? await moduleCacheSignatureForFile(file, sigInfo, opts) : sigInfo.cacheSig; const cached = cacheEnabled ? tryLoadFromCache(projectRoot, file, cacheSig, opts, report) : null; if (cached) { if (fileReport) fileReport.cached = (fileReport.cached ?? 0) + 1; modules.set(file, cached); collectJsonDependencies(cached.imports, jsonDependencies); if (bloomFilterCache) { - const filter = await buildBloomFilterForFile(file); + const filter = await buildBloomFilterForFile(file, opts); if (filter) bloomFilterCache.set(file, filter); } } else { @@ -1067,7 +1103,7 @@ export async function buildProjectIndexIncremental( const fileResults = await mapLimit(changedList, conc, async (file) => { try { if (fileReport) fileReport.parsed = (fileReport.parsed ?? 0) + 1; - const support = supportForFile(file); + const support = supportForFile(file, opts?.languageExtensions); if (!support) return [file, createEmptyModuleIndex(file)] as const; const built = await buildIndexedModuleForFile({ file, @@ -1114,7 +1150,7 @@ export async function buildProjectIndexIncremental( for (const jsonPath of jsonDependencies) { ensureJsonModule(modules, jsonPath); } - expandStarImports(modules); + expandStarImports(modules, opts); const retainedTrackedEntries = Object.entries(trackedEntries).filter(([file]) => !deletedTrackedFiles.has(file)); const cachedGraphEntries = new Map(retainedTrackedEntries); const manifestEntries = new Map(cachedGraphEntries); @@ -1145,6 +1181,7 @@ export async function buildProjectIndexIncremental( resolveNodeModules: !!graphOptions.resolveNodeModules, dynamicImportHeuristics: !!graphOptions.dynamicImportHeuristics, ...(opts?.native ? { native: opts.native } : {}), + ...(opts?.languageExtensions ? { languageExtensions: opts.languageExtensions } : {}), ...(opts?.logLevel ? { logLevel: opts.logLevel } : {}), ...(graphOptions.resolutionHints ? { resolutionHints: graphOptions.resolutionHints } : {}), allFiles: Array.from(allFiles), @@ -1198,6 +1235,13 @@ export async function buildGraphDelta(projectRoot: string, opts?: IncrementalBui const manifest = await loadManifest(projectRoot, opts); const trackedEntries = sanitizeManifestEntriesForRoot(projectRoot, manifest?.files); const graphOptions = normalizeGraphOptions(opts?.graph); + const previousLanguageExtensions = normalizeLanguageExtensions(manifest?.buildOptions?.languageExtensions); + const currentLanguageExtensions = normalizeLanguageExtensions(opts?.languageExtensions); + const languageExtensionsChanged = manifest + ? diffBuildOptions(manifest.buildOptions, opts).includes("languageExtensions") + : false; + const languageSupportChangedForFile = (file: string): boolean => + supportForFile(file, previousLanguageExtensions)?.id !== supportForFile(file, currentLanguageExtensions)?.id; const strictIncremental = opts?.incrementalStrict ?? false; if (strictIncremental && graphOptions.fast) graphOptions.fast = false; const explicitFiles = normalizeIndexedFileInputs(projectRoot, opts?.files ?? [], "Graph delta file").filter((file) => @@ -1238,7 +1282,12 @@ export async function buildGraphDelta(projectRoot: string, opts?: IncrementalBui explicitFiles.forEach((file) => changedFiles.add(file)); manifestDiffFiles.forEach((file) => changedFiles.add(file)); gitFiles.forEach((file) => changedFiles.add(file)); - if (allFiles.size === 0 && changedFiles.size === 0) { + if (languageExtensionsChanged) { + for (const file of trackedFiles) { + if (languageSupportChangedForFile(file)) changedFiles.add(file); + } + } + if (!languageExtensionsChanged && allFiles.size === 0 && changedFiles.size === 0) { return { changedFiles: [], added: [], removed: [] }; } if (manifest && graphOptionsEqual(manifest.graphOptions, graphOptions)) { @@ -1255,10 +1304,9 @@ export async function buildGraphDelta(projectRoot: string, opts?: IncrementalBui } } } - const changedList = Array.from(changedFiles); const beforeEdges = new Map(); if (manifest) { - for (const file of changedList) { + for (const file of changedFiles) { const entry = trackedEntries[file]; if (!entry?.edges) continue; for (const edge of entry.edges) { @@ -1267,6 +1315,12 @@ export async function buildGraphDelta(projectRoot: string, opts?: IncrementalBui } } const index = await buildProjectIndexIncremental(projectRoot, opts); + if (languageExtensionsChanged) { + for (const file of index.modules.keys()) { + if (languageSupportChangedForFile(file)) changedFiles.add(file); + } + } + const changedList = Array.from(changedFiles); const afterEdges = new Map(); for (const edge of index.graph.edges) { if (changedFiles.has(edge.from)) afterEdges.set(edgeKey(edge), edge); diff --git a/src/indexer/build-workers.ts b/src/indexer/build-workers.ts index 73e69696..37bbfe3a 100644 --- a/src/indexer/build-workers.ts +++ b/src/indexer/build-workers.ts @@ -126,10 +126,10 @@ export async function prepareFileContextForBuild( }); } } - prepared = await prepareFileForIndexing(file, opts?.native); + prepared = await prepareFileForIndexing(file, opts?.native, opts?.languageExtensions); } } else { - prepared = await prepareFileForIndexing(file, opts?.native); + prepared = await prepareFileForIndexing(file, opts?.native, opts?.languageExtensions); } if (isGraphOnlyLanguage(prepared.sup.id)) { return prepared; diff --git a/src/indexer/imports.ts b/src/indexer/imports.ts index c2c56bd4..f294300b 100644 --- a/src/indexer/imports.ts +++ b/src/indexer/imports.ts @@ -1,9 +1,10 @@ import { prepareSourceInput } from "../languages/filePrep.js"; import { loadNearestTsconfigFor, resolveImportSpecifier } from "../util/resolution.js"; import { loadWorkspaceConfig } from "../util/workspace.js"; -import { type LogLevel } from "../logging.js"; -import { type FallbackImportExtractionEvent, type FallbackImportExtractionReason } from "../graphs/specifiers.js"; +import type { LogLevel } from "../logging.js"; +import type { FallbackImportExtractionEvent, FallbackImportExtractionReason } from "../graphs/specifiers.js"; import type { GraphBuildOptions } from "../graphs/types.js"; +import type { LanguageExtensionMap } from "../languages.js"; import { isGraphOnlyLanguage } from "../documentLinks.js"; import { stripJsLikeComments } from "../util/comments.js"; import { @@ -12,10 +13,8 @@ import { isNativeBindingLoadedForLanguage, isNativeRequiredUnavailableError, isNativeQueryAuthoritative, - type NativeQueryExecution, - type NativeQueryResults, - type NativeRuntimeMode, } from "../native/treeSitterNative.js"; +import type { NativeQueryExecution, NativeQueryResults, NativeRuntimeMode } from "../native/treeSitterNative.js"; import type { ResolvedImportTarget } from "./imports/context.js"; import { collectGraphOnlyImports } from "./imports/graphOnly.js"; import { collectJsTextImports, collectJsTextValueRequireImports } from "./imports/jsTextImports.js"; @@ -43,13 +42,19 @@ export async function collectImportsForFile( native?: NativeRuntimeMode; onFallbackImportExtraction?: (event: FallbackImportExtractionEvent) => void; logLevel?: LogLevel; + languageExtensions?: LanguageExtensionMap; }, ): Promise { let source = opts?.source; let sup = opts?.sup; if (!source || !sup) { - const prep = await prepareSourceInput(file, source !== undefined ? { source } : undefined); + const prep = await prepareSourceInput( + file, + source !== undefined + ? { source, languageExtensions: opts?.languageExtensions } + : { languageExtensions: opts?.languageExtensions }, + ); source = prep.source; sup = prep.sup; } diff --git a/src/indexer/parse-context.ts b/src/indexer/parse-context.ts index b9bde573..d7b57735 100644 --- a/src/indexer/parse-context.ts +++ b/src/indexer/parse-context.ts @@ -1,4 +1,5 @@ import { isGraphOnlyLanguage } from "../documentLinks.js"; +import type { LanguageExtensionMap } from "../languages.js"; import { prepareSourceInput } from "../languages/filePrep.js"; import { getNativeQueryExecution, @@ -115,8 +116,12 @@ export function parsePreparedFileContext(context: PreparedFileContext): ParsedFi throw new Error(`Failed to reconstruct syntax tree for ${context.file}`); } -export async function prepareFileForIndexing(file: string, native?: NativeRuntimeMode): Promise { - const prep = await prepareSourceInput(file); +export async function prepareFileForIndexing( + file: string, + native?: NativeRuntimeMode, + languageExtensions?: LanguageExtensionMap, +): Promise { + const prep = await prepareSourceInput(file, { languageExtensions }); if (isGraphOnlyLanguage(prep.sup.id)) { return { file, diff --git a/src/indexer/types.ts b/src/indexer/types.ts index 25ecb0b3..f3751ea6 100644 --- a/src/indexer/types.ts +++ b/src/indexer/types.ts @@ -6,8 +6,8 @@ import type { NativeFallbackReason, NativeRuntimeMode } from "../native/contract import type { ScopeIndex } from "./scope-types.js"; import type { ReferenceCandidateIndex } from "./reference-candidate-types.js"; import type { ParsedFileContext } from "./parse-context.js"; -import type { Edge, FileId, Graph, Range } from "../types.js"; -import { type ProjectFileDiscoveryOptions, type ProjectFileInfo } from "../util/projectFiles.js"; +import type { Edge, FileId, Graph, ProgressUpdate, Range } from "../types.js"; +import type { ProjectFileDiscoveryOptions, ProjectFileInfo } from "../util/projectFiles.js"; import type { ImportBinding } from "./import-types.js"; export type { ImportBinding } from "./import-types.js"; @@ -128,8 +128,10 @@ export type ProjectIndex = { * optional `discovery` globs, and a `report` object when the caller wants * timings/backend diagnostics alongside the resulting index. */ +export type LanguageExtensionMap = import("../languages.js").LanguageExtensionMap; + export type BuildOptions = { - onProgress?: ((progress: import("../types.js").ProgressUpdate) => void) | undefined; + onProgress?: ((progress: ProgressUpdate) => void) | undefined; threads?: number; cache?: "off" | "memory" | "disk"; cacheDir?: string; @@ -147,6 +149,7 @@ export type BuildOptions = { useNativeWorkers?: boolean; nativeThreads?: number; discovery?: ProjectFileDiscoveryOptions; + languageExtensions?: LanguageExtensionMap; }; /** diff --git a/src/languages.ts b/src/languages.ts index 57fe8a4b..d7d522e4 100644 --- a/src/languages.ts +++ b/src/languages.ts @@ -73,7 +73,63 @@ export const SQL_SUPPORT = adaptDefinition(getLanguageById("sql")!); export const LANGUAGE_SUPPORTS: LanguageSupport[] = getAllLanguages().map(adaptDefinition); -export function supportForFile(filename: string): LanguageSupport | undefined { +export type LanguageExtensionMap = Record; + +const LANGUAGE_EXTENSION_KEY_PATTERN = /^\.[a-z0-9][a-z0-9._+-]*$/; +const NON_REMAPPABLE_EXTENSIONS = new Set([".vue", ".svelte"]); + +export function isLiteralLanguageExtension(extension: string): boolean { + return LANGUAGE_EXTENSION_KEY_PATTERN.test(extension.trim().toLowerCase()); +} + +export function isRemappableLanguageExtension(extension: string): boolean { + return !NON_REMAPPABLE_EXTENSIONS.has(extension.trim().toLowerCase()); +} + +export function normalizeLanguageExtensions( + extensions: LanguageExtensionMap | undefined, +): LanguageExtensionMap | undefined { + const entries = Object.entries(extensions ?? {}) + .map(([extension, languageId]) => [extension.trim().toLowerCase(), languageId.trim().toLowerCase()] as const) + .filter( + ([extension, languageId]) => + isLiteralLanguageExtension(extension) && isRemappableLanguageExtension(extension) && !!supportById(languageId), + ) + .sort((left, right) => left[0].localeCompare(right[0])); + if (!entries.length) return undefined; + return Object.fromEntries(entries); +} + +function caseInsensitiveExtensionPattern(extension: string): string { + const suffix = extension.replace(/[a-z]/g, (letter) => `[${letter}${letter.toUpperCase()}]`); + return `**/*${suffix}`; +} + +export function languageExtensionPatterns(extensions: LanguageExtensionMap | undefined): string[] { + return Object.keys(normalizeLanguageExtensions(extensions) ?? {}).map(caseInsensitiveExtensionPattern); +} + +function mappedSupportForFile( + filename: string, + extensionMap: LanguageExtensionMap | undefined, +): LanguageSupport | undefined { + const mappings = Object.entries(normalizeLanguageExtensions(extensionMap) ?? {}).sort( + (left, right) => right[0].length - left[0].length || left[0].localeCompare(right[0]), + ); + const lowerFilename = filename.toLowerCase(); + for (const [extension, languageId] of mappings) { + if (!lowerFilename.endsWith(extension)) continue; + return supportById(languageId); + } + return undefined; +} + +export function supportForFile( + filename: string, + extensionMap?: LanguageExtensionMap | undefined, +): LanguageSupport | undefined { + const mapped = mappedSupportForFile(filename, extensionMap); + if (mapped) return mapped; const ext = path.extname(filename).toLowerCase(); if (ext === ".h") { const sample = readFileSample(filename); @@ -82,8 +138,8 @@ export function supportForFile(filename: string): LanguageSupport | undefined { } return LANGUAGE_SUPPORTS.find((s) => s.matchExts.includes(ext)); } -export function languageForFile(filename: string): ParserLanguage { - const sup = supportForFile(filename); +export function languageForFile(filename: string, extensionMap?: LanguageExtensionMap | undefined): ParserLanguage { + const sup = supportForFile(filename, extensionMap); if (!sup) throw new Error(`Unsupported file extension: ${filename}`); return sup.language(filename); } diff --git a/src/languages/filePrep.ts b/src/languages/filePrep.ts index 5e55b262..de90d0f4 100644 --- a/src/languages/filePrep.ts +++ b/src/languages/filePrep.ts @@ -1,15 +1,10 @@ import fsp from "node:fs/promises"; import type { ParserLanguage } from "../languages/types.js"; -import { - JS_SUPPORT, - TS_SUPPORT, - TSX_SUPPORT, - supportForFile, - supportById, - type LanguageSupport, -} from "../languages.js"; -import { prepareSFCScriptSource, detectSFCFramework, type SFCFramework } from "./sfc.js"; +import { JS_SUPPORT, TS_SUPPORT, TSX_SUPPORT, supportForFile, supportById } from "../languages.js"; +import type { LanguageExtensionMap, LanguageSupport } from "../languages.js"; +import { prepareSFCScriptSource, detectSFCFramework } from "./sfc.js"; +import type { SFCFramework } from "./sfc.js"; interface ParserInput { source: string; @@ -38,7 +33,10 @@ const SCRIPT_SUPPORT_MAP: Record = { tsx: TSX_SUPPORT, }; -export async function prepareParserInput(file: string, opts?: { source?: string }): Promise { +export async function prepareParserInput( + file: string, + opts?: { source?: string | undefined; languageExtensions?: LanguageExtensionMap | undefined }, +): Promise { const prepared = await prepareSourceInput(file, opts); return { ...prepared, @@ -46,14 +44,17 @@ export async function prepareParserInput(file: string, opts?: { source?: string }; } -export async function prepareSourceInput(file: string, opts?: { source?: string }): Promise { +export async function prepareSourceInput( + file: string, + opts?: { source?: string | undefined; languageExtensions?: LanguageExtensionMap | undefined }, +): Promise { const framework = detectSFCFramework(file); if (framework) { const rawSource = opts?.source ?? (await fsp.readFile(file, "utf8")); return prepareSFCSourceInput(rawSource, framework); } - const sup = supportForFile(file); + const sup = supportForFile(file, opts?.languageExtensions); if (!sup) throw new UnsupportedParserInputError(file); const rawSource = opts?.source ?? (await fsp.readFile(file, "utf8")); return { 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/src/session.ts b/src/session.ts index fe43f52d..00ffad09 100644 --- a/src/session.ts +++ b/src/session.ts @@ -15,6 +15,7 @@ import { type SymbolDef, } from "./indexer/types.js"; import { buildProjectIndex, buildProjectIndexIncremental } from "./indexer/build-index.js"; +import { normalizeLanguageExtensions } from "./indexer/build-cache.js"; import { findReferences, goToDefinition } from "./indexer/navigation.js"; import { analyzeImpactFromDiff, @@ -110,6 +111,7 @@ function normalizeBuildOptions(options?: BuildOptions): Record gitignoreRoot: options.discovery.gitignoreRoot ? path.resolve(options.discovery.gitignoreRoot) : undefined, } : undefined, + languageExtensions: normalizeLanguageExtensions(options.languageExtensions), }; } @@ -241,10 +243,17 @@ export class CodeReviewSession implements ICodeReviewSession { private async currentBuildOptions(): Promise { const config = await loadCodegraphConfig(this.root); const discovery = mergeDiscoveryOptions(config.discovery, this.buildOptions?.discovery); - if (!hasDiscoveryOptions(discovery)) { + const hasDiscovery = hasDiscoveryOptions(discovery); + const languageExtensions = + normalizeLanguageExtensions(this.buildOptions?.languageExtensions) ?? config.languages?.extensions; + if (!hasDiscovery && !languageExtensions) { return this.buildOptions; } - return { ...this.buildOptions, discovery }; + return { + ...this.buildOptions, + ...(hasDiscovery ? { discovery } : {}), + ...(languageExtensions ? { languageExtensions } : {}), + }; } private async buildIndex(options: { forceFull?: boolean } = {}): Promise<{ diff --git a/src/sql/sourceGraph.ts b/src/sql/sourceGraph.ts index d71fed91..285cc4a7 100644 --- a/src/sql/sourceGraph.ts +++ b/src/sql/sourceGraph.ts @@ -1,6 +1,5 @@ import crypto from "node:crypto"; import fsp from "node:fs/promises"; -import path from "node:path"; import { uniqueByKey } from "../util/collections.js"; import type { ModuleIndex, SymbolDef } from "../indexer/types.js"; @@ -8,6 +7,7 @@ import { SymbolKind } from "../indexer/types.js"; import type { Edge, Range } from "../types.js"; import { normalizePath } from "../util/paths.js"; import { mapLimit } from "../util/concurrency.js"; +import { supportForFile, type LanguageExtensionMap } from "../languages.js"; import { extractSqlFactsFromSource, sqlObjectBaseName } from "./extractFacts.js"; import { pushSqlLookupValue } from "./lookup.js"; import type { SqlFactKind, SqlStatementFact } from "./types.js"; @@ -49,8 +49,8 @@ type SqlDefinitionCandidateMatch = { confidence: number; }; -function isSqlFile(filePath: string): boolean { - return path.extname(filePath).toLowerCase() === ".sql"; +function isSqlFile(filePath: string, languageExtensions?: LanguageExtensionMap): boolean { + return supportForFile(filePath, languageExtensions)?.id === "sql"; } export function sqlCorpusSignature( @@ -198,10 +198,13 @@ async function readSqlFacts(filePath: string): Promise { return extractSqlFactsFromSource(filePath, await fsp.readFile(filePath, "utf8")); } -export async function buildSqlFactCache(allFiles: readonly string[]): Promise { - const sqlFiles = Array.from(new Set(allFiles.map(normalizePath).filter(isSqlFile))).sort((left, right) => - left.localeCompare(right), - ); +export async function buildSqlFactCache( + allFiles: readonly string[], + languageExtensions?: LanguageExtensionMap, +): Promise { + const sqlFiles = Array.from( + new Set(allFiles.map(normalizePath).filter((file) => isSqlFile(file, languageExtensions))), + ).sort((left, right) => left.localeCompare(right)); const factGroups = await mapLimit( sqlFiles, SQL_FACT_READ_CONCURRENCY, @@ -225,10 +228,11 @@ export async function collectSqlEdgesForFile( filePath: string, allFiles: readonly string[], factCache?: SqlFactCache, + languageExtensions?: LanguageExtensionMap, ): Promise { const normalizedFile = normalizePath(filePath); - if (!isSqlFile(normalizedFile)) return []; - const cache = factCache ?? (await buildSqlFactCache(allFiles)); + if (!isSqlFile(normalizedFile, languageExtensions)) return []; + const cache = factCache ?? (await buildSqlFactCache(allFiles, languageExtensions)); const currentFacts = cache.factsByFile.get(normalizedFile) ?? []; const edges: Edge[] = []; const seen = new Set(); 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, + ); +}); diff --git a/tests/agent-session.test.ts b/tests/agent-session.test.ts index 071d0cf0..0a597cc4 100644 --- a/tests/agent-session.test.ts +++ b/tests/agent-session.test.ts @@ -92,6 +92,48 @@ describe("agent session", () => { expect(buildOptions?.useBloomFilters).toBe(false); }); + it("discovers built-in and configured custom files and forwards the normalized mapping", async () => { + const root = await mkRepo(); + const customPath = path.join(root, "feature.custom"); + await fs.writeFile(customPath, "export const customFeature = 1;\n"); + await fs.writeFile( + path.join(root, "codegraph.config.json"), + JSON.stringify({ languages: { extensions: { ".CUSTOM": " ts " } } }), + ); + const buildSpy = vi.spyOn(indexerBuild, "buildProjectIndexIncremental"); + + const snapshot = await createAgentSession({ root }).loadProject({ symbolGraph: "skip" }); + + expect(snapshot.files.map((file) => path.basename(file)).sort()).toEqual([ + "feature.custom", + "main.ts", + "schema.sql", + "util.ts", + ]); + expect(snapshot.index.byFile.get(customPath.replace(/\\/g, "/"))?.locals.map((local) => local.localName)).toContain( + "customFeature", + ); + expect(buildSpy.mock.calls[0]?.[1]?.languageExtensions).toEqual({ ".custom": "ts" }); + }); + + it("uses programmatic language extensions when listing agent session files", async () => { + const root = await mkRepo(); + await fs.writeFile(path.join(root, "feature.custom"), "export const customFeature = 1;\n"); + + const files = await createAgentSession({ + root, + useConfig: false, + buildOptions: { languageExtensions: { ".CUSTOM": " ts " } }, + }).listFiles?.(); + + expect(files?.map((file) => path.basename(file)).sort()).toEqual([ + "feature.custom", + "main.ts", + "schema.sql", + "util.ts", + ]); + }); + it("auto-enables native workers for large agent builds unless explicitly disabled", async () => { const root = await fs.mkdtemp(path.join(os.tmpdir(), "cg-agent-session-large-")); for (let index = 0; index < 260; index += 1) { diff --git a/tests/cache-invalidation.test.ts b/tests/cache-invalidation.test.ts index 5993d5a0..592034c0 100644 --- a/tests/cache-invalidation.test.ts +++ b/tests/cache-invalidation.test.ts @@ -221,6 +221,58 @@ describe("Cache invalidation and strict hashing", () => { expect(second.byFile.has(normalizedSamplePath)).toBe(true); }); + it("rediscovers newly supported files when language extensions change incrementally", async () => { + const root = await mkTmpDir("dg-incremental-language-extension-discovery-"); + const mappedPath = path.join(root, "newly-supported.tpl"); + await fsp.writeFile(mappedPath, " local.localName)).toContain( + "newly_mapped", + ); + }); + + it("recomputes manifest graph edges when a custom extension is remapped", async () => { + const root = await mkTmpDir("dg-full-language-extension-graph-"); + const mappedPath = path.join(root, "entry.tpl"); + const dependencyPath = path.join(root, "dependency.ts"); + await fsp.writeFile(mappedPath, 'import "./dependency";\n', "utf8"); + await fsp.writeFile(dependencyPath, "export const dependency = 1;\n", "utf8"); + + const htmlIndex = await buildProjectIndex(root, { + cache: "disk", + languageExtensions: { ".tpl": "html" }, + logLevel: "silent", + threads: 1, + }); + expect(htmlIndex.graph.edges.filter((edge) => edge.from === normalize(mappedPath))).toEqual([]); + + const tsIndex = await buildProjectIndex(root, { + cache: "disk", + languageExtensions: { ".tpl": "ts" }, + logLevel: "silent", + threads: 1, + }); + + expect(tsIndex.graph.edges).toContainEqual({ + from: normalize(mappedPath), + to: { type: "file", path: normalize(dependencyPath) }, + raw: "./dependency", + }); + }); + it("normalizes discovery glob separators before comparing manifest build options", () => { const buildOptions = summarizeBuildOptions({ discovery: { @@ -305,6 +357,52 @@ describe("Cache invalidation and strict hashing", () => { ); }); + it("rebuilds cross-file SQL edges when a custom-mapped SQL definition changes", async () => { + const root = await mkTmpDir("dg-mapped-sql-corpus-cache-"); + const schemaPath = path.join(root, "schema.ddl"); + const reportPath = path.join(root, "report.sql"); + const schemaFile = normalize(path.resolve(schemaPath)); + const reportFile = normalize(path.resolve(reportPath)); + await fsp.writeFile(schemaPath, "CREATE TABLE users (id integer);\n", "utf8"); + await fsp.writeFile(reportPath, "SELECT id FROM users;\nSELECT id FROM accounts;\n", "utf8"); + + const initial = await buildProjectIndex(root, { + threads: 2, + cache: "disk", + languageExtensions: { ".ddl": "sql" }, + }); + + expect(initial.graph.edges).toContainEqual( + expect.objectContaining({ + from: reportFile, + raw: "sql:reads_from:users", + to: { type: "file", path: schemaFile }, + }), + ); + + await fsp.writeFile(schemaPath, "CREATE TABLE accounts (id integer, active boolean);\n", "utf8"); + const rebuilt = await buildProjectIndex(root, { + threads: 2, + cache: "disk", + languageExtensions: { ".ddl": "sql" }, + }); + + expect(rebuilt.graph.edges).not.toContainEqual( + expect.objectContaining({ + from: reportFile, + raw: "sql:reads_from:users", + to: { type: "file", path: schemaFile }, + }), + ); + expect(rebuilt.graph.edges).toContainEqual( + expect.objectContaining({ + from: reportFile, + raw: "sql:reads_from:accounts", + to: { type: "file", path: schemaFile }, + }), + ); + }); + it("reuses cached SQL edges without rereading the SQL corpus", async () => { const root = await mkTmpDir("dg-sql-edge-cache-no-read-"); const schemaPath = path.join(root, "schema.sql"); diff --git a/tests/cli-command-modules.test.ts b/tests/cli-command-modules.test.ts index 160313a1..5afa153f 100644 --- a/tests/cli-command-modules.test.ts +++ b/tests/cli-command-modules.test.ts @@ -713,17 +713,20 @@ describe("CLI command modules", () => { } }); - test("writes graph delta output through the extracted graph-delta command handler", async () => { + test("forwards custom language mappings through the graph-delta command handler", async () => { const tempDir = await fsp.mkdtemp(path.join(os.tmpdir(), "codegraph-delta-module-")); const outputPath = path.join(tempDir, "delta.json"); - const sourcePath = path.join(tempDir, "main.ts"); + const sourcePath = path.join(tempDir, "main.tpl"); + const helperPath = path.join(tempDir, "helper.ts"); await fsp.writeFile(sourcePath, "import { helper } from './helper';\nhelper();\n", "utf8"); - await fsp.writeFile(path.join(tempDir, "helper.ts"), "export function helper() { return 1; }\n", "utf8"); + await fsp.writeFile(helperPath, "export function helper() { return 1; }\n", "utf8"); + const languageExtensions = { ".tpl": "ts" }; + const expectedEdge = { from: "main.tpl", to: { type: "file", path: "helper.ts" }, raw: "./helper" }; try { await handleGraphDeltaCommand({ projectRootFs: tempDir, - files: [sourcePath], + files: [sourcePath, helperPath], getOpt: (name) => { if (name === "--output") return outputPath; return undefined; @@ -733,6 +736,7 @@ describe("CLI command modules", () => { nativeMode: "auto", workerOpts: {}, graphOptions: undefined, + languageExtensions, gitBase: undefined, gitHead: undefined, changedSince: undefined, @@ -742,7 +746,9 @@ describe("CLI command modules", () => { }); const report = readJsonRecord(JSON.parse(await fsp.readFile(outputPath, "utf8"))); - expect(report.changedFiles).toEqual(["main.ts"]); + expect(report.changedFiles).toEqual(["helper.ts", "main.tpl"]); + expect(report.added).toEqual([expectedEdge]); + expect(report.removed).toEqual([]); } finally { await fsp.rm(tempDir, { recursive: true, force: true }); } @@ -759,6 +765,7 @@ describe("CLI command modules", () => { nativeMode: "auto", workerOpts: {}, graphOptions: undefined, + languageExtensions: undefined, gitBase: undefined, gitHead: undefined, changedSince: undefined, diff --git a/tests/codegraph-config.test.ts b/tests/codegraph-config.test.ts index ab001e8d..bce42267 100644 --- a/tests/codegraph-config.test.ts +++ b/tests/codegraph-config.test.ts @@ -4,6 +4,9 @@ import path from "node:path"; import { describe, expect, it } from "vitest"; import { hasDiscoveryOptions, loadCodegraphConfig, mergeDiscoveryOptions } from "../src/config.js"; import { searchCodegraph } from "../src/agent/search.js"; +import { buildProjectIndex, type BuildReport } from "../src/indexer/build-index.js"; +import { diffBuildOptions, summarizeBuildOptions } from "../src/indexer/build-cache.js"; +import { normalizeLanguageExtensions, supportForFile } from "../src/languages.js"; async function mkRepo(): Promise { const root = await fs.mkdtemp(path.join(os.tmpdir(), "cg-config-")); @@ -14,6 +17,37 @@ async function mkRepo(): Promise { return root; } +async function writeConfig(root: string, config: unknown): Promise { + await fs.writeFile(path.join(root, "codegraph.config.json"), JSON.stringify(config), "utf8"); +} + +function normalizedFile(root: string, relativePath: string): string { + return path.join(root, relativePath).replace(/\\/g, "/"); +} + +async function buildWithProjectConfig(root: string) { + const config = await loadCodegraphConfig(root); + return await buildProjectIndex(root, { + cache: "disk", + ...(config.discovery ? { discovery: config.discovery } : {}), + ...(config.languages?.extensions ? { languageExtensions: config.languages.extensions } : {}), + }); +} + +function localExportNames( + index: Awaited>, + root: string, + relativePath: string, +): string[] { + const moduleIndex = index.byFile.get(normalizedFile(root, relativePath)); + return ( + moduleIndex?.exports + .filter((entry) => entry.type === "local") + .map((entry) => entry.exportedAs) + .sort() ?? [] + ); +} + describe("codegraph config", () => { it("loads discovery settings from codegraph.config.json", async () => { const root = await mkRepo(); @@ -119,4 +153,298 @@ describe("codegraph config", () => { expect(kept.results.map((result) => result.file)).toContain("src/kept.ts"); expect(ignored.results).toEqual([]); }); + + it("normalizes extension keys and language IDs from codegraph.config.json", async () => { + const root = await mkRepo(); + await writeConfig(root, { + languages: { + extensions: { + ".TPL": "PHP", + }, + }, + }); + + const config = await loadCodegraphConfig(root); + + expect(config.languages?.extensions).toEqual({ ".tpl": "php" }); + }); + + it("discovers uppercase custom suffixes from normalized config mappings without narrowing built-ins", async () => { + const root = await mkRepo(); + await fs.writeFile( + path.join(root, "src", "template.TPL"), + " { + const root = await mkRepo(); + await fs.writeFile( + path.join(root, "src", "view.inc.php"), + " { + const root = await mkRepo(); + await fs.writeFile(path.join(root, "src", "builtin.ts"), "export const builtinValue = 1;\n", "utf8"); + await fs.writeFile(path.join(root, "src", "remapped.php"), " { + const root = await mkRepo(); + await fs.writeFile( + path.join(root, "src", "cached.tpl"), + " { + const root = await mkRepo(); + await fs.writeFile( + path.join(root, "src", "cached.tpl"), + " { + expect(supportForFile("widget.tpl", { tpl: "html" })).toBeUndefined(); + expect(supportForFile("widget.tpl", { ".tpl": "html" })?.id).toBe("html"); + }); + + it("ignores non-dot-prefixed languageExtensions keys when comparing manifest build options", () => { + const dotOnly = summarizeBuildOptions({ + languageExtensions: { ".tpl": "html" }, + }); + const withNonDotKey = summarizeBuildOptions({ + languageExtensions: { ".tpl": "html", tpl: "html" }, + }); + + expect(withNonDotKey).toEqual(dotOnly); + expect(diffBuildOptions(dotOnly, { languageExtensions: { ".tpl": "html", tpl: "html" } })).not.toContain( + "languageExtensions", + ); + }); + + it("ignores glob metacharacter suffixes in programmatic support and cache options", () => { + const validMapping = { ".tpl": "html" }; + const withMetacharacter = { ...validMapping, ".foo[bar]": "php" }; + + expect(supportForFile("widget.foo[bar]", withMetacharacter)).toBeUndefined(); + expect(summarizeBuildOptions({ languageExtensions: withMetacharacter })).toEqual( + summarizeBuildOptions({ languageExtensions: validMapping }), + ); + }); + + it("keeps Vue and Svelte built-in support and cache options when programmatic remaps are attempted", () => { + const validMapping = { ".tpl": "html" }; + const withSfcRemaps = { ...validMapping, ".vue": "php", ".svelte": "php" }; + + expect(supportForFile("Component.vue", withSfcRemaps)?.id).toBe("vue"); + expect(supportForFile("Component.svelte", withSfcRemaps)?.id).toBe("svelte"); + expect(summarizeBuildOptions({ languageExtensions: withSfcRemaps })).toEqual( + summarizeBuildOptions({ languageExtensions: validMapping }), + ); + }); + + it("drops unknown language IDs while trimming and lowercasing programmatic extension mappings", () => { + const normalized = normalizeLanguageExtensions({ + ".TPL": " HTML ", + ".unknown": "wat", + ".PHP": " PHP ", + }); + + expect(Object.entries(normalized ?? {})).toEqual([ + [".php", "php"], + [".tpl", "html"], + ]); + }); + + it("uses an uppercase valid shorter mapping after ignoring an unknown language ID", () => { + const support = supportForFile("widget.component.tpl", { + ".component.tpl": "wat", + ".tpl": "HTML", + }); + + expect(support?.id).toBe("html"); + }); + + it("does not discover files solely because an unknown language ID maps their extension", async () => { + const root = await mkRepo(); + await fs.writeFile( + path.join(root, "src", "template.tpl"), + " { + const validOnly = summarizeBuildOptions({ + languageExtensions: { ".tpl": "html" }, + }); + const withUnknownLanguage = summarizeBuildOptions({ + languageExtensions: { ".tpl": "html", ".unknown": "wat" }, + }); + + expect(withUnknownLanguage).toEqual(validOnly); + expect( + diffBuildOptions( + { languageExtensions: { ".tpl": "html", ".unknown": "wat" } }, + { languageExtensions: { ".tpl": "html" } }, + ), + ).not.toContain("languageExtensions"); + }); + + it("rejects language extension keys that do not start with a dot", async () => { + const root = await mkRepo(); + await writeConfig(root, { + languages: { + extensions: { + tpl: "php", + }, + }, + }); + + await expect(loadCodegraphConfig(root)).rejects.toThrow('languages.extensions key "tpl" must start with "."'); + }); + + it("rejects glob metacharacters in configured extension suffixes", async () => { + const root = await mkRepo(); + await writeConfig(root, { + languages: { + extensions: { + ".foo[bar]": "php", + }, + }, + }); + + await expect(loadCodegraphConfig(root)).rejects.toThrow( + 'languages.extensions key ".foo[bar]" must be a literal suffix containing only letters, digits, ".", "_", "+", or "-"', + ); + }); + + it.each([".vue", ".svelte"])("rejects configured remapping of the built-in %s suffix", async (extension) => { + const root = await mkRepo(); + await writeConfig(root, { + languages: { + extensions: { + [extension]: "php", + }, + }, + }); + + await expect(loadCodegraphConfig(root)).rejects.toThrow( + `languages.extensions key "${extension}" cannot be remapped`, + ); + }); + + it("rejects language extension mappings to unknown languages", async () => { + const root = await mkRepo(); + await writeConfig(root, { + languages: { + extensions: { + ".tpl": "wat", + }, + }, + }); + + await expect(loadCodegraphConfig(root)).rejects.toThrow( + 'languages.extensions[".tpl"] references unknown language "wat"', + ); + }); }); diff --git a/tests/disk-cache-sqlite.test.ts b/tests/disk-cache-sqlite.test.ts index 0ec2f733..1f788dc6 100644 --- a/tests/disk-cache-sqlite.test.ts +++ b/tests/disk-cache-sqlite.test.ts @@ -101,6 +101,31 @@ describe("disk cache uses sqlite backend", () => { expect((report2.cache?.hits ?? 0) > 0).toBe(true); }); + it("reuses disk cache entries across builds when languageExtensions is configured", async () => { + const root = await mkTmpDir("dg-disk-cache-language-extensions-"); + await fsp.writeFile(path.join(root, "template.tpl"), " 0).toBe(true); + }); + it("migrates an older module cache sqlite schema", async () => { const root = await mkTmpDir("dg-disk-cache-old-module-"); await fsp.writeFile(path.join(root, "a.ts"), "export const a = 1;\n", "utf8"); diff --git a/tests/graph-delta.test.ts b/tests/graph-delta.test.ts index 9631121f..b49c7fd1 100644 --- a/tests/graph-delta.test.ts +++ b/tests/graph-delta.test.ts @@ -83,6 +83,46 @@ describe("Graph delta export", () => { expect(hasFileEdge(delta.removed, "removed.ts", "dep.ts", "./dep")).toBe(true); }); + it("reports exact edge changes when unchanged custom files gain or change mappings", async () => { + const root = await mkTmpDir("dg-graph-delta-language-extensions-"); + const removedSourcePath = path.join(root, "removed.tpl"); + const addedSourcePath = path.join(root, "added.view"); + await fsp.writeFile(removedSourcePath, `import './removed-dependency';\n`, "utf8"); + await fsp.writeFile(addedSourcePath, `import './added-dependency';\n`, "utf8"); + await fsp.writeFile(path.join(root, "removed-dependency.ts"), "export const removed = 1;\n", "utf8"); + await fsp.writeFile(path.join(root, "added-dependency.ts"), "export const added = 1;\n", "utf8"); + + await buildProjectIndex(root, { + cache: "disk", + languageExtensions: { ".tpl": "ts" }, + logLevel: "silent", + threads: 1, + }); + + const delta = await buildGraphDelta(root, { + cache: "disk", + languageExtensions: { ".tpl": "html", ".view": "ts" }, + logLevel: "silent", + threads: 1, + }); + + expect(delta.changedFiles).toEqual(["added.view", "removed.tpl"]); + expect(delta.added).toEqual([ + { + from: "added.view", + to: { type: "file", path: "added-dependency.ts" }, + raw: "./added-dependency", + }, + ]); + expect(delta.removed).toEqual([ + { + from: "removed.tpl", + to: { type: "file", path: "removed-dependency.ts" }, + raw: "./removed-dependency", + }, + ]); + }); + it("rejects changed files outside the project root", async () => { const root = await createTempProjectRoot("dg-graph-delta-root-", [ { path: "a.ts", contents: "export const a = 1;\n" }, diff --git a/tests/project-file-discovery.test.ts b/tests/project-file-discovery.test.ts index 7c41ed24..a4ed56f2 100644 --- a/tests/project-file-discovery.test.ts +++ b/tests/project-file-discovery.test.ts @@ -127,6 +127,19 @@ describe("project file discovery", () => { } }); + it.skipIf(os.platform() === "win32")("keeps ordinary ignore globs case-sensitive on POSIX", async () => { + const tempDir = await fs.mkdtemp(path.join(os.tmpdir(), "codegraph-project-ignore-case-")); + const keptFile = path.join(tempDir, "Tests", "kept.ts"); + await createFile(keptFile, "export const kept = 1;\n"); + + const discovered = await listProjectFiles(tempDir, ["**/*.ts"], { + ignoreGlobs: ["tests/**"], + useGitignore: false, + }); + + expect(discovered.map(normalize)).toContain(normalize(keptFile)); + }); + it("filters discovered files whose realpath escapes the project root", async () => { const tempDir = await fs.mkdtemp(path.join(os.tmpdir(), "codegraph-project-link-root-")); const outsideDir = await fs.mkdtemp(path.join(os.tmpdir(), "codegraph-project-link-outside-")); @@ -198,6 +211,31 @@ describe("project file discovery", () => { expect(discoveredSet.has(normalize(path.join(linkedPackage, "src", "index.ts")))).toBe(false); }); + it.skipIf(os.platform() === "win32")( + "keeps ordinary ignore globs case-sensitive while crawling safe symlink directories on POSIX", + async () => { + const tempDir = await fs.mkdtemp(path.join(os.tmpdir(), "codegraph-project-link-ignore-case-")); + const packageDir = path.join(tempDir, "packages", "core"); + const linkedPackage = path.join(tempDir, "Tests"); + const linkedFile = path.join(linkedPackage, "kept.ts"); + await createFile(path.join(packageDir, "kept.ts"), "export const kept = 1;\n"); + + try { + await fs.symlink(packageDir, linkedPackage, "junction"); + } catch (error) { + if (isSymlinkUnavailable(error)) return; + throw error; + } + + const discovered = await listProjectFiles(tempDir, ["**/*.ts"], { + ignoreGlobs: ["tests/**"], + useGitignore: false, + }); + + expect(discovered.map(normalize)).toContain(normalize(linkedFile)); + }, + ); + it("does not apply project-root ignore globs relative to safe symlink directory targets", async () => { const tempDir = await fs.mkdtemp(path.join(os.tmpdir(), "codegraph-project-link-root-ignore-")); const packageDir = path.join(tempDir, "packages", "core"); diff --git a/tests/session.test.ts b/tests/session.test.ts index 23777176..14433d5c 100644 --- a/tests/session.test.ts +++ b/tests/session.test.ts @@ -1,6 +1,6 @@ import { describe, test, expect, beforeAll, afterAll, afterEach, beforeEach, vi } from "vitest"; import type { ICodeReviewSession } from "../src/index.js"; -import type { BuildOptions, BuildReport } from "../src/indexer/types.js"; +import type { BuildOptions, BuildReport, LanguageExtensionMap } from "../src/indexer/types.js"; import { CodeReviewSession, SessionManager, createCodeReviewSession } from "../src/session.js"; import * as indexerBuild from "../src/indexer/build-index.js"; import path from "node:path"; @@ -108,6 +108,35 @@ describe("CodeReviewSession", () => { } }); + test("should apply codegraph.config.json language extension mappings when buildOptions.languageExtensions is explicitly empty", async () => { + const root = await fsp.mkdtemp(path.join(os.tmpdir(), "dg-session-lang-ext-empty-")); + await fsp.writeFile(path.join(root, "main.ts"), "export const value = 1;\n", "utf8"); + await fsp.writeFile( + path.join(root, "codegraph.config.json"), + JSON.stringify({ languages: { extensions: { ".tpl": "html" } } }), + "utf8", + ); + const originalBuild = indexerBuild.buildProjectIndexIncremental; + let requestedLanguageExtensions: LanguageExtensionMap | undefined; + const buildSpy = vi.spyOn(indexerBuild, "buildProjectIndexIncremental").mockImplementation(async (...args) => { + requestedLanguageExtensions = args[1]?.languageExtensions; + return await originalBuild(...args); + }); + + try { + const session = await createCodeReviewSession({ + root, + buildOptions: { cache: "memory", useBloomFilters: true, languageExtensions: {} }, + }); + + expect(requestedLanguageExtensions).toEqual({ ".tpl": "html" }); + session.dispose(); + } finally { + buildSpy.mockRestore(); + await fsp.rm(root, { recursive: true, force: true }); + } + }); + test("should provide session statistics", async () => { const session = readySession(); @@ -1367,6 +1396,34 @@ describe("SessionManager", () => { ).rejects.toThrow(/different configuration/); }); + test("should reuse a session when languageExtensions is empty instead of omitted", async () => { + const session1 = await manager.getOrCreateSession("shared", { + root: sampleRoot, + buildOptions: sampleBuildOptions(), + }); + + const session2 = await manager.getOrCreateSession("shared", { + root: sampleRoot, + buildOptions: sampleBuildOptions({ languageExtensions: {} }), + }); + + expect(session2).toBe(session1); + }); + + test("should reuse a session when languageExtensions has a redundant non-dot key", async () => { + const session1 = await manager.getOrCreateSession("shared", { + root: sampleRoot, + buildOptions: sampleBuildOptions({ languageExtensions: { ".tpl": "html" } }), + }); + + const session2 = await manager.getOrCreateSession("shared", { + root: sampleRoot, + buildOptions: sampleBuildOptions({ languageExtensions: { ".tpl": "html", tpl: "html" } }), + }); + + expect(session2).toBe(session1); + }); + test("should reject reusing a session id when graph options drift", async () => { await manager.getOrCreateSession("shared", { root: sampleRoot,