From 167036a1138a9ccb46ae37ab328c3e8ed8b73fed Mon Sep 17 00:00:00 2001 From: Zak Date: Thu, 7 May 2026 21:13:49 +0800 Subject: [PATCH 1/5] feat: track parser coverage and expose unsupported file stats (#1076) --- gitnexus/src/cli/analyze.ts | 7 ++++ .../ingestion/pipeline-phases/parse-impl.ts | 38 +++++++++++++++++++ .../core/ingestion/pipeline-phases/parse.ts | 3 ++ gitnexus/src/core/ingestion/pipeline.ts | 7 ++-- gitnexus/src/core/run-analyze.ts | 7 ++++ gitnexus/src/mcp/local/local-backend.ts | 7 ++++ gitnexus/src/mcp/resources.ts | 18 +++++++++ gitnexus/src/storage/repo-manager.ts | 6 +++ gitnexus/src/types/pipeline.ts | 20 ++++++++++ 9 files changed, 109 insertions(+), 4 deletions(-) diff --git a/gitnexus/src/cli/analyze.ts b/gitnexus/src/cli/analyze.ts index 2c199ae662..d357c45625 100644 --- a/gitnexus/src/cli/analyze.ts +++ b/gitnexus/src/cli/analyze.ts @@ -479,6 +479,13 @@ export const analyzeCommand = async (inputPath?: string, options?: AnalyzeOption console.log( ` ${(s.nodes ?? 0).toLocaleString()} nodes | ${(s.edges ?? 0).toLocaleString()} edges | ${s.communities ?? 0} clusters | ${s.processes ?? 0} flows`, ); + if (s.parserCoverage && s.parserCoverage.unsupportedFiles > 0) { + const pc = s.parserCoverage; + const topExts = pc.unsupportedByExtension.slice(0, 5).map((e) => `${e.extension}: ${e.count}`); + console.log( + ` Skipped ${pc.unsupportedFiles} files with unsupported extensions (${topExts.join(', ')}${pc.unsupportedByExtension.length > 5 ? ', ...' : ''})`, + ); + } console.log(` ${repoPath}`); try { diff --git a/gitnexus/src/core/ingestion/pipeline-phases/parse-impl.ts b/gitnexus/src/core/ingestion/pipeline-phases/parse-impl.ts index 025bdbeb7c..6dc6718853 100644 --- a/gitnexus/src/core/ingestion/pipeline-phases/parse-impl.ts +++ b/gitnexus/src/core/ingestion/pipeline-phases/parse-impl.ts @@ -118,6 +118,13 @@ export async function runChunkedParseAndResolve( * source. See plan * docs/plans/2026-04-20-002-perf-parse-heritage-mro-plan.md (Unit 4). */ scopeTreeCache: ASTCache; + /** Parser coverage — which files were parsed vs skipped */ + parserCoverage: { + totalFiles: number; + supportedFiles: number; + unsupportedFiles: number; + unsupportedByExtension: Array<{ extension: string; count: number }>; + }; }> { const ctx = createResolutionContext(); const symbolTable = ctx.model.symbols; @@ -127,6 +134,28 @@ export async function runChunkedParseAndResolve( return lang && isLanguageAvailable(lang); }); + // ── Parser coverage stats ────────────────────────────────────────── + const unsupportedExtCounts = new Map(); + for (const f of scannedFiles) { + const lang = getLanguageFromFilename(f.path); + if (!lang) { + const ext = path.extname(f.path).toLowerCase() || '(no extension)'; + unsupportedExtCounts.set(ext, (unsupportedExtCounts.get(ext) || 0) + 1); + } + } + const unsupportedByExtension = Array.from(unsupportedExtCounts.entries()) + .map(([extension, count]) => ({ extension, count })) + .sort((a, b) => b.count - a.count); + const unsupportedFiles = unsupportedByExtension.reduce((sum, e) => sum + e.count, 0); + const supportedFiles = parseableScanned.length; + + const parserCoverage = { + totalFiles: scannedFiles.length, + supportedFiles, + unsupportedFiles, + unsupportedByExtension, + }; + // Warn about files skipped due to unavailable parsers const skippedByLang = new Map(); for (const f of scannedFiles) { @@ -141,6 +170,14 @@ export async function runChunkedParseAndResolve( ); } + // Warn about files with unsupported extensions (no grammar at all) + if (unsupportedFiles > 0) { + const topExts = unsupportedByExtension.slice(0, 5).map((e) => `${e.extension}: ${e.count}`); + console.warn( + `Skipped ${unsupportedFiles} files with unsupported extensions (${topExts.join(', ')}${unsupportedByExtension.length > 5 ? ', ...' : ''})`, + ); + } + const totalParseable = parseableScanned.length; if (totalParseable === 0) { @@ -620,5 +657,6 @@ export async function runChunkedParseAndResolve( // chunk-local `astCache` above is intentionally NOT exposed // because parse-impl clears it between chunks. scopeTreeCache, + parserCoverage, }; } diff --git a/gitnexus/src/core/ingestion/pipeline-phases/parse.ts b/gitnexus/src/core/ingestion/pipeline-phases/parse.ts index a20d1e4b00..cab8442a4b 100644 --- a/gitnexus/src/core/ingestion/pipeline-phases/parse.ts +++ b/gitnexus/src/core/ingestion/pipeline-phases/parse.ts @@ -30,6 +30,7 @@ import type { import type { createResolutionContext } from '../model/resolution-context.js'; import { runChunkedParseAndResolve } from './parse-impl.js'; import type { ASTCache } from '../ast-cache.js'; +import type { ParserCoverage } from '../../../types/pipeline.js'; export interface ParseOutput { /** @@ -81,6 +82,8 @@ export interface ParseOutput { * `scopeTreeCache.clear()` after its extract loop finishes. */ readonly scopeTreeCache: ASTCache; + /** Parser coverage — which files were parsed vs skipped */ + readonly parserCoverage: ParserCoverage; } export const parsePhase: PipelinePhase = { diff --git a/gitnexus/src/core/ingestion/pipeline.ts b/gitnexus/src/core/ingestion/pipeline.ts index c220ea224e..53318efa50 100644 --- a/gitnexus/src/core/ingestion/pipeline.ts +++ b/gitnexus/src/core/ingestion/pipeline.ts @@ -37,6 +37,7 @@ import { type PipelinePhase, type CommunitiesOutput, type ProcessesOutput, + type ParseOutput, } from './pipeline-phases/index.js'; export interface PipelineOptions { @@ -112,10 +113,7 @@ export const runPipelineFromRepo = async ( }); // Extract final results for the PipelineResult contract - const { totalFiles, usedWorkerPool } = getPhaseOutput<{ - totalFiles: number; - usedWorkerPool: boolean; - }>(results, 'parse'); + const { totalFiles, usedWorkerPool, parserCoverage } = getPhaseOutput(results, 'parse'); let communityResult: CommunitiesOutput['communityResult'] | undefined; let processResult: ProcessesOutput['processResult'] | undefined; @@ -146,5 +144,6 @@ export const runPipelineFromRepo = async ( communityResult, processResult, usedWorkerPool, + parserCoverage, }; }; diff --git a/gitnexus/src/core/run-analyze.ts b/gitnexus/src/core/run-analyze.ts index 7227fd9459..25a57e5c92 100644 --- a/gitnexus/src/core/run-analyze.ts +++ b/gitnexus/src/core/run-analyze.ts @@ -108,6 +108,12 @@ export interface AnalyzeResult { communities?: number; processes?: number; embeddings?: number; + parserCoverage?: { + totalFiles: number; + supportedFiles: number; + unsupportedFiles: number; + unsupportedByExtension: Array<{ extension: string; count: number }>; + }; }; alreadyUpToDate?: boolean; /** The raw pipeline result — only populated when needed by callers (e.g. skill generation). */ @@ -472,6 +478,7 @@ export async function runFullAnalysis( communities: pipelineResult.communityResult?.stats.totalCommunities, processes: pipelineResult.processResult?.stats.totalProcesses, embeddings: embeddingCount, + parserCoverage: pipelineResult.parserCoverage, }, capabilities: { graph: { provider: 'ladybugdb', status: runtimeCapabilities.graph }, diff --git a/gitnexus/src/mcp/local/local-backend.ts b/gitnexus/src/mcp/local/local-backend.ts index 68df9feaac..dc12f0126e 100644 --- a/gitnexus/src/mcp/local/local-backend.ts +++ b/gitnexus/src/mcp/local/local-backend.ts @@ -197,6 +197,12 @@ export interface CodebaseContext { communityCount: number; processCount: number; }; + parserCoverage?: { + totalFiles: number; + supportedFiles: number; + unsupportedFiles: number; + unsupportedByExtension: Array<{ extension: string; count: number }>; + }; } interface RepoHandle { @@ -316,6 +322,7 @@ export class LocalBackend { communityCount: s.communities || 0, processCount: s.processes || 0, }, + parserCoverage: s.parserCoverage, }); } diff --git a/gitnexus/src/mcp/resources.ts b/gitnexus/src/mcp/resources.ts index 88e7a99cc9..9cb497653d 100644 --- a/gitnexus/src/mcp/resources.ts +++ b/gitnexus/src/mcp/resources.ts @@ -286,6 +286,9 @@ async function getReposResource(backend: LocalBackend): Promise { lines.push(` files: ${repo.stats.files || 0}`); lines.push(` symbols: ${repo.stats.nodes || 0}`); lines.push(` processes: ${repo.stats.processes || 0}`); + if (repo.stats.parserCoverage?.unsupportedFiles) { + lines.push(` unsupported_files: ${repo.stats.parserCoverage.unsupportedFiles}`); + } } } @@ -330,6 +333,21 @@ async function getContextResource(backend: LocalBackend, repoName?: string): Pro lines.push(` files: ${context.stats.fileCount}`); lines.push(` symbols: ${context.stats.functionCount}`); lines.push(` processes: ${context.stats.processCount}`); + + if (context.parserCoverage && context.parserCoverage.unsupportedFiles > 0) { + const pc = context.parserCoverage; + lines.push(''); + lines.push('parser_coverage:'); + lines.push(` total_files: ${pc.totalFiles}`); + lines.push(` supported: ${pc.supportedFiles}`); + lines.push(` unsupported: ${pc.unsupportedFiles}`); + lines.push(' unsupported_by_extension:'); + for (const ext of pc.unsupportedByExtension.slice(0, 10)) { + lines.push(` - extension: "${ext.extension}"`); + lines.push(` count: ${ext.count}`); + } + } + lines.push(''); lines.push('tools_available:'); lines.push(' - query: Process-grouped code intelligence (execution flows related to a concept)'); diff --git a/gitnexus/src/storage/repo-manager.ts b/gitnexus/src/storage/repo-manager.ts index 8c0bda95fb..8b98a6a548 100644 --- a/gitnexus/src/storage/repo-manager.ts +++ b/gitnexus/src/storage/repo-manager.ts @@ -70,6 +70,12 @@ export interface RepoMeta { communities?: number; processes?: number; embeddings?: number; + parserCoverage?: { + totalFiles: number; + supportedFiles: number; + unsupportedFiles: number; + unsupportedByExtension: Array<{ extension: string; count: number }>; + }; }; } diff --git a/gitnexus/src/types/pipeline.ts b/gitnexus/src/types/pipeline.ts index 331bdd6fcf..bd212f8363 100644 --- a/gitnexus/src/types/pipeline.ts +++ b/gitnexus/src/types/pipeline.ts @@ -2,6 +2,24 @@ import type { KnowledgeGraph } from '../core/graph/types.js'; import { CommunityDetectionResult } from '../core/ingestion/community-processor.js'; import { ProcessDetectionResult } from '../core/ingestion/process-processor.js'; +/** Per-extension breakdown of unsupported files */ +export interface UnsupportedExtension { + extension: string; + count: number; +} + +/** Parser coverage stats — tracks which files were parsed vs skipped */ +export interface ParserCoverage { + /** Total source files in repo (before language filtering) */ + totalFiles: number; + /** Files with supported extensions that entered the parse pipeline */ + supportedFiles: number; + /** Files with unsupported extensions (no grammar defined) */ + unsupportedFiles: number; + /** Per-extension breakdown of unsupported files, sorted by count desc */ + unsupportedByExtension: UnsupportedExtension[]; +} + // CLI-specific: in-memory result with graph + detection results export interface PipelineResult { graph: KnowledgeGraph; @@ -17,4 +35,6 @@ export interface PipelineResult { * so regression suites can prove which path executed. */ usedWorkerPool: boolean; + /** Parser coverage stats — which files were parsed vs skipped */ + parserCoverage?: ParserCoverage; } From 30b818234b7c91d88242e8c736899576e91840d5 Mon Sep 17 00:00:00 2001 From: Zak Date: Fri, 8 May 2026 14:14:14 +0800 Subject: [PATCH 2/5] feat(cli): add --json analyze output and parserCoverage regression test - Register --json; print stats (incl. parserCoverage) as JSON on success - Unit test: mixed .ts + unsupported extensions assert parserCoverage counts Co-authored-by: Cursor --- gitnexus/src/cli/analyze.ts | 25 ++++++++++------- gitnexus/src/cli/index.ts | 1 + .../test/unit/parse-impl-fallback.test.ts | 27 +++++++++++++++++++ 3 files changed, 44 insertions(+), 9 deletions(-) diff --git a/gitnexus/src/cli/analyze.ts b/gitnexus/src/cli/analyze.ts index 8542817b47..59884d2aad 100644 --- a/gitnexus/src/cli/analyze.ts +++ b/gitnexus/src/cli/analyze.ts @@ -146,6 +146,7 @@ export interface AnalyzeOptions { embeddingBatchSize?: string; embeddingSubBatchSize?: string; embeddingDevice?: string; + json?: boolean; } export const analyzeCommand = async (inputPath?: string, options?: AnalyzeOptions) => { @@ -501,18 +502,24 @@ export const analyzeCommand = async (inputPath?: string, options?: AnalyzeOption // ── Summary ──────────────────────────────────────────────────── const s = result.stats; - console.log(`\n Repository indexed successfully (${totalTime}s)\n`); - console.log( - ` ${(s.nodes ?? 0).toLocaleString()} nodes | ${(s.edges ?? 0).toLocaleString()} edges | ${s.communities ?? 0} clusters | ${s.processes ?? 0} flows`, - ); - if (s.parserCoverage && s.parserCoverage.unsupportedFiles > 0) { - const pc = s.parserCoverage; - const topExts = pc.unsupportedByExtension.slice(0, 5).map((e) => `${e.extension}: ${e.count}`); + if (options?.json) { + console.log(JSON.stringify({ repoPath, totalTime, ...s }, null, 2)); + } else { + console.log(`\n Repository indexed successfully (${totalTime}s)\n`); console.log( - ` Skipped ${pc.unsupportedFiles} files with unsupported extensions (${topExts.join(', ')}${pc.unsupportedByExtension.length > 5 ? ', ...' : ''})`, + ` ${(s.nodes ?? 0).toLocaleString()} nodes | ${(s.edges ?? 0).toLocaleString()} edges | ${s.communities ?? 0} clusters | ${s.processes ?? 0} flows`, ); + if (s.parserCoverage && s.parserCoverage.unsupportedFiles > 0) { + const pc = s.parserCoverage; + const topExts = pc.unsupportedByExtension + .slice(0, 5) + .map((e) => `${e.extension}: ${e.count}`); + console.log( + ` Skipped ${pc.unsupportedFiles} files with unsupported extensions (${topExts.join(', ')}${pc.unsupportedByExtension.length > 5 ? ', ...' : ''})`, + ); + } + console.log(` ${repoPath}`); } - console.log(` ${repoPath}`); try { await fs.access(getGlobalRegistryPath()); diff --git a/gitnexus/src/cli/index.ts b/gitnexus/src/cli/index.ts index b89b40db05..d18a3769fb 100644 --- a/gitnexus/src/cli/index.ts +++ b/gitnexus/src/cli/index.ts @@ -63,6 +63,7 @@ program .option('--embedding-batch-size ', 'Number of nodes per embedding batch') .option('--embedding-sub-batch-size ', 'Number of chunks per embedding model call') .option('--embedding-device ', 'Embedding device: auto, cpu, dml, cuda, or wasm') + .option('--json', 'Output analysis result as JSON (includes parserCoverage stats)') .addHelpText( 'after', '\nEnvironment variables:\n' + diff --git a/gitnexus/test/unit/parse-impl-fallback.test.ts b/gitnexus/test/unit/parse-impl-fallback.test.ts index 8690165525..97aed4c765 100644 --- a/gitnexus/test/unit/parse-impl-fallback.test.ts +++ b/gitnexus/test/unit/parse-impl-fallback.test.ts @@ -179,6 +179,33 @@ describe('parse-impl sequential fallback cleanup (U6)', () => { expect(spies.astCacheClearCalls).toBeGreaterThan(clearsBefore); }); + it('parserCoverage: unsupported extensions are counted and not zero', async () => { + const repoWithUnsupported = makeTempRepo({ + 'a.ts': `export function foo() {}\n`, + 'readme.md': `# hello\n`, + 'data.csv': `a,b,c\n`, + }); + try { + const graph = createKnowledgeGraph(); + const files = ['a.ts', 'readme.md', 'data.csv']; + const result = await runChunkedParseAndResolve( + graph, + scanned(repoWithUnsupported, files), + files, + files.length, + repoWithUnsupported, + Date.now(), + () => {}, + { skipWorkers: true }, + ); + expect(result.parserCoverage.unsupportedFiles).toBeGreaterThan(0); + expect(result.parserCoverage.unsupportedByExtension.length).toBeGreaterThan(0); + expect(result.parserCoverage.totalFiles).toBe(3); + } finally { + fs.rmSync(repoWithUnsupported, { recursive: true, force: true }); + } + }); + it('error path: processCalls throws in fallback loop — cleanup still runs', async () => { const graph = createKnowledgeGraph(); const files = ['a.ts', 'b.ts']; From ab9c64f06f5bb5b6b672e040d47547533852c607 Mon Sep 17 00:00:00 2001 From: Zak Date: Fri, 8 May 2026 19:14:30 +0800 Subject: [PATCH 3/5] style: format pipeline.ts for Prettier CI check Co-authored-by: Cursor --- gitnexus/src/core/ingestion/pipeline.ts | 5 ++++- 1 file changed, 4 insertions(+), 1 deletion(-) diff --git a/gitnexus/src/core/ingestion/pipeline.ts b/gitnexus/src/core/ingestion/pipeline.ts index 53318efa50..8e1165a451 100644 --- a/gitnexus/src/core/ingestion/pipeline.ts +++ b/gitnexus/src/core/ingestion/pipeline.ts @@ -113,7 +113,10 @@ export const runPipelineFromRepo = async ( }); // Extract final results for the PipelineResult contract - const { totalFiles, usedWorkerPool, parserCoverage } = getPhaseOutput(results, 'parse'); + const { totalFiles, usedWorkerPool, parserCoverage } = getPhaseOutput( + results, + 'parse', + ); let communityResult: CommunitiesOutput['communityResult'] | undefined; let processResult: ProcessesOutput['processResult'] | undefined; From 7af3a79f6fd882039dcee1ca4d0b142f1cf92631 Mon Sep 17 00:00:00 2001 From: Zak Date: Sun, 10 May 2026 23:40:45 +0800 Subject: [PATCH 4/5] fix(parser): use logger.warn for unsupported extension skip message Co-authored-by: Cursor --- gitnexus/src/core/ingestion/pipeline-phases/parse-impl.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/gitnexus/src/core/ingestion/pipeline-phases/parse-impl.ts b/gitnexus/src/core/ingestion/pipeline-phases/parse-impl.ts index 90fe6755b5..e3820cb82f 100644 --- a/gitnexus/src/core/ingestion/pipeline-phases/parse-impl.ts +++ b/gitnexus/src/core/ingestion/pipeline-phases/parse-impl.ts @@ -174,7 +174,7 @@ export async function runChunkedParseAndResolve( // Warn about files with unsupported extensions (no grammar at all) if (unsupportedFiles > 0) { const topExts = unsupportedByExtension.slice(0, 5).map((e) => `${e.extension}: ${e.count}`); - console.warn( + logger.warn( `Skipped ${unsupportedFiles} files with unsupported extensions (${topExts.join(', ')}${unsupportedByExtension.length > 5 ? ', ...' : ''})`, ); } From cfa0bb3fab0d4c36cb6411fd9179af765a202531 Mon Sep 17 00:00:00 2001 From: "github-actions[bot]" <41898282+github-actions[bot]@users.noreply.github.com> Date: Mon, 1 Jun 2026 04:32:26 +0000 Subject: [PATCH 5/5] chore(autofix): apply prettier + eslint fixes via /autofix command --- gitnexus-web/src/components/CodeReferencesPanel.tsx | 4 ++-- gitnexus-web/src/components/FileTreePanel.tsx | 4 ++-- gitnexus-web/src/components/ProcessesPanel.tsx | 2 +- gitnexus-web/src/components/QueryFAB.tsx | 2 +- gitnexus-web/src/components/RightPanel.tsx | 4 ++-- 5 files changed, 8 insertions(+), 8 deletions(-) diff --git a/gitnexus-web/src/components/CodeReferencesPanel.tsx b/gitnexus-web/src/components/CodeReferencesPanel.tsx index 07740f28c1..bd416f9fec 100644 --- a/gitnexus-web/src/components/CodeReferencesPanel.tsx +++ b/gitnexus-web/src/components/CodeReferencesPanel.tsx @@ -381,7 +381,7 @@ export const CodeReferencesPanel = ({ onFocusNode }: CodeReferencesPanelProps) = -
+
{isLoadingFile ? (
@@ -454,7 +454,7 @@ export const CodeReferencesPanel = ({ onFocusNode }: CodeReferencesPanelProps) = {t('graph:codePanel.references', { count: aiReferences.length })}
-
+
{refsWithSnippets.map( ({ ref, content, start, highlightStart, highlightEnd, totalLines }) => { const nodeColor = ref.label diff --git a/gitnexus-web/src/components/FileTreePanel.tsx b/gitnexus-web/src/components/FileTreePanel.tsx index 6d2a559aa2..a3c7487a99 100644 --- a/gitnexus-web/src/components/FileTreePanel.tsx +++ b/gitnexus-web/src/components/FileTreePanel.tsx @@ -389,7 +389,7 @@ export const FileTreePanel = ({ onFocusNode }: FileTreePanelProps) => {
{/* File tree */} -
+
{fileTree.length === 0 ? (
{t('graph:fileTree.noFilesLoaded')} @@ -413,7 +413,7 @@ export const FileTreePanel = ({ onFocusNode }: FileTreePanelProps) => { )} {activeTab === 'filters' && ( -
+

{t('graph:fileTree.nodeTypes')} diff --git a/gitnexus-web/src/components/ProcessesPanel.tsx b/gitnexus-web/src/components/ProcessesPanel.tsx index 6cefc7cb8f..5ff9c54d4b 100644 --- a/gitnexus-web/src/components/ProcessesPanel.tsx +++ b/gitnexus-web/src/components/ProcessesPanel.tsx @@ -364,7 +364,7 @@ export const ProcessesPanel = () => {

{/* Process list */} -
+
{/* View All Processes Card */}
{showResults && queryResult.rows.length > 0 && ( -
+
diff --git a/gitnexus-web/src/components/RightPanel.tsx b/gitnexus-web/src/components/RightPanel.tsx index 24f7a1a8bf..193d2c1e62 100644 --- a/gitnexus-web/src/components/RightPanel.tsx +++ b/gitnexus-web/src/components/RightPanel.tsx @@ -292,7 +292,7 @@ export const RightPanel = () => { )} {/* Messages */} -
+
{chatMessages.length === 0 ? (
@@ -417,7 +417,7 @@ export const RightPanel = () => { onKeyDown={handleKeyDown} placeholder={t('chat:input.placeholder')} rows={1} - className="min-h-[36px] flex-1 resize-none scrollbar-thin border-none bg-transparent text-sm text-text-primary outline-none placeholder:text-text-muted" + className="scrollbar-thin min-h-[36px] flex-1 resize-none border-none bg-transparent text-sm text-text-primary outline-none placeholder:text-text-muted" style={{ height: '36px', overflowY: 'hidden' }} />