From dc93b5fcfd11b505d833e0378976c2a7cc20c3e6 Mon Sep 17 00:00:00 2001 From: carlos-alm <127798846+carlos-alm@users.noreply.github.com> Date: Sat, 4 Apr 2026 06:10:33 -0600 Subject: [PATCH 1/5] refactor: split presentation formatters into sub-renderers --- src/presentation/communities.ts | 83 ++++++++++++---------- src/presentation/manifesto.ts | 73 +++++++++---------- src/presentation/queries-cli/inspect.ts | 94 +++++++++++++------------ 3 files changed, 127 insertions(+), 123 deletions(-) diff --git a/src/presentation/communities.ts b/src/presentation/communities.ts index eb8ecf07..6681f2ef 100644 --- a/src/presentation/communities.ts +++ b/src/presentation/communities.ts @@ -44,6 +44,48 @@ interface CommunitiesResult { drift: DriftAnalysis; } +function renderCommunityList(communityList: Community[]): void { + for (const c of communityList) { + const dirs = Object.entries(c.directories) + .sort((a, b) => b[1] - a[1]) + .map(([d, n]) => `${d} (${n})`) + .join(', '); + console.log(` Community ${c.id} (${c.size} members): ${dirs}`); + if (c.members) { + const shown = c.members.slice(0, 8); + for (const m of shown) { + const kind = m.kind ? ` [${m.kind}]` : ''; + console.log(` - ${m.name}${kind} ${m.file}`); + } + if (c.members.length > 8) { + console.log(` ... and ${c.members.length - 8} more`); + } + } + } +} + +function renderDriftAnalysis(d: DriftAnalysis, driftScore: number): void { + if (d.splitCandidates.length === 0 && d.mergeCandidates.length === 0) return; + + console.log(`\n# Drift Analysis (score: ${driftScore}%)\n`); + + if (d.splitCandidates.length > 0) { + console.log(' Split candidates (directories spanning multiple communities):'); + for (const s of d.splitCandidates.slice(0, 10)) { + console.log(` - ${s.directory} → ${s.communityCount} communities`); + } + } + + if (d.mergeCandidates.length > 0) { + console.log(' Merge candidates (communities spanning multiple directories):'); + for (const m of d.mergeCandidates.slice(0, 10)) { + console.log( + ` - Community ${m.communityId} (${m.size} members) → ${m.directoryCount} dirs: ${m.directories.join(', ')}`, + ); + } + } +} + export function communities(customDbPath: string | undefined, opts: CommunitiesCliOpts = {}): void { const data = communitiesData(customDbPath, opts) as unknown as CommunitiesResult; @@ -64,46 +106,9 @@ export function communities(customDbPath: string | undefined, opts: CommunitiesC ); if (!opts.drift) { - for (const c of data.communities) { - const dirs = Object.entries(c.directories) - .sort((a, b) => b[1] - a[1]) - .map(([d, n]) => `${d} (${n})`) - .join(', '); - console.log(` Community ${c.id} (${c.size} members): ${dirs}`); - if (c.members) { - const shown = c.members.slice(0, 8); - for (const m of shown) { - const kind = m.kind ? ` [${m.kind}]` : ''; - console.log(` - ${m.name}${kind} ${m.file}`); - } - if (c.members.length > 8) { - console.log(` ... and ${c.members.length - 8} more`); - } - } - } - } - - // Drift analysis - const d = data.drift; - if (d.splitCandidates.length > 0 || d.mergeCandidates.length > 0) { - console.log(`\n# Drift Analysis (score: ${data.summary.driftScore}%)\n`); - - if (d.splitCandidates.length > 0) { - console.log(' Split candidates (directories spanning multiple communities):'); - for (const s of d.splitCandidates.slice(0, 10)) { - console.log(` - ${s.directory} → ${s.communityCount} communities`); - } - } - - if (d.mergeCandidates.length > 0) { - console.log(' Merge candidates (communities spanning multiple directories):'); - for (const m of d.mergeCandidates.slice(0, 10)) { - console.log( - ` - Community ${m.communityId} (${m.size} members) → ${m.directoryCount} dirs: ${m.directories.join(', ')}`, - ); - } - } + renderCommunityList(data.communities); } + renderDriftAnalysis(data.drift, data.summary.driftScore); console.log(); } diff --git a/src/presentation/manifesto.ts b/src/presentation/manifesto.ts index 981c6f8a..521f09f0 100644 --- a/src/presentation/manifesto.ts +++ b/src/presentation/manifesto.ts @@ -22,17 +22,9 @@ interface ManifestoViolationRow { line?: number; } -export function manifesto(customDbPath: string | undefined, opts: ManifestoOpts = {}): void { - const data = manifestoData(customDbPath, opts as any) as any; - - if (outputResult(data, 'violations', opts)) { - if (!data.passed) process.exitCode = 1; - return; - } - +function renderRulesTable(data: any): void { console.log('\n# Manifesto Rules\n'); - // Rules table console.log( ` ${'Rule'.padEnd(20)} ${'Level'.padEnd(10)} ${'Status'.padEnd(8)} ${'Warn'.padStart(6)} ${'Fail'.padStart(6)} ${'Violations'.padStart(11)}`, ); @@ -49,44 +41,49 @@ export function manifesto(customDbPath: string | undefined, opts: ManifestoOpts ); } - // Summary const s = data.summary; console.log( `\n ${s.total} rules | ${s.passed} passed | ${s.warned} warned | ${s.failed} failed | ${s.violationCount} violations`, ); +} - // Violations detail - if (data.violations.length > 0) { - const failViolations = data.violations.filter((v: ManifestoViolationRow) => v.level === 'fail'); - const warnViolations = data.violations.filter((v: ManifestoViolationRow) => v.level === 'warn'); +function renderViolationList( + label: string, + violations: ManifestoViolationRow[], + maxShown = 20, +): void { + if (violations.length === 0) return; + console.log(`\n## ${label} (${violations.length})\n`); + for (const v of violations.slice(0, maxShown)) { + const loc = v.line ? `${v.file}:${v.line}` : v.file; + const tag = label === 'Failures' ? 'FAIL' : 'WARN'; + console.log( + ` [${tag}] ${v.rule}: ${v.name} (${v.value}) at ${loc} — threshold ${v.threshold}`, + ); + } + if (violations.length > maxShown) { + console.log(` ... and ${violations.length - maxShown} more`); + } +} + +function renderViolations(violations: ManifestoViolationRow[]): void { + if (violations.length === 0) return; + const failViolations = violations.filter((v) => v.level === 'fail'); + const warnViolations = violations.filter((v) => v.level === 'warn'); + renderViolationList('Failures', failViolations); + renderViolationList('Warnings', warnViolations); +} - if (failViolations.length > 0) { - console.log(`\n## Failures (${failViolations.length})\n`); - for (const v of failViolations.slice(0, 20)) { - const loc = v.line ? `${v.file}:${v.line}` : v.file; - console.log( - ` [FAIL] ${v.rule}: ${v.name} (${v.value}) at ${loc} — threshold ${v.threshold}`, - ); - } - if (failViolations.length > 20) { - console.log(` ... and ${failViolations.length - 20} more`); - } - } +export function manifesto(customDbPath: string | undefined, opts: ManifestoOpts = {}): void { + const data = manifestoData(customDbPath, opts as any) as any; - if (warnViolations.length > 0) { - console.log(`\n## Warnings (${warnViolations.length})\n`); - for (const v of warnViolations.slice(0, 20)) { - const loc = v.line ? `${v.file}:${v.line}` : v.file; - console.log( - ` [WARN] ${v.rule}: ${v.name} (${v.value}) at ${loc} — threshold ${v.threshold}`, - ); - } - if (warnViolations.length > 20) { - console.log(` ... and ${warnViolations.length - 20} more`); - } - } + if (outputResult(data, 'violations', opts)) { + if (!data.passed) process.exitCode = 1; + return; } + renderRulesTable(data); + renderViolations(data.violations); console.log(); if (!data.passed) { diff --git a/src/presentation/queries-cli/inspect.ts b/src/presentation/queries-cli/inspect.ts index 1b407134..e900289d 100644 --- a/src/presentation/queries-cli/inspect.ts +++ b/src/presentation/queries-cli/inspect.ts @@ -182,6 +182,39 @@ interface InterfacesData { results: InterfacesResult[]; } +function renderWhereSymbolResults(results: WhereSymbolResult[]): void { + for (const r of results) { + const roleTag = r.role ? ` [${r.role}]` : ''; + const tag = r.exported ? ' (exported)' : ''; + console.log(`\n${kindIcon(r.kind)} ${r.name}${roleTag} ${r.file}:${r.line}${tag}`); + if (r.uses.length > 0) { + const useStrs = r.uses.map((u) => `${u.file}:${u.line}`); + console.log(` Used in: ${useStrs.join(', ')}`); + } else { + console.log(' No uses found'); + } + } +} + +function renderWhereFileResults(results: WhereFileResult[]): void { + for (const r of results) { + console.log(`\n# ${r.file}`); + if (r.symbols.length > 0) { + const symStrs = r.symbols.map((s) => `${s.name}:${s.line}`); + console.log(` Symbols: ${symStrs.join(', ')}`); + } + if (r.imports.length > 0) { + console.log(` Imports: ${r.imports.join(', ')}`); + } + if (r.importedBy.length > 0) { + console.log(` Imported by: ${r.importedBy.join(', ')}`); + } + if (r.exported.length > 0) { + console.log(` Exported: ${r.exported.join(', ')}`); + } + } +} + export function where(target: string, customDbPath: string, opts: OutputOpts = {}): void { const data = whereData(target, customDbPath, opts as Record) as WhereData; if (outputResult(data as unknown as Record, 'results', opts)) return; @@ -196,34 +229,9 @@ export function where(target: string, customDbPath: string, opts: OutputOpts = { } if (data.mode === 'symbol') { - for (const r of data.results as WhereSymbolResult[]) { - const roleTag = r.role ? ` [${r.role}]` : ''; - const tag = r.exported ? ' (exported)' : ''; - console.log(`\n${kindIcon(r.kind)} ${r.name}${roleTag} ${r.file}:${r.line}${tag}`); - if (r.uses.length > 0) { - const useStrs = r.uses.map((u) => `${u.file}:${u.line}`); - console.log(` Used in: ${useStrs.join(', ')}`); - } else { - console.log(' No uses found'); - } - } + renderWhereSymbolResults(data.results as WhereSymbolResult[]); } else { - for (const r of data.results as WhereFileResult[]) { - console.log(`\n# ${r.file}`); - if (r.symbols.length > 0) { - const symStrs = r.symbols.map((s) => `${s.name}:${s.line}`); - console.log(` Symbols: ${symStrs.join(', ')}`); - } - if (r.imports.length > 0) { - console.log(` Imports: ${r.imports.join(', ')}`); - } - if (r.importedBy.length > 0) { - console.log(` Imported by: ${r.importedBy.join(', ')}`); - } - if (r.exported.length > 0) { - console.log(` Exported: ${r.exported.join(', ')}`); - } - } + renderWhereFileResults(data.results as WhereFileResult[]); } console.log(); } @@ -402,6 +410,17 @@ function renderContextResult(r: ContextResult): void { } } +function renderExplainSymbolList(label: string, symbols: ExplainSymbol[]): void { + if (symbols.length === 0) return; + console.log(`\n## ${label}`); + for (const s of symbols) { + const sig = s.signature?.params != null ? `(${s.signature.params})` : ''; + const roleTag = s.role ? ` [${s.role}]` : ''; + const summary = s.summary ? ` -- ${s.summary}` : ''; + console.log(` ${kindIcon(s.kind)} ${s.name}${sig}${roleTag} :${s.line}${summary}`); + } +} + function renderFileExplain(r: FileExplainResult): void { const publicCount = r.publicApi.length; const internalCount = r.internal.length; @@ -418,25 +437,8 @@ function renderFileExplain(r: FileExplainResult): void { console.log(` Imported by: ${r.importedBy.map((i) => i.file).join(', ')}`); } - if (r.publicApi.length > 0) { - console.log(`\n## Exported`); - for (const s of r.publicApi) { - const sig = s.signature?.params != null ? `(${s.signature.params})` : ''; - const roleTag = s.role ? ` [${s.role}]` : ''; - const summary = s.summary ? ` -- ${s.summary}` : ''; - console.log(` ${kindIcon(s.kind)} ${s.name}${sig}${roleTag} :${s.line}${summary}`); - } - } - - if (r.internal.length > 0) { - console.log(`\n## Internal`); - for (const s of r.internal) { - const sig = s.signature?.params != null ? `(${s.signature.params})` : ''; - const roleTag = s.role ? ` [${s.role}]` : ''; - const summary = s.summary ? ` -- ${s.summary}` : ''; - console.log(` ${kindIcon(s.kind)} ${s.name}${sig}${roleTag} :${s.line}${summary}`); - } - } + renderExplainSymbolList('Exported', r.publicApi); + renderExplainSymbolList('Internal', r.internal); if (r.dataFlow.length > 0) { console.log(`\n## Data Flow`); From 4647c51efa0b089f36a6abceee92addb7684ec93 Mon Sep 17 00:00:00 2001 From: carlos-alm <127798846+carlos-alm@users.noreply.github.com> Date: Sat, 4 Apr 2026 06:13:03 -0600 Subject: [PATCH 2/5] refactor: extract watcher debounce and journal logic --- src/domain/graph/watcher.ts | 200 ++++++++++++++++++++---------------- 1 file changed, 112 insertions(+), 88 deletions(-) diff --git a/src/domain/graph/watcher.ts b/src/domain/graph/watcher.ts index 0bc834b3..194f69c6 100644 --- a/src/domain/graph/watcher.ts +++ b/src/domain/graph/watcher.ts @@ -141,7 +141,8 @@ function collectTrackedFiles(dir: string, result: string[]): void { let entries: fs.Dirent[]; try { entries = fs.readdirSync(dir, { withFileTypes: true }); - } catch { + } catch (e: unknown) { + debug(`collectTrackedFiles: cannot read ${dir}: ${(e as Error).message}`); return; } for (const entry of entries) { @@ -155,10 +156,20 @@ function collectTrackedFiles(dir: string, result: string[]): void { } } -export async function watchProject( - rootDir: string, - opts: { engine?: string; poll?: boolean; pollInterval?: number } = {}, -): Promise { +/** Shared watcher state passed between setup and watcher sub-functions. */ +interface WatcherContext { + rootDir: string; + db: ReturnType; + stmts: IncrementalStmts; + engineOpts: import('../../types.js').EngineOpts; + cache: ReturnType; + pending: Set; + timer: ReturnType | null; + debounceMs: number; +} + +/** Initialize DB, engine, cache, and statements for watch mode. */ +function setupWatcher(rootDir: string, opts: { engine?: string }): WatcherContext { const dbPath = path.join(rootDir, '.codegraph', 'graph.db'); if (!fs.existsSync(dbPath)) { throw new DbError('No graph.db found. Run `codegraph build` first.', { file: dbPath }); @@ -183,111 +194,124 @@ export async function watchProject( const stmts = prepareWatcherStatements(db); - const pending = new Set(); - let timer: ReturnType | null = null; - const DEBOUNCE_MS = 300; - - const usePoll = opts.poll ?? process.platform === 'win32'; - const POLL_INTERVAL_MS = opts.pollInterval ?? 2000; + return { + rootDir, + db, + stmts, + engineOpts, + cache, + pending: new Set(), + timer: null, + debounceMs: 300, + }; +} - info(`Watching ${rootDir} for changes${usePoll ? ' (polling mode)' : ''}...`); - info('Press Ctrl+C to stop.'); +/** Schedule debounced processing of pending files. */ +function scheduleDebouncedProcess(ctx: WatcherContext): void { + if (ctx.timer) clearTimeout(ctx.timer); + ctx.timer = setTimeout(async () => { + const files = [...ctx.pending]; + ctx.pending.clear(); + await processPendingFiles(files, ctx.db, ctx.rootDir, ctx.stmts, ctx.engineOpts, ctx.cache); + }, ctx.debounceMs); +} - let cleanup: () => void; +/** Start polling-based file watcher. Returns cleanup function. */ +function startPollingWatcher(ctx: WatcherContext, pollIntervalMs: number): () => void { + const mtimeMap = new Map(); + + const initial: string[] = []; + collectTrackedFiles(ctx.rootDir, initial); + for (const f of initial) { + try { + mtimeMap.set(f, fs.statSync(f).mtimeMs); + } catch { + /* deleted between collect and stat */ + } + } + info(`Polling ${initial.length} tracked files every ${pollIntervalMs}ms`); - if (usePoll) { - // Polling mode: avoids native OS file watchers (NtNotifyChangeDirectoryFileEx) - // which can crash ReFS drivers on Windows Dev Drives. - const mtimeMap = new Map(); + const pollTimer = setInterval(() => { + const current: string[] = []; + collectTrackedFiles(ctx.rootDir, current); + const currentSet = new Set(current); - // Seed initial mtimes - const initial: string[] = []; - collectTrackedFiles(rootDir, initial); - for (const f of initial) { + for (const f of current) { try { - mtimeMap.set(f, fs.statSync(f).mtimeMs); + const mtime = fs.statSync(f).mtimeMs; + const prev = mtimeMap.get(f); + if (prev === undefined || mtime !== prev) { + mtimeMap.set(f, mtime); + ctx.pending.add(f); + } } catch { /* deleted between collect and stat */ } } - info(`Polling ${initial.length} tracked files every ${POLL_INTERVAL_MS}ms`); - - const pollTimer = setInterval(() => { - const current: string[] = []; - collectTrackedFiles(rootDir, current); - const currentSet = new Set(current); - - // Detect modified or new files - for (const f of current) { - try { - const mtime = fs.statSync(f).mtimeMs; - const prev = mtimeMap.get(f); - if (prev === undefined || mtime !== prev) { - mtimeMap.set(f, mtime); - pending.add(f); - } - } catch { - /* deleted between collect and stat */ - } - } - // Detect deleted files - for (const f of mtimeMap.keys()) { - if (!currentSet.has(f)) { - mtimeMap.delete(f); - pending.add(f); - } + for (const f of mtimeMap.keys()) { + if (!currentSet.has(f)) { + mtimeMap.delete(f); + ctx.pending.add(f); } + } - if (pending.size > 0) { - if (timer) clearTimeout(timer); - timer = setTimeout(async () => { - const files = [...pending]; - pending.clear(); - await processPendingFiles(files, db, rootDir, stmts, engineOpts, cache); - }, DEBOUNCE_MS); - } - }, POLL_INTERVAL_MS); - - cleanup = () => clearInterval(pollTimer); - } else { - // Native OS watcher — efficient but can trigger ReFS crashes on Windows Dev Drives. - // Use --poll if you experience BSOD/HYPERVISOR_ERROR on ReFS volumes. - const watcher = fs.watch(rootDir, { recursive: true }, (_eventType, filename) => { - if (!filename) return; - if (shouldIgnore(filename)) return; - if (!isTrackedExt(filename)) return; - - const fullPath = path.join(rootDir, filename); - pending.add(fullPath); - - if (timer) clearTimeout(timer); - timer = setTimeout(async () => { - const files = [...pending]; - pending.clear(); - await processPendingFiles(files, db, rootDir, stmts, engineOpts, cache); - }, DEBOUNCE_MS); - }); - - cleanup = () => watcher.close(); - } + if (ctx.pending.size > 0) { + scheduleDebouncedProcess(ctx); + } + }, pollIntervalMs); + + return () => clearInterval(pollTimer); +} + +/** Start native OS file watcher. Returns cleanup function. */ +function startNativeWatcher(ctx: WatcherContext): () => void { + const watcher = fs.watch(ctx.rootDir, { recursive: true }, (_eventType, filename) => { + if (!filename) return; + if (shouldIgnore(filename)) return; + if (!isTrackedExt(filename)) return; + ctx.pending.add(path.join(ctx.rootDir, filename)); + scheduleDebouncedProcess(ctx); + }); + + return () => watcher.close(); +} + +/** Register SIGINT handler to flush journal and clean up. */ +function setupShutdownHandler(ctx: WatcherContext, cleanup: () => void): void { process.on('SIGINT', () => { info('Stopping watcher...'); cleanup(); - // Flush any pending file paths to journal before exit - if (pending.size > 0) { - const entries = [...pending].map((filePath) => ({ - file: normalizePath(path.relative(rootDir, filePath)), + if (ctx.pending.size > 0) { + const entries = [...ctx.pending].map((filePath) => ({ + file: normalizePath(path.relative(ctx.rootDir, filePath)), })); try { - appendJournalEntries(rootDir, entries); + appendJournalEntries(ctx.rootDir, entries); } catch (e: unknown) { debug(`Journal flush on exit failed (non-fatal): ${(e as Error).message}`); } } - if (cache) cache.clear(); - closeDb(db); + if (ctx.cache) ctx.cache.clear(); + closeDb(ctx.db); process.exit(0); }); } + +export async function watchProject( + rootDir: string, + opts: { engine?: string; poll?: boolean; pollInterval?: number } = {}, +): Promise { + const ctx = setupWatcher(rootDir, opts); + + const usePoll = opts.poll ?? process.platform === 'win32'; + const pollIntervalMs = opts.pollInterval ?? 2000; + + info(`Watching ${rootDir} for changes${usePoll ? ' (polling mode)' : ''}...`); + info('Press Ctrl+C to stop.'); + + const cleanup = usePoll ? startPollingWatcher(ctx, pollIntervalMs) : startNativeWatcher(ctx); + + setupShutdownHandler(ctx, cleanup); +} From 0b1f1ef150c6b5640977b6ebda923d36fd27b1da Mon Sep 17 00:00:00 2001 From: carlos-alm <127798846+carlos-alm@users.noreply.github.com> Date: Sat, 4 Apr 2026 06:17:10 -0600 Subject: [PATCH 3/5] refactor: reduce complexity in TS extractors and file-utils --- src/extractors/go.ts | 89 ++++++++++------ src/extractors/javascript.ts | 72 ++++++++----- src/shared/file-utils.ts | 193 +++++++++++++++++++++-------------- 3 files changed, 218 insertions(+), 136 deletions(-) diff --git a/src/extractors/go.ts b/src/extractors/go.ts index 13124b26..6019b910 100644 --- a/src/extractors/go.ts +++ b/src/extractors/go.ts @@ -266,44 +266,69 @@ function handleTypedIdentifiers( } /** Infer type from a single RHS expression in a short var declaration. */ -function inferShortVarType( +/** x := Struct{...} — composite literal (confidence 1.0). */ +function inferCompositeLiteral( varNode: TreeSitterNode, rhs: TreeSitterNode, typeMap: Map, -): void { - // x := Struct{...} — composite literal (confidence 1.0) - if (rhs.type === 'composite_literal') { - const typeNode = rhs.childForFieldName('type'); - if (typeNode) { - const typeName = extractGoTypeName(typeNode); - if (typeName) setTypeMapEntry(typeMap, varNode.text, typeName, 1.0); - } - } - // x := &Struct{...} — address-of composite literal (confidence 1.0) - if (rhs.type === 'unary_expression') { - const operand = rhs.childForFieldName('operand'); - if (operand && operand.type === 'composite_literal') { - const typeNode = operand.childForFieldName('type'); - if (typeNode) { - const typeName = extractGoTypeName(typeNode); - if (typeName) setTypeMapEntry(typeMap, varNode.text, typeName, 1.0); - } - } - } - // x := NewFoo() or x := pkg.NewFoo() — factory function (confidence 0.7) - if (rhs.type === 'call_expression') { - const fn = rhs.childForFieldName('function'); - if (fn && fn.type === 'selector_expression') { - const field = fn.childForFieldName('field'); - if (field?.text.startsWith('New')) { - const typeName = field.text.slice(3); - if (typeName) setTypeMapEntry(typeMap, varNode.text, typeName, 0.7); - } - } else if (fn && fn.type === 'identifier' && fn.text.startsWith('New')) { - const typeName = fn.text.slice(3); +): boolean { + if (rhs.type !== 'composite_literal') return false; + const typeNode = rhs.childForFieldName('type'); + if (!typeNode) return false; + const typeName = extractGoTypeName(typeNode); + if (typeName) setTypeMapEntry(typeMap, varNode.text, typeName, 1.0); + return true; +} + +/** x := &Struct{...} — address-of composite literal (confidence 1.0). */ +function inferAddressOfComposite( + varNode: TreeSitterNode, + rhs: TreeSitterNode, + typeMap: Map, +): boolean { + if (rhs.type !== 'unary_expression') return false; + const operand = rhs.childForFieldName('operand'); + if (!operand || operand.type !== 'composite_literal') return false; + const typeNode = operand.childForFieldName('type'); + if (!typeNode) return false; + const typeName = extractGoTypeName(typeNode); + if (typeName) setTypeMapEntry(typeMap, varNode.text, typeName, 1.0); + return true; +} + +/** x := NewFoo() or x := pkg.NewFoo() — factory function (confidence 0.7). */ +function inferFactoryCall( + varNode: TreeSitterNode, + rhs: TreeSitterNode, + typeMap: Map, +): boolean { + if (rhs.type !== 'call_expression') return false; + const fn = rhs.childForFieldName('function'); + if (!fn) return false; + + if (fn.type === 'selector_expression') { + const field = fn.childForFieldName('field'); + if (field?.text.startsWith('New')) { + const typeName = field.text.slice(3); if (typeName) setTypeMapEntry(typeMap, varNode.text, typeName, 0.7); + return true; } + } else if (fn.type === 'identifier' && fn.text.startsWith('New')) { + const typeName = fn.text.slice(3); + if (typeName) setTypeMapEntry(typeMap, varNode.text, typeName, 0.7); + return true; } + return false; +} + +function inferShortVarType( + varNode: TreeSitterNode, + rhs: TreeSitterNode, + typeMap: Map, +): void { + if (inferCompositeLiteral(varNode, rhs, typeMap)) return; + if (inferAddressOfComposite(varNode, rhs, typeMap)) return; + inferFactoryCall(varNode, rhs, typeMap); } /** Handle short_var_declaration: x := Struct{}, x := &Struct{}, x := NewFoo(). */ diff --git a/src/extractors/javascript.ts b/src/extractors/javascript.ts index e699d085..9e62a678 100644 --- a/src/extractors/javascript.ts +++ b/src/extractors/javascript.ts @@ -202,6 +202,48 @@ function handleExportCapture( } } +function handleInterfaceCapture( + c: Record, + definitions: Definition[], +): void { + const ifaceNode = c.iface_node!; + const ifaceName = c.iface_name!.text; + definitions.push({ + name: ifaceName, + kind: 'interface', + line: ifaceNode.startPosition.row + 1, + endLine: nodeEndLine(ifaceNode), + }); + const body = + ifaceNode.childForFieldName('body') || + findChild(ifaceNode, 'interface_body') || + findChild(ifaceNode, 'object_type'); + if (body) extractInterfaceMethods(body, ifaceName, definitions); +} + +function handleTypeCapture(c: Record, definitions: Definition[]): void { + const typeNode = c.type_node!; + definitions.push({ + name: c.type_name!.text, + kind: 'type', + line: typeNode.startPosition.row + 1, + endLine: nodeEndLine(typeNode), + }); +} + +function handleImportCapture(c: Record, imports: Import[]): void { + const impNode = c.imp_node!; + const isTypeOnly = impNode.text.startsWith('import type'); + const modPath = c.imp_source!.text.replace(/['"]/g, ''); + const names = extractImportNames(impNode); + imports.push({ + source: modPath, + names, + line: impNode.startPosition.row + 1, + typeOnly: isTypeOnly, + }); +} + /** Dispatch a single query match to the appropriate handler. */ function dispatchQueryMatch( c: Record, @@ -220,35 +262,11 @@ function dispatchQueryMatch( } else if (c.meth_node) { handleMethodCapture(c, definitions); } else if (c.iface_node) { - const ifaceName = c.iface_name!.text; - definitions.push({ - name: ifaceName, - kind: 'interface', - line: c.iface_node.startPosition.row + 1, - endLine: nodeEndLine(c.iface_node), - }); - const body = - c.iface_node.childForFieldName('body') || - findChild(c.iface_node, 'interface_body') || - findChild(c.iface_node, 'object_type'); - if (body) extractInterfaceMethods(body, ifaceName, definitions); + handleInterfaceCapture(c, definitions); } else if (c.type_node) { - definitions.push({ - name: c.type_name!.text, - kind: 'type', - line: c.type_node.startPosition.row + 1, - endLine: nodeEndLine(c.type_node), - }); + handleTypeCapture(c, definitions); } else if (c.imp_node) { - const isTypeOnly = c.imp_node.text.startsWith('import type'); - const modPath = c.imp_source!.text.replace(/['"]/g, ''); - const names = extractImportNames(c.imp_node); - imports.push({ - source: modPath, - names, - line: c.imp_node.startPosition.row + 1, - typeOnly: isTypeOnly, - }); + handleImportCapture(c, imports); } else if (c.exp_node) { handleExportCapture(c, exps, imports); } else if (c.callfn_node) { diff --git a/src/shared/file-utils.ts b/src/shared/file-utils.ts index 6d8e5d68..6879e915 100644 --- a/src/shared/file-utils.ts +++ b/src/shared/file-utils.ts @@ -45,56 +45,97 @@ interface ExtractSummaryOpts { summaryMaxChars?: number; } -export function extractSummary( - fileLines: string[] | null, - line: number | undefined, - opts: ExtractSummaryOpts = {}, +/** Truncate text to maxChars, appending "..." if truncated. */ +function truncate(text: string, maxChars: number): string { + return text.length > maxChars ? `${text.slice(0, maxChars)}...` : text; +} + +/** Try to extract a single-line comment (// or #) above the definition. */ +function extractSingleLineComment( + fileLines: string[], + idx: number, + scanLines: number, + maxChars: number, ): string | null { - if (!fileLines || !line || line <= 1) return null; - const idx = line - 2; // line above the definition (0-indexed) - const jsdocEndScanLines = opts.jsdocEndScanLines ?? 10; - const jsdocOpenScanLines = opts.jsdocOpenScanLines ?? 20; - const summaryMaxChars = opts.summaryMaxChars ?? 100; - // Scan up for JSDoc or comment - let jsdocEnd = -1; - for (let i = idx; i >= Math.max(0, idx - jsdocEndScanLines); i--) { + for (let i = idx; i >= Math.max(0, idx - scanLines); i--) { const trimmed = fileLines[i]!.trim(); - if (trimmed.endsWith('*/')) { - jsdocEnd = i; - break; - } + if (trimmed.endsWith('*/')) return null; // hit a block comment — defer to JSDoc extractor if (trimmed.startsWith('//') || trimmed.startsWith('#')) { - // Single-line comment immediately above const text = trimmed .replace(/^\/\/\s*/, '') .replace(/^#\s*/, '') .trim(); - return text.length > summaryMaxChars ? `${text.slice(0, summaryMaxChars)}...` : text; + return truncate(text, maxChars); } - if (trimmed !== '' && !trimmed.startsWith('*') && !trimmed.startsWith('/*')) break; + if (trimmed !== '' && !trimmed.startsWith('*') && !trimmed.startsWith('/*')) return null; } - if (jsdocEnd >= 0) { - // Find opening /** - for (let i = jsdocEnd; i >= Math.max(0, jsdocEnd - jsdocOpenScanLines); i--) { - if (fileLines[i]!.trim().startsWith('/**')) { - // Extract first non-tag, non-empty line - for (let j = i + 1; j <= jsdocEnd; j++) { - const docLine = fileLines[j]!.trim() - .replace(/^\*\s?/, '') - .trim(); - if (docLine && !docLine.startsWith('@') && docLine !== '/' && docLine !== '*/') { - return docLine.length > summaryMaxChars - ? `${docLine.slice(0, summaryMaxChars)}...` - : docLine; - } - } - break; + return null; +} + +/** Find the line index where a block comment (*​/) ends, scanning upward from idx. */ +function findJsdocEndLine(fileLines: string[], idx: number, scanLines: number): number { + for (let i = idx; i >= Math.max(0, idx - scanLines); i--) { + const trimmed = fileLines[i]!.trim(); + if (trimmed.endsWith('*/')) return i; + if ( + trimmed !== '' && + !trimmed.startsWith('*') && + !trimmed.startsWith('/*') && + !trimmed.startsWith('//') && + !trimmed.startsWith('#') + ) { + break; + } + } + return -1; +} + +/** Extract the first description line from a JSDoc block ending at jsdocEnd. */ +function extractJsdocDescription( + fileLines: string[], + jsdocEnd: number, + openScanLines: number, + maxChars: number, +): string | null { + for (let i = jsdocEnd; i >= Math.max(0, jsdocEnd - openScanLines); i--) { + if (!fileLines[i]!.trim().startsWith('/**')) continue; + for (let j = i + 1; j <= jsdocEnd; j++) { + const docLine = fileLines[j]!.trim() + .replace(/^\*\s?/, '') + .trim(); + if (docLine && !docLine.startsWith('@') && docLine !== '/' && docLine !== '*/') { + return truncate(docLine, maxChars); } } + break; } return null; } +export function extractSummary( + fileLines: string[] | null, + line: number | undefined, + opts: ExtractSummaryOpts = {}, +): string | null { + if (!fileLines || !line || line <= 1) return null; + const idx = line - 2; // line above the definition (0-indexed) + const jsdocEndScanLines = opts.jsdocEndScanLines ?? 10; + const jsdocOpenScanLines = opts.jsdocOpenScanLines ?? 20; + const summaryMaxChars = opts.summaryMaxChars ?? 100; + + // Try single-line comment first + const singleLine = extractSingleLineComment(fileLines, idx, jsdocEndScanLines, summaryMaxChars); + if (singleLine) return singleLine; + + // Try JSDoc block comment + const jsdocEnd = findJsdocEndLine(fileLines, idx, jsdocEndScanLines); + if (jsdocEnd >= 0) { + return extractJsdocDescription(fileLines, jsdocEnd, jsdocOpenScanLines, summaryMaxChars); + } + + return null; +} + interface ExtractSignatureOpts { signatureGatherLines?: number; } @@ -104,6 +145,38 @@ export interface Signature { returnType: string | null; } +/** Per-language signature patterns. Each entry has a regex and an extractor for return type. */ +const SIGNATURE_PATTERNS: Array<{ + regex: RegExp; + returnType: (m: RegExpMatchArray) => string | null; +}> = [ + // JS/TS: function name(params) or async function + { + regex: /(?:export\s+)?(?:async\s+)?function\s*\*?\s*\w*\s*\(([^)]*)\)\s*(?::\s*([^\n{]+))?/, + returnType: (m) => (m[2] ? m[2].trim().replace(/\s*\{$/, '') : null), + }, + // Arrow: const name = (params) => or (params):ReturnType => + { + regex: /=\s*(?:async\s+)?\(([^)]*)\)\s*(?::\s*([^=>\n{]+))?\s*=>/, + returnType: (m) => (m[2] ? m[2].trim() : null), + }, + // Python: def name(params) -> return: + { + regex: /def\s+\w+\s*\(([^)]*)\)\s*(?:->\s*([^:\n]+))?/, + returnType: (m) => (m[2] ? m[2].trim() : null), + }, + // Go: func (recv) name(params) (returns) + { + regex: /func\s+(?:\([^)]*\)\s+)?\w+\s*\(([^)]*)\)\s*(?:\(([^)]+)\)|(\w[^\n{]*))?/, + returnType: (m) => (m[2] || m[3] || '').trim() || null, + }, + // Rust: fn name(params) -> ReturnType + { + regex: /fn\s+\w+\s*\(([^)]*)\)\s*(?:->\s*([^\n{]+))?/, + returnType: (m) => (m[2] ? m[2].trim() : null), + }, +]; + export function extractSignature( fileLines: string[] | null, line: number | undefined, @@ -112,52 +185,18 @@ export function extractSignature( if (!fileLines || !line) return null; const idx = line - 1; const signatureGatherLines = opts.signatureGatherLines ?? 5; - // Gather lines to handle multi-line params const chunk = fileLines .slice(idx, Math.min(fileLines.length, idx + signatureGatherLines)) .join('\n'); - // JS/TS: function name(params) or (params) => or async function - let m = chunk.match( - /(?:export\s+)?(?:async\s+)?function\s*\*?\s*\w*\s*\(([^)]*)\)\s*(?::\s*([^\n{]+))?/, - ); - if (m) { - return { - params: m[1]!.trim() || null, - returnType: m[2] ? m[2].trim().replace(/\s*\{$/, '') : null, - }; - } - // Arrow: const name = (params) => or (params):ReturnType => - m = chunk.match(/=\s*(?:async\s+)?\(([^)]*)\)\s*(?::\s*([^=>\n{]+))?\s*=>/); - if (m) { - return { - params: m[1]!.trim() || null, - returnType: m[2] ? m[2].trim() : null, - }; - } - // Python: def name(params) -> return: - m = chunk.match(/def\s+\w+\s*\(([^)]*)\)\s*(?:->\s*([^:\n]+))?/); - if (m) { - return { - params: m[1]!.trim() || null, - returnType: m[2] ? m[2].trim() : null, - }; - } - // Go: func (recv) name(params) (returns) - m = chunk.match(/func\s+(?:\([^)]*\)\s+)?\w+\s*\(([^)]*)\)\s*(?:\(([^)]+)\)|(\w[^\n{]*))?/); - if (m) { - return { - params: m[1]!.trim() || null, - returnType: (m[2] || m[3] || '').trim() || null, - }; - } - // Rust: fn name(params) -> ReturnType - m = chunk.match(/fn\s+\w+\s*\(([^)]*)\)\s*(?:->\s*([^\n{]+))?/); - if (m) { - return { - params: m[1]!.trim() || null, - returnType: m[2] ? m[2].trim() : null, - }; + for (const pattern of SIGNATURE_PATTERNS) { + const m = chunk.match(pattern.regex); + if (m) { + return { + params: m[1]!.trim() || null, + returnType: pattern.returnType(m), + }; + } } return null; } From a427fad2065561939180dc605675eaeb8ae3c46f Mon Sep 17 00:00:00 2001 From: carlos-alm <127798846+carlos-alm@users.noreply.github.com> Date: Sat, 4 Apr 2026 06:23:28 -0600 Subject: [PATCH 4/5] refactor: simplify AST store visitor and engine setup --- src/ast-analysis/engine.ts | 120 +++++++++++------- .../visitors/ast-store-visitor.ts | 40 +++--- 2 files changed, 90 insertions(+), 70 deletions(-) diff --git a/src/ast-analysis/engine.ts b/src/ast-analysis/engine.ts index 18e0e649..cc8c9d37 100644 --- a/src/ast-analysis/engine.ts +++ b/src/ast-analysis/engine.ts @@ -215,25 +215,37 @@ function runNativeAnalysis( } } +/** Index native results by line number and match to a definition by name. */ +function indexNativeByLine( + results: T[], +): Map { + const byLine = new Map(); + for (const r of results) { + if (!byLine.has(r.line)) byLine.set(r.line, []); + byLine.get(r.line)!.push(r); + } + return byLine; +} + +function matchNativeResult( + candidates: T[] | undefined, + defName: string, +): T | undefined { + if (!candidates) return undefined; + if (candidates.length === 1) return candidates[0]; + return candidates.find((r) => r.name === defName) ?? candidates[0]; +} + /** Store native complexity results on definitions, matched by line number. */ function storeNativeComplexityResults( results: NativeFunctionComplexityResult[], defs: Definition[], ): void { - const byLine = new Map(); - for (const r of results) { - if (!byLine.has(r.line)) byLine.set(r.line, []); - byLine.get(r.line)!.push(r); - } + const byLine = indexNativeByLine(results); for (const def of defs) { if ((def.kind === 'function' || def.kind === 'method') && def.line && !def.complexity) { - const candidates = byLine.get(def.line); - if (!candidates) continue; - const match = - candidates.length === 1 - ? candidates[0] - : (candidates.find((r) => r.name === def.name) ?? candidates[0]); + const match = matchNativeResult(byLine.get(def.line), def.name); if (!match) continue; const { complexity: c } = match; def.complexity = { @@ -284,11 +296,7 @@ function overrideCyclomaticFromCfg(def: Definition, cfgCyclomatic: number): void /** Store native CFG results on definitions, matched by line number. */ function storeNativeCfgResults(results: NativeFunctionCfgResult[], defs: Definition[]): void { - const byLine = new Map(); - for (const r of results) { - if (!byLine.has(r.line)) byLine.set(r.line, []); - byLine.get(r.line)!.push(r); - } + const byLine = indexNativeByLine(results); for (const def of defs) { if ( @@ -297,12 +305,7 @@ function storeNativeCfgResults(results: NativeFunctionCfgResult[], defs: Definit def.cfg !== null && !def.cfg?.blocks?.length ) { - const candidates = byLine.get(def.line); - if (!candidates) continue; - const match = - candidates.length === 1 - ? candidates[0] - : (candidates.find((r) => r.name === def.name) ?? candidates[0]); + const match = matchNativeResult(byLine.get(def.line), def.name); if (!match) continue; def.cfg = match.cfg; @@ -353,42 +356,61 @@ function reconcileCfgCyclomatic(fileSymbols: Map): void // ─── WASM pre-parse ───────────────────────────────────────────────────── +/** Check whether a single file needs a WASM tree for any enabled analysis pass. */ +function fileNeedsWasmTree( + relPath: string, + symbols: ExtractorOutput, + flags: { doAst: boolean; doComplexity: boolean; doCfg: boolean; doDataflow: boolean }, +): boolean { + if (symbols._tree) return false; + const ext = path.extname(relPath).toLowerCase(); + const defs = symbols.definitions || []; + const lid = symbols._langId || ''; + + if ( + flags.doAst && + !Array.isArray(symbols.astNodes) && + (WALK_EXTENSIONS.has(ext) || AST_TYPE_MAPS.has(lid)) + ) + return true; + if ( + flags.doComplexity && + (COMPLEXITY_EXTENSIONS.has(ext) || COMPLEXITY_RULES.has(lid)) && + defs.some((d) => hasFuncBody(d) && !d.complexity) + ) + return true; + if ( + flags.doCfg && + (CFG_EXTENSIONS.has(ext) || CFG_RULES.has(lid)) && + defs.some((d) => hasFuncBody(d) && d.cfg !== null && !Array.isArray(d.cfg?.blocks)) + ) + return true; + if ( + flags.doDataflow && + !symbols.dataflow && + (DATAFLOW_EXTENSIONS.has(ext) || DATAFLOW_RULES.has(lid)) + ) + return true; + return false; +} + async function ensureWasmTreesIfNeeded( fileSymbols: Map, opts: AnalysisOpts, rootDir: string, ): Promise { - const doAst = opts.ast !== false; - const doComplexity = opts.complexity !== false; - const doCfg = opts.cfg !== false; - const doDataflow = opts.dataflow !== false; + const flags = { + doAst: opts.ast !== false, + doComplexity: opts.complexity !== false, + doCfg: opts.cfg !== false, + doDataflow: opts.dataflow !== false, + }; - if (!doAst && !doComplexity && !doCfg && !doDataflow) return; + if (!flags.doAst && !flags.doComplexity && !flags.doCfg && !flags.doDataflow) return; let needsWasmTrees = false; for (const [relPath, symbols] of fileSymbols) { - if (symbols._tree) continue; - const ext = path.extname(relPath).toLowerCase(); - const defs = symbols.definitions || []; - - // AST: need tree when native didn't provide non-call astNodes - const lid = symbols._langId || ''; - const needsAst = - doAst && - !Array.isArray(symbols.astNodes) && - (WALK_EXTENSIONS.has(ext) || AST_TYPE_MAPS.has(lid)); - const needsComplexity = - doComplexity && - (COMPLEXITY_EXTENSIONS.has(ext) || COMPLEXITY_RULES.has(lid)) && - defs.some((d) => hasFuncBody(d) && !d.complexity); - const needsCfg = - doCfg && - (CFG_EXTENSIONS.has(ext) || CFG_RULES.has(lid)) && - defs.some((d) => hasFuncBody(d) && d.cfg !== null && !Array.isArray(d.cfg?.blocks)); - const needsDataflow = - doDataflow && !symbols.dataflow && (DATAFLOW_EXTENSIONS.has(ext) || DATAFLOW_RULES.has(lid)); - - if (needsAst || needsComplexity || needsCfg || needsDataflow) { + if (fileNeedsWasmTree(relPath, symbols, flags)) { needsWasmTrees = true; break; } diff --git a/src/ast-analysis/visitors/ast-store-visitor.ts b/src/ast-analysis/visitors/ast-store-visitor.ts index 84d770f4..c21dd306 100644 --- a/src/ast-analysis/visitors/ast-store-visitor.ts +++ b/src/ast-analysis/visitors/ast-store-visitor.ts @@ -102,27 +102,25 @@ export function createAstStoreVisitor( return nodeIdMap.get(`${parentDef.name}|${parentDef.kind}|${parentDef.line}`) || null; } - function resolveNameAndText( - node: TreeSitterNode, - kind: string, - ): { name: string | null | undefined; text: string | null; skip?: boolean } { - switch (kind) { - case 'new': - return { name: extractNewName(node), text: truncate(node.text) }; - case 'throw': - return { name: extractThrowName(node), text: extractExpressionText(node) }; - case 'await': - return { name: extractAwaitName(node), text: extractExpressionText(node) }; - case 'string': { - const content = node.text?.replace(/^['"`]|['"`]$/g, '') || ''; - if (content.length < 2) return { name: null, text: null, skip: true }; - return { name: truncate(content, 100), text: truncate(node.text) }; - } - case 'regex': - return { name: node.text || '?', text: truncate(node.text) }; - default: - return { name: undefined, text: null }; - } + type NameTextResult = { name: string | null | undefined; text: string | null; skip?: boolean }; + type KindHandler = (node: TreeSitterNode) => NameTextResult; + + const kindHandlers: Record = { + new: (node) => ({ name: extractNewName(node), text: truncate(node.text) }), + throw: (node) => ({ name: extractThrowName(node), text: extractExpressionText(node) }), + await: (node) => ({ name: extractAwaitName(node), text: extractExpressionText(node) }), + string: (node) => { + const content = node.text?.replace(/^['"`]|['"`]$/g, '') || ''; + if (content.length < 2) return { name: null, text: null, skip: true }; + return { name: truncate(content, 100), text: truncate(node.text) }; + }, + regex: (node) => ({ name: node.text || '?', text: truncate(node.text) }), + }; + const defaultResult: NameTextResult = { name: undefined, text: null }; + + function resolveNameAndText(node: TreeSitterNode, kind: string): NameTextResult { + const handler = kindHandlers[kind]; + return handler ? handler(node) : defaultResult; } function collectNode(node: TreeSitterNode, kind: string): void { From 03015d9cc695a7cb72d5a06f601b26612b36c593 Mon Sep 17 00:00:00 2001 From: carlos-alm <127798846+carlos-alm@users.noreply.github.com> Date: Sat, 4 Apr 2026 14:22:14 -0600 Subject: [PATCH 5/5] fix: address Greptile review feedback (#847) Use process.once instead of process.on for SIGINT handler in watcher to prevent listener accumulation on repeated calls. Replace nullish coalescing (??) with logical OR (||) in engine.ts native analysis check to better communicate "any method exists" intent. --- src/ast-analysis/engine.ts | 2 +- src/domain/graph/watcher.ts | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/src/ast-analysis/engine.ts b/src/ast-analysis/engine.ts index cc8c9d37..94878c53 100644 --- a/src/ast-analysis/engine.ts +++ b/src/ast-analysis/engine.ts @@ -690,7 +690,7 @@ export async function runAnalyses( // This fills in complexity/CFG/dataflow for files that the native parse pipeline // missed, avoiding the need to parse with WASM + run JS visitors. const native = loadNative(); - if (native?.analyzeComplexity ?? native?.buildCfgAnalysis ?? native?.extractDataflowAnalysis) { + if (native?.analyzeComplexity || native?.buildCfgAnalysis || native?.extractDataflowAnalysis) { const t0native = performance.now(); runNativeAnalysis(native, fileSymbols, rootDir, opts, extToLang); debug(`native standalone analysis: ${(performance.now() - t0native).toFixed(1)}ms`); diff --git a/src/domain/graph/watcher.ts b/src/domain/graph/watcher.ts index 194f69c6..61508a86 100644 --- a/src/domain/graph/watcher.ts +++ b/src/domain/graph/watcher.ts @@ -280,7 +280,7 @@ function startNativeWatcher(ctx: WatcherContext): () => void { /** Register SIGINT handler to flush journal and clean up. */ function setupShutdownHandler(ctx: WatcherContext, cleanup: () => void): void { - process.on('SIGINT', () => { + process.once('SIGINT', () => { info('Stopping watcher...'); cleanup(); if (ctx.pending.size > 0) {