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
1 change: 1 addition & 0 deletions .gitignore
Original file line number Diff line number Diff line change
@@ -1,4 +1,5 @@
node_modules/
.pnpm-store/
dist/
.codebase-index/
.codebase-index.json
Expand Down
9 changes: 8 additions & 1 deletion src/cli-formatters.ts
Original file line number Diff line number Diff line change
Expand Up @@ -206,11 +206,11 @@
lines.push('');

const boxLines = drawBox('Team Patterns', lines, BOX_WIDTH);
console.log('');

Check warning on line 209 in src/cli-formatters.ts

View workflow job for this annotation

GitHub Actions / Quality Checks

Unexpected console statement. Only these console methods are allowed: warn, error
for (const l of boxLines) {
console.log(l);

Check warning on line 211 in src/cli-formatters.ts

View workflow job for this annotation

GitHub Actions / Quality Checks

Unexpected console statement. Only these console methods are allowed: warn, error
}
console.log('');

Check warning on line 213 in src/cli-formatters.ts

View workflow job for this annotation

GitHub Actions / Quality Checks

Unexpected console statement. Only these console methods are allowed: warn, error
}

export function formatSearch(
Expand Down Expand Up @@ -269,7 +269,14 @@
if (impact?.coverage) {
boxLines.push(`Callers: ${impact.coverage}`);
}
if (impact?.files && impact.files.length > 0) {
if (impact?.details && impact.details.length > 0) {
const shown = impact.details.slice(0, 3).map((d) => {
const p = shortPath(d.file, rootPath);
const suffix = d.line ? `:${d.line}` : '';
return `${p}${suffix} (hop ${d.hop})`;
});
boxLines.push(`Files: ${shown.join(', ')}`);
} else if (impact?.files && impact.files.length > 0) {
const shown = impact.files.slice(0, 3).map((f) => shortPath(f, rootPath));
boxLines.push(`Files: ${shown.join(', ')}`);
}
Expand All @@ -289,17 +296,17 @@
const boxTitle =
titleParts.length > 0 ? `Search: ${titleParts.join(' \u2500\u2500\u2500 ')}` : 'Search';

console.log('');

Check warning on line 299 in src/cli-formatters.ts

View workflow job for this annotation

GitHub Actions / Quality Checks

Unexpected console statement. Only these console methods are allowed: warn, error
if (boxLines.length > 0) {
const boxOut = drawBox(boxTitle, boxLines, BOX_WIDTH);
for (const l of boxOut) {
console.log(l);

Check warning on line 303 in src/cli-formatters.ts

View workflow job for this annotation

GitHub Actions / Quality Checks

Unexpected console statement. Only these console methods are allowed: warn, error
}
console.log('');

Check warning on line 305 in src/cli-formatters.ts

View workflow job for this annotation

GitHub Actions / Quality Checks

Unexpected console statement. Only these console methods are allowed: warn, error
} else if (quality) {
const status = quality.status === 'ok' ? 'ok' : 'low confidence';
console.log(` ${results?.length ?? 0} results · quality: ${status}`);

Check warning on line 308 in src/cli-formatters.ts

View workflow job for this annotation

GitHub Actions / Quality Checks

Unexpected console statement. Only these console methods are allowed: warn, error
console.log('');

Check warning on line 309 in src/cli-formatters.ts

View workflow job for this annotation

GitHub Actions / Quality Checks

Unexpected console statement. Only these console methods are allowed: warn, error
}

if (results && results.length > 0) {
Expand All @@ -315,8 +322,8 @@
if (typePart) metaParts.push(typePart);
if (trendPart) metaParts.push(trendPart);

console.log(`${i + 1}. ${file}`);

Check warning on line 325 in src/cli-formatters.ts

View workflow job for this annotation

GitHub Actions / Quality Checks

Unexpected console statement. Only these console methods are allowed: warn, error
console.log(` ${metaParts.join(' \u00b7 ')}`);

Check warning on line 326 in src/cli-formatters.ts

View workflow job for this annotation

GitHub Actions / Quality Checks

Unexpected console statement. Only these console methods are allowed: warn, error

const summary = r.summary ?? '';
if (summary) {
Expand Down
3 changes: 2 additions & 1 deletion src/core/indexer.ts
Original file line number Diff line number Diff line change
Expand Up @@ -515,7 +515,7 @@ export class CodebaseIndexer {
}
}

internalFileGraph.trackImport(file, resolvedPath, imp.imports);
internalFileGraph.trackImport(file, resolvedPath, imp.line || 1, imp.imports);
}
}

Expand Down Expand Up @@ -856,6 +856,7 @@ export class CodebaseIndexer {
generatedAt,
graph: {
imports: graphData.imports || {},
...(graphData.importDetails ? { importDetails: graphData.importDetails } : {}),
importedBy,
exports: graphData.exports || {}
},
Expand Down
133 changes: 120 additions & 13 deletions src/tools/search-codebase.ts
Original file line number Diff line number Diff line change
Expand Up @@ -27,6 +27,7 @@ import { RELATIONSHIPS_FILENAME } from '../constants/codebase-context.js';
interface RelationshipsData {
graph?: {
imports?: Record<string, string[]>;
importDetails?: Record<string, Record<string, { line?: number; importedSymbols?: string[] }>>;
};
stats?: unknown;
}
Expand Down Expand Up @@ -280,6 +281,35 @@ export async function handle(
return null;
}

type ImportEdgeDetail = { line?: number; importedSymbols?: string[] };
type ImportDetailsGraph = Record<string, Record<string, ImportEdgeDetail>>;

function getImportDetailsGraph(): ImportDetailsGraph | null {
if (relationships?.graph?.importDetails) {
return relationships.graph.importDetails as ImportDetailsGraph;
}
const internalDetails = intelligence?.internalFileGraph?.importDetails;
if (internalDetails) {
return internalDetails as ImportDetailsGraph;
}
return null;
}

function normalizeGraphPath(filePath: string): string {
const normalized = filePath.replace(/\\/g, '/');
if (path.isAbsolute(filePath)) {
const rel = path.relative(ctx.rootPath, filePath).replace(/\\/g, '/');
if (rel && !rel.startsWith('..')) {
return rel;
}
}
return normalized.replace(/^\.\//, '');
}

function pathsMatch(a: string, b: string): boolean {
return a === b || a.endsWith(b) || b.endsWith(a);
}

function computeIndexConfidence(): 'fresh' | 'aging' | 'stale' {
let confidence: 'fresh' | 'aging' | 'stale' = 'stale';
if (intelligence?.generatedAt) {
Expand All @@ -294,21 +324,92 @@ export async function handle(
return confidence;
}

// Cheap impact breadth estimate from the import graph (used for risk assessment).
function computeImpactCandidates(resultPaths: string[]): string[] {
const impactCandidates: string[] = [];
type ImpactCandidate = { file: string; line?: number; hop: 1 | 2 };

function findImportDetail(
details: ImportDetailsGraph | null,
importer: string,
imported: string
): ImportEdgeDetail | null {
if (!details) return null;
const edges = details[importer];
if (!edges) return null;
if (edges[imported]) return edges[imported];

let bestKey: string | null = null;
for (const depKey of Object.keys(edges)) {
if (!pathsMatch(depKey, imported)) continue;
if (!bestKey || depKey.length > bestKey.length) {
bestKey = depKey;
}
}

return bestKey ? edges[bestKey] : null;
}

// Impact breadth estimate from the import graph (used for risk assessment).
// 2-hop: direct importers (hop 1) + importers of importers (hop 2).
function computeImpactCandidates(resultPaths: string[]): ImpactCandidate[] {
const allImports = getImportsGraph();
if (!allImports) return impactCandidates;
for (const [file, deps] of Object.entries(allImports)) {
if (
deps.some((dep: string) => resultPaths.some((rp) => dep.endsWith(rp) || rp.endsWith(dep)))
) {
if (!resultPaths.some((rp) => file.endsWith(rp) || rp.endsWith(file))) {
impactCandidates.push(file);
if (!allImports) return [];

const importDetails = getImportDetailsGraph();

const reverseImportsLocal = new Map<string, string[]>();
for (const [file, deps] of Object.entries<string[]>(allImports)) {
for (const dep of deps) {
if (!reverseImportsLocal.has(dep)) reverseImportsLocal.set(dep, []);
reverseImportsLocal.get(dep)!.push(file);
}
}

const targets = resultPaths.map((rp) => normalizeGraphPath(rp));
const targetSet = new Set(targets);

const candidates = new Map<string, ImpactCandidate>();

const addCandidate = (file: string, hop: 1 | 2, line?: number): void => {
if (Array.from(targetSet).some((t) => pathsMatch(t, file))) return;

const existing = candidates.get(file);
if (existing) {
if (existing.hop <= hop) return;
}
candidates.set(file, { file, hop, ...(line ? { line } : {}) });
};

const collectImporters = (
target: string
): Array<{ importer: string; detail: ImportEdgeDetail | null }> => {
const matches: Array<{ importer: string; detail: ImportEdgeDetail | null }> = [];
for (const [dep, importers] of reverseImportsLocal) {
if (!pathsMatch(dep, target)) continue;
for (const importer of importers) {
matches.push({ importer, detail: findImportDetail(importDetails, importer, dep) });
}
}
return matches;
};

// Hop 1
const hop1Files: string[] = [];
for (const target of targets) {
for (const { importer, detail } of collectImporters(target)) {
addCandidate(importer, 1, detail?.line);
}
}
for (const candidate of candidates.values()) {
if (candidate.hop === 1) hop1Files.push(candidate.file);
}
return impactCandidates;

// Hop 2
for (const mid of hop1Files) {
for (const { importer, detail } of collectImporters(mid)) {
addCandidate(importer, 2, detail?.line);
}
}

return Array.from(candidates.values()).slice(0, 20);
}

// Build reverse import map from relationships sidecar (preferred) or intelligence graph
Expand Down Expand Up @@ -673,12 +774,18 @@ export async function handle(

// Add impact (coverage + top 3 files)
if (impactCoverage || impactCandidates.length > 0) {
const impactObj: { coverage?: string; files?: string[] } = {};
const impactObj: {
coverage?: string;
files?: string[];
details?: Array<{ file: string; line?: number; hop: 1 | 2 }>;
} = {};
if (impactCoverage) {
impactObj.coverage = `${impactCoverage.covered}/${impactCoverage.total} callers in results`;
}
if (impactCandidates.length > 0) {
impactObj.files = impactCandidates.slice(0, 3);
const top = impactCandidates.slice(0, 3);
impactObj.files = top.map((candidate) => candidate.file);
impactObj.details = top;
}
if (Object.keys(impactObj).length > 0) {
decisionCard.impact = impactObj;
Expand Down
1 change: 1 addition & 0 deletions src/tools/types.ts
Original file line number Diff line number Diff line change
Expand Up @@ -13,6 +13,7 @@ export interface DecisionCard {
impact?: {
coverage?: string;
files?: string[];
details?: Array<{ file: string; line?: number; hop: 1 | 2 }>;
};
whatWouldHelp?: string[];
}
Expand Down
1 change: 1 addition & 0 deletions src/types/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -647,6 +647,7 @@ export interface IntelligenceData {
/** Opaque — consumed via InternalFileGraph.fromJSON */
internalFileGraph?: {
imports?: Record<string, string[]>;
importDetails?: Record<string, Record<string, { line?: number; importedSymbols?: string[] }>>;
exports?: Record<string, unknown[]>;
stats?: unknown;
};
Expand Down
97 changes: 89 additions & 8 deletions src/utils/usage-tracker.ts
Original file line number Diff line number Diff line change
Expand Up @@ -622,13 +622,20 @@ export interface UnusedExport {
exports: string[];
}

export interface ImportEdgeDetail {
line?: number;
importedSymbols?: string[];
}

export class InternalFileGraph {
// Map: normalized file path -> Set of normalized file paths it imports
private imports: Map<string, Set<string>> = new Map();
// Map: normalized file path -> exports from that file
private exports: Map<string, FileExport[]> = new Map();
// Map: normalized file path -> Set of what symbols are imported from this file
private importedSymbols: Map<string, Set<string>> = new Map();
// Map: fromFile -> toFile -> edge details (line/symbols)
private importDetails: Map<string, Map<string, ImportEdgeDetail>> = new Map();
// Root path for relative path conversion
private rootPath: string;

Expand Down Expand Up @@ -663,7 +670,12 @@ export class InternalFileGraph {
* Track that importingFile imports importedFile
* Both should be absolute paths; they will be normalized internally.
*/
trackImport(importingFile: string, importedFile: string, importedSymbols?: string[]): void {
trackImport(
importingFile: string,
importedFile: string,
line?: number,
importedSymbols?: string[]
): void {
const fromFile = this.normalizePath(importingFile);
const toFile = this.normalizePath(importedFile);

Expand All @@ -674,15 +686,43 @@ export class InternalFileGraph {

this.imports.get(fromFile)!.add(toFile);

// Track which symbols are imported from the target file
if (importedSymbols && importedSymbols.length > 0) {
const normalizedLine =
typeof line === 'number' && Number.isFinite(line) && line > 0 ? Math.floor(line) : undefined;

const normalizedSymbols =
importedSymbols && importedSymbols.length > 0
? importedSymbols.filter((sym) => sym !== '*' && sym !== 'default')
: [];

if (normalizedLine || normalizedSymbols.length > 0) {
if (!this.importDetails.has(fromFile)) {
this.importDetails.set(fromFile, new Map());
}
const detailsForFrom = this.importDetails.get(fromFile)!;
const existing = detailsForFrom.get(toFile) ?? {};

const mergedLine =
existing.line && normalizedLine
? Math.min(existing.line, normalizedLine)
: (existing.line ?? normalizedLine);

const mergedSymbolsSet = new Set<string>(existing.importedSymbols ?? []);
for (const sym of normalizedSymbols) mergedSymbolsSet.add(sym);
const mergedSymbols = Array.from(mergedSymbolsSet);

detailsForFrom.set(toFile, {
...(mergedLine ? { line: mergedLine } : {}),
...(mergedSymbols.length > 0 ? { importedSymbols: mergedSymbols.sort() } : {})
});
}

// Track which symbols are imported from the target file (for unused export detection)
if (normalizedSymbols.length > 0) {
if (!this.importedSymbols.has(toFile)) {
this.importedSymbols.set(toFile, new Set());
}
for (const sym of importedSymbols) {
if (sym !== '*' && sym !== 'default') {
this.importedSymbols.get(toFile)!.add(sym);
}
for (const sym of normalizedSymbols) {
this.importedSymbols.get(toFile)!.add(sym);
}
}
}
Expand Down Expand Up @@ -824,6 +864,7 @@ export class InternalFileGraph {
toJSON(): {
imports: Record<string, string[]>;
exports: Record<string, FileExport[]>;
importDetails?: Record<string, Record<string, ImportEdgeDetail>>;
stats: { files: number; edges: number; avgDependencies: number };
} {
const imports: Record<string, string[]> = {};
Expand All @@ -836,7 +877,25 @@ export class InternalFileGraph {
exports[file] = exps;
}

return { imports, exports, stats: this.getStats() };
const importDetails: Record<string, Record<string, ImportEdgeDetail>> = {};
for (const [fromFile, edges] of this.importDetails.entries()) {
const nested: Record<string, ImportEdgeDetail> = {};
for (const [toFile, detail] of edges.entries()) {
if (!detail.line && (!detail.importedSymbols || detail.importedSymbols.length === 0))
continue;
nested[toFile] = detail;
}
if (Object.keys(nested).length > 0) {
importDetails[fromFile] = nested;
}
}

return {
imports,
exports,
...(Object.keys(importDetails).length > 0 ? { importDetails } : {}),
stats: this.getStats()
};
}

/**
Expand All @@ -846,6 +905,7 @@ export class InternalFileGraph {
data: {
imports?: Record<string, string[]>;
exports?: Record<string, FileExport[]>;
importDetails?: Record<string, Record<string, ImportEdgeDetail>>;
},
rootPath: string
): InternalFileGraph {
Expand All @@ -863,6 +923,27 @@ export class InternalFileGraph {
}
}

if (data.importDetails) {
for (const [fromFile, edges] of Object.entries(data.importDetails)) {
const edgeMap = new Map<string, ImportEdgeDetail>();
for (const [toFile, detail] of Object.entries(edges ?? {})) {
edgeMap.set(toFile, detail);

if (detail.importedSymbols && detail.importedSymbols.length > 0) {
if (!graph.importedSymbols.has(toFile)) {
graph.importedSymbols.set(toFile, new Set());
}
for (const sym of detail.importedSymbols) {
graph.importedSymbols.get(toFile)!.add(sym);
}
}
}
if (edgeMap.size > 0) {
graph.importDetails.set(fromFile, edgeMap);
}
}
}

return graph;
}
}