Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
129 changes: 72 additions & 57 deletions docs/plans/2026-06-21-codegraph-technical-audit.md

Large diffs are not rendered by default.

7 changes: 6 additions & 1 deletion src/agent-tools.ts
Original file line number Diff line number Diff line change
Expand Up @@ -451,7 +451,12 @@ async function collectToolDependencyEntries(
...(options.depth !== undefined ? { depth: options.depth } : {}),
limit: limit + 1,
});
const limited = boundAgentList(entries, limit).items.map((entry) => ({
const sortedEntries = [...entries].sort((left, right) => {
const fileDelta = left.file.localeCompare(right.file);
if (fileDelta !== 0) return fileDelta;
return left.depth - right.depth;
});
const limited = boundAgentList(sortedEntries, limit).items.map((entry) => ({
file: normalizeToolFileOutput(root, entry.file),
depth: entry.depth,
}));
Expand Down
7 changes: 6 additions & 1 deletion src/agent/explain.ts
Original file line number Diff line number Diff line change
Expand Up @@ -192,6 +192,7 @@ type ResolvedExplainTarget =

const SQL_FACT_READ_CONCURRENCY = 32;
const AGENT_DUPLICATE_MAX_PAIRS = 20_000;
const AGENT_EXPLAIN_REFERENCE_COLLECTION_MULTIPLIER = 10;

export async function explainCodegraphTarget(request: AgentExplainTarget): Promise<AgentExplanation> {
const session = createAgentSession({
Expand Down Expand Up @@ -731,7 +732,11 @@ async function collectReferenceContext(
referenceLimit: number,
snippetLimit: number,
): Promise<ReferenceContext> {
const collectionLimit = Math.max(referenceLimit, snippetLimit) + 1;
const displayLimit = Math.max(referenceLimit, snippetLimit);
if (displayLimit <= 0) {
return emptyReferenceContext();
}
const collectionLimit = displayLimit * AGENT_EXPLAIN_REFERENCE_COLLECTION_MULTIPLIER;
const result = await findReferences(snapshot.index, { def }, { context: "line", maxReferences: collectionLimit });
if (result.status !== "ok") return emptyReferenceContext();

Expand Down
21 changes: 3 additions & 18 deletions src/agent/orient.ts
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,7 @@ import { getUnresolvedImports } from "../graphs/unresolved.js";
import type { BuildOptions } from "../indexer/types.js";
import type { Graph } from "../types.js";
import { isGitRepo } from "../util/git.js";
import { isPathUnderIncludeRoots, normalizeIncludeRootsRelative } from "../util/includeRoots.js";
import { normalizePath } from "../util/paths.js";
import { createAgentSession, type AgentSession } from "./session.js";
import { quoteShellArg } from "./shell.js";
Expand Down Expand Up @@ -127,9 +128,9 @@ export async function orientCodegraphWithSession(
const limits = ORIENT_BUDGETS[budget];
const snapshot = await session.loadProject({ symbolGraph: "skip" });
const root = snapshot.root;
const includeRoots = normalizeIncludeRoots(root, request.includeRoots ?? []);
const includeRoots = normalizeIncludeRootsRelative(root, request.includeRoots ?? []);
const projectFiles = snapshot.files.map((file) => normalizeRelativePath(root, file));
const scopedFiles = projectFiles.filter((file) => isUnderIncludeRoots(file, includeRoots));
const scopedFiles = projectFiles.filter((file) => isPathUnderIncludeRoots(file, includeRoots));
const scopedFileSet = new Set(scopedFiles);
const scopedAbsoluteFiles = snapshot.files.filter((file) => scopedFileSet.has(normalizeRelativePath(root, file)));
const scopedFileGraph = buildScopedGraph(snapshot.fileGraph, root, scopedFileSet);
Expand Down Expand Up @@ -258,27 +259,11 @@ function buildReviewFocus(base: string, head: string): AgentOrientationFocus {
};
}

function normalizeIncludeRoots(root: string, includeRoots: string[]): string[] {
return includeRoots
.map((includeRoot) => {
const relativeRoot = path.isAbsolute(includeRoot) ? path.relative(root, includeRoot) : includeRoot;
return normalizePath(relativeRoot)
.replace(/^\.?\//, "")
.replace(/\/$/, "");
})
.filter((includeRoot) => includeRoot && includeRoot !== ".");
}

function normalizeRelativePath(root: string, file: string): string {
const relative = path.isAbsolute(file) ? path.relative(root, file) : file;
return normalizePath(relative);
}

function isUnderIncludeRoots(file: string, includeRoots: string[]): boolean {
if (!includeRoots.length) return true;
return includeRoots.some((root) => file === root || file.startsWith(`${root}/`));
}

function buildScopedGraph(graph: Graph, root: string, scopedFiles: ReadonlySet<string>): Graph {
const nodes = new Set<string>();
for (const node of graph.nodes) {
Expand Down
5 changes: 2 additions & 3 deletions src/cli.ts
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,7 @@ import path from "node:path";
import fs from "node:fs";
import { fileURLToPath } from "node:url";
import { collectGraph } from "./graph-builder.js";
import { isPathUnderIncludeRoots } from "./util/includeRoots.js";
import type { BuildOptions } from "./indexer/types.js";
import { type GraphBuildOptions } from "./graphs/types.js";
import { type NativeRuntimeMode } from "./native/treeSitterNative.js";
Expand Down Expand Up @@ -353,9 +354,7 @@ async function runCliWithActiveRuntime(rawArgs: string[]) {
const includeRootsAbs = includeRoots.map((r) => normalizePath(resolveFilePathFromRoot(projectRootFs, r)));

const isUnderIncludeRoots = (filePath: string): boolean => {
if (!includeRootsAbs.length) return true;
const f = filePath.replace(/\\/g, "/");
return includeRootsAbs.some((root) => f === root || f.startsWith(`${root}/`));
return isPathUnderIncludeRoots(filePath.replace(/\\/g, "/"), includeRootsAbs);
};
const displayScanRoot = (scanRoot: string): string => {
const relative = normalizePath(path.relative(projectRootFs, scanRoot));
Expand Down
12 changes: 11 additions & 1 deletion src/cli/context.ts
Original file line number Diff line number Diff line change
Expand Up @@ -371,6 +371,14 @@ export type CommandReport = {
review?: ReviewBuildReport;
};

function isSupportedShortFlagToken(token: string): boolean {
return token === "-h" || token === "-v" || token === "-o";
}

function isCliOptionValueToken(token: string): boolean {
return !token.startsWith("--") && !isSupportedShortFlagToken(token);
}

export function parseCliArgs(command: string, tokens: string[]): ParsedCliArgs {
const positionals: string[] = [];
const flags = new Set<string>();
Expand Down Expand Up @@ -400,7 +408,9 @@ export function parseCliArgs(command: string, tokens: string[]): ParsedCliArgs {
const key = t;
if (isCliValueOption(command, key, positionals)) {
const next = tokens[i + 1];
if (next === undefined) throw new Error(`Missing value for ${key} option`);
if (next === undefined || !isCliOptionValueToken(next)) {
throw new Error(`Missing value for ${key} option`);
}
pushOpt(key, next);
i++;
} else {
Expand Down
5 changes: 3 additions & 2 deletions src/cli/graph.ts
Original file line number Diff line number Diff line change
Expand Up @@ -24,6 +24,7 @@ import {
parseCacheModeOption,
parseNonNegativeIntegerOption,
parseOptionalNonNegativeIntegerOption,
parseSymbolGraphScopeOption,
} from "./options.js";

type CompactEdgeTo = { type: "file"; path: number } | { type: "external"; name: string };
Expand Down Expand Up @@ -340,7 +341,7 @@ export async function handleGraphCommand(context: GraphCommandContext): Promise<
context.maybeWriteNativeBackendStatus(indexReport, context.showProgress);

const detailedSymbols = context.hasFlag("--symbols-detailed");
const scope = context.getOpt("--symbols-detailed-scope") as "all" | "imported" | undefined;
const scope = parseSymbolGraphScopeOption(context.getOpt("--symbols-detailed-scope"), "--symbols-detailed-scope");
const maxEdgesRaw = context.getOpt("--symbols-detailed-max-edges");
const maxEdges = parseOptionalNonNegativeIntegerOption(maxEdgesRaw, "--symbols-detailed-max-edges");
const membersOnly = context.hasFlag("--symbols-detailed-members-only");
Expand Down Expand Up @@ -392,7 +393,7 @@ export async function handleGraphCommand(context: GraphCommandContext): Promise<
context.maybeWriteNativeBackendStatus(indexReport, context.showProgress);
let sgraph: SymbolGraph;
if (detailedSymbols) {
const scope = context.getOpt("--symbols-detailed-scope") as "all" | "imported" | undefined;
const scope = parseSymbolGraphScopeOption(context.getOpt("--symbols-detailed-scope"), "--symbols-detailed-scope");
const maxEdgesRaw = context.getOpt("--symbols-detailed-max-edges");
const maxEdges = parseOptionalNonNegativeIntegerOption(maxEdgesRaw, "--symbols-detailed-max-edges");
const membersOnly = context.hasFlag("--symbols-detailed-members-only");
Expand Down
10 changes: 6 additions & 4 deletions src/cli/impact.ts
Original file line number Diff line number Diff line change
Expand Up @@ -24,6 +24,8 @@ import type { NativeRuntimeMode } from "../native/treeSitterNative.js";
import type { Graph } from "../types.js";
import { type ProjectFileDiscoveryOptions } from "../util/projectFiles.js";
import {
parseImpactScopeOption,
parseRefContextOption,
parseCacheModeOption,
parseOptionalNonNegativeIntegerOption,
parseOptionalPositiveIntegerOption,
Expand Down Expand Up @@ -344,11 +346,11 @@ function applyAnalysisOptions(context: ImpactCommandContext, options: ImpactOpti
const parsedDepth = parseOptionalNonNegativeIntegerOption(depth, "--depth");
if (parsedDepth !== undefined) options.depth = parsedDepth;

const scope = context.getOpt("--scope");
if (scope === "all" || scope === "imported") options.scope = scope;
const scope = parseImpactScopeOption(context.getOpt("--scope"), "--scope");
if (scope !== undefined) options.scope = scope;

const refContext = context.getOpt("--ref-context");
if (refContext) options.refContext = refContext as "line" | "block";
const refContext = parseRefContextOption(context.getOpt("--ref-context"), "--ref-context");
if (refContext !== undefined) options.refContext = refContext;

const refContextLines = context.getOpt("--ref-context-lines");
const parsedRefContextLines = parseOptionalNonNegativeIntegerOption(refContextLines, "--ref-context-lines");
Expand Down
31 changes: 4 additions & 27 deletions src/cli/inspect.ts
Original file line number Diff line number Diff line change
Expand Up @@ -14,6 +14,7 @@ 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 { toProjectDisplayPath } from "../util/paths.js";
import { type ProjectFileDiscoveryOptions } from "../util/projectFiles.js";
Expand Down Expand Up @@ -141,30 +142,6 @@ function formatIndexCacheMetadata(metadata: IndexCacheMetadata): string {
return `Index cache: manifest=${metadata.manifestPath} updatedAt=${updatedAt} lastCommit=${lastCommit}`;
}

function restrictGraphToIncludeRoots(graph: Graph, includeRoots: string[]): Graph {
if (!includeRoots.length) {
return graph;
}
const normalizedRoots = includeRoots.map(normalizePathForDisplay);
const nodes = new Set<string>();
for (const file of graph.nodes) {
const normalizedFile = normalizePathForDisplay(file);
if (normalizedRoots.some((root) => normalizedFile === root || normalizedFile.startsWith(`${root}/`))) {
nodes.add(normalizedFile);
}
}
const edges = graph.edges.filter((edge) => {
if (!nodes.has(normalizePathForDisplay(edge.from))) {
return false;
}
return edge.to.type === "external" || nodes.has(normalizePathForDisplay(edge.to.path));
});
return {
nodes,
edges,
};
}

async function buildScopedReportGraph(
projectRoot: string,
includeRoots: string[],
Expand Down Expand Up @@ -195,7 +172,7 @@ async function buildScopedReportGraph(
...(opts.report ? { report: opts.report } : {}),
});
return {
graph: restrictGraphToIncludeRoots(index.graph, includeRoots),
graph: restrictGraphToIncludeRoots(index.graph, includeRoots, normalizePathForDisplay),
indexCache,
};
}
Expand All @@ -205,7 +182,7 @@ async function buildScopedReportGraph(
...(opts.report ? { report: opts.report } : {}),
});
return {
graph: restrictGraphToIncludeRoots(sourceGraph, includeRoots),
graph: restrictGraphToIncludeRoots(sourceGraph, includeRoots, normalizePathForDisplay),
};
}

Expand Down Expand Up @@ -292,7 +269,7 @@ async function buildInspectReport(
...workerOpts,
...(graphOptions ? { graph: graphOptions } : {}),
});
const graph = restrictGraphToIncludeRoots(index.graph, includeRoots);
const graph = restrictGraphToIncludeRoots(index.graph, includeRoots, normalizePathForDisplay);
const hotspots = getHotspots(graph, { limit });
const unresolved = getUnresolvedImports(graph, { projectRoot });
const cycles = findDetailedCycles(graph);
Expand Down
33 changes: 32 additions & 1 deletion src/cli/options.ts
Original file line number Diff line number Diff line change
Expand Up @@ -528,14 +528,20 @@ export function parseCacheModeOption(rawValue: string | undefined): CacheModeOpt
throw new Error(`Invalid --cache value "${rawValue}". Expected one of: off, memory, disk.`);
}

const STRICT_INTEGER_PATTERN = /^-?\d+$/;

function parseIntegerOptionValue(
rawValue: string,
optionName: string,
expectedDescription: string,
minValue: number,
maxValue?: number,
): number {
const parsedValue = Number(rawValue);
const trimmed = rawValue.trim();
if (!STRICT_INTEGER_PATTERN.test(trimmed)) {
throw new Error(`Invalid ${optionName} value "${rawValue}". Expected ${expectedDescription}.`);
}
const parsedValue = Number(trimmed);
const isAboveMinimum = parsedValue >= minValue;
const isBelowMaximum = maxValue === undefined || parsedValue <= maxValue;
if (!Number.isInteger(parsedValue) || !isAboveMinimum || !isBelowMaximum) {
Expand All @@ -544,6 +550,31 @@ function parseIntegerOptionValue(
return parsedValue;
}

export type SymbolGraphScopeOption = "all" | "imported";
export type RefContextOption = "line" | "block";

export function parseSymbolGraphScopeOption(
rawValue: string | undefined,
optionName: string,
): SymbolGraphScopeOption | undefined {
if (rawValue === undefined) return undefined;
if (rawValue === "all" || rawValue === "imported") return rawValue;
throw new Error(`Invalid ${optionName} value "${rawValue}". Expected one of: all, imported.`);
}

export function parseRefContextOption(rawValue: string | undefined, optionName: string): RefContextOption | undefined {
if (rawValue === undefined) return undefined;
if (rawValue === "line" || rawValue === "block") return rawValue;
throw new Error(`Invalid ${optionName} value "${rawValue}". Expected one of: line, block.`);
}

export function parseImpactScopeOption(
rawValue: string | undefined,
optionName: string,
): SymbolGraphScopeOption | undefined {
return parseSymbolGraphScopeOption(rawValue, optionName);
}

function parseDefaultedIntegerOption(
rawValue: string | undefined,
optionName: string,
Expand Down
17 changes: 4 additions & 13 deletions src/drift/snapshot.ts
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,7 @@ import { buildProjectIndex, buildProjectIndexFromFiles } from "../indexer/build-
import { getApiSurface } from "../indexer/symbols.js";
import type { Edge } from "../types.js";
import { DEFAULT_PROJECT_PATTERNS, listProjectFiles } from "../util/projectFiles.js";
import { isPathUnderIncludeRoots, normalizeIncludeRootsAbsolute } from "../util/includeRoots.js";
import { normalizePath, resolveFilePathFromRoot, toProjectDisplayPath } from "../util/paths.js";
import { countFilesByLanguage } from "./languages.js";
import type {
Expand All @@ -25,21 +26,11 @@ function normalizeRoot(root: string): string {
return normalizePath(path.resolve(root));
}

function normalizeIncludeRoot(root: string, includeRoot: string): string {
return normalizePath(resolveFilePathFromRoot(root, includeRoot));
}

function isUnderIncludeRoots(filePath: string, roots: readonly string[]): boolean {
if (!roots.length) return true;
const normalizedFile = normalizePath(filePath);
return roots.some((root) => normalizedFile === root || normalizedFile.startsWith(`${root}/`));
}

async function listFilesForSnapshot(root: string, options: ArchitectureSnapshotOptions): Promise<string[] | undefined> {
if (!options.includeRoots?.length) return undefined;
const roots = options.includeRoots.map((entry) => normalizeIncludeRoot(root, entry));
const roots = normalizeIncludeRootsAbsolute(root, options.includeRoots);
const files = await listProjectFiles(root, DEFAULT_PROJECT_PATTERNS, options.discovery);
return files.filter((file) => isUnderIncludeRoots(file, roots)).sort();
return files.filter((file) => isPathUnderIncludeRoots(normalizePath(file), roots)).sort();
}

function cycleKey(files: readonly string[]): string {
Expand Down Expand Up @@ -169,7 +160,7 @@ export async function buildArchitectureSnapshot(
const index = files
? await buildProjectIndexFromFiles(root, files, indexOptions)
: await buildProjectIndex(root, indexOptions);
const includeRoots = options.includeRoots?.map((entry) => normalizeIncludeRoot(root, entry)) ?? [];
const includeRoots = options.includeRoots ? normalizeIncludeRootsAbsolute(root, options.includeRoots) : [];
const indexedFiles = [...index.byFile.keys()].sort();

return {
Expand Down
10 changes: 9 additions & 1 deletion src/graphs/adjacency.ts
Original file line number Diff line number Diff line change
Expand Up @@ -25,8 +25,16 @@ export function buildGraphAdjacency(graph: Graph): GraphAdjacencyIndex {
return { forward, reverse };
}

const adjacencyByGraph = new WeakMap<Graph, GraphAdjacencyIndex>();

export function graphAdjacencyFor(graph: Graph): GraphAdjacencyIndex {
return buildGraphAdjacency(graph);
const cached = adjacencyByGraph.get(graph);
if (cached) {
return cached;
}
const built = buildGraphAdjacency(graph);
adjacencyByGraph.set(graph, built);
return built;
}

export function getForwardNeighbors(adjacency: GraphAdjacencyIndex, file: FileId): readonly FileId[] {
Expand Down
Loading