diff --git a/gitnexus/src/cli/analyze.ts b/gitnexus/src/cli/analyze.ts index 37a0ca5b3..dbbb61a57 100644 --- a/gitnexus/src/cli/analyze.ts +++ b/gitnexus/src/cli/analyze.ts @@ -21,6 +21,7 @@ import { import { getGitRoot, hasGitDir } from '../storage/git.js'; import { runFullAnalysis } from '../core/run-analyze.js'; import { getMaxFileSizeBannerMessage } from '../core/ingestion/utils/max-file-size.js'; +import { formatCoverageGapWarning } from '../core/coverage-gaps.js'; import fs from 'fs/promises'; const HEAP_MB = 8192; @@ -333,6 +334,17 @@ export const analyzeCommand = async (inputPath?: string, options?: AnalyzeOption ); console.log(` ${repoPath}`); + // Coverage-gap warning — surface silent indexing gaps where a meaningful + // number of source files belong to a language with no LanguageProvider + // (e.g., a Clojure-majority repo where `analyze` "succeeded" but extracted + // zero symbols from .cljs/.clj/.cljc). Reporting-only — does not fail the + // run. + const gapWarning = formatCoverageGapWarning(result.pipelineResult?.coverageGaps ?? []); + if (gapWarning) { + console.log(''); + console.log(gapWarning); + } + try { await fs.access(getGlobalRegistryPath()); } catch { diff --git a/gitnexus/src/core/coverage-gaps.ts b/gitnexus/src/core/coverage-gaps.ts new file mode 100644 index 000000000..488120393 --- /dev/null +++ b/gitnexus/src/core/coverage-gaps.ts @@ -0,0 +1,141 @@ +/** + * Coverage-gap detection. + * + * Surfaces silent indexing gaps where the repo contains a meaningful number of + * source files in a language GitNexus does not yet support. Without this, + * `analyze` "succeeds" on, e.g., a Clojure-majority monorepo while extracting + * zero symbols from `.clj/.cljs/.cljc` — a precise but dangerously partial + * graph that callers may trust without knowing the limitation. + * + * The check is reporting-only: it never alters pipeline behavior or exit + * status. The CLI prints any returned gaps in the analyze summary. + */ + +import { getProviderForFile } from './ingestion/languages/index.js'; + +/** + * Extensions for languages that are clearly source code but currently have no + * GitNexus LanguageProvider. New providers SHOULD remove their extensions from + * this map when added so the warning self-deactivates. + * + * Curated list — broad enough to catch real coverage cliffs, narrow enough to + * avoid yelling at every config or asset extension. Markup, configuration, + * and stylesheet formats are intentionally omitted. + */ +const UNSUPPORTED_SOURCE_LANGUAGES: ReadonlyMap = new Map([ + // Clojure family + ['.clj', 'Clojure'], + ['.cljs', 'ClojureScript'], + ['.cljc', 'Clojure (cross-platform)'], + ['.edn', 'Clojure (EDN)'], + // JVM + ['.scala', 'Scala'], + ['.sc', 'Scala'], + ['.groovy', 'Groovy'], + // BEAM + ['.ex', 'Elixir'], + ['.exs', 'Elixir'], + ['.erl', 'Erlang'], + ['.hrl', 'Erlang'], + // ML family + ['.ml', 'OCaml'], + ['.mli', 'OCaml'], + ['.fs', 'F#'], + ['.fsi', 'F#'], + ['.fsx', 'F#'], + ['.hs', 'Haskell'], + ['.lhs', 'Haskell'], + ['.elm', 'Elm'], + // Scripting / scientific + ['.lua', 'Lua'], + ['.r', 'R'], + ['.jl', 'Julia'], + ['.pl', 'Perl'], + ['.pm', 'Perl'], + // Systems + ['.nim', 'Nim'], + ['.zig', 'Zig'], + ['.v', 'V'], + ['.cr', 'Crystal'], + // Shell-ish (only the genuinely-source ones) + ['.sh', 'Shell'], + ['.bash', 'Bash'], + ['.zsh', 'Zsh'], + ['.ps1', 'PowerShell'], +]); + +/** Minimum file count for a gap to be worth surfacing. */ +const DEFAULT_MIN_FILES = 10; + +export interface CoverageGap { + /** Lower-cased file extension including the leading dot, e.g. `.cljs`. */ + extension: string; + /** Human-readable language name shown in the warning. */ + language: string; + /** Number of files with this extension in the scanned repo. */ + fileCount: number; +} + +export interface DetectCoverageGapsOptions { + /** Minimum file count threshold (default 10). */ + minFiles?: number; +} + +/** + * Tally extensions across `filePaths` and return one gap per + * unsupported-but-meaningfully-present language, sorted by file count desc. + * + * Pure function. Does not read the filesystem. + */ +export function detectCoverageGaps( + filePaths: readonly string[], + options?: DetectCoverageGapsOptions, +): CoverageGap[] { + const minFiles = options?.minFiles ?? DEFAULT_MIN_FILES; + + const counts = new Map(); + for (const filePath of filePaths) { + const dot = filePath.lastIndexOf('.'); + if (dot < 0) continue; + const ext = filePath.slice(dot).toLowerCase(); + if (!UNSUPPORTED_SOURCE_LANGUAGES.has(ext)) continue; + // Defensive: a future provider may claim one of these extensions. Treat + // "has provider" as the source of truth and skip — no gap to report. + if (getProviderForFile(filePath) !== null) continue; + counts.set(ext, (counts.get(ext) ?? 0) + 1); + } + + const gaps: CoverageGap[] = []; + for (const [ext, fileCount] of counts) { + if (fileCount < minFiles) continue; + gaps.push({ + extension: ext, + language: UNSUPPORTED_SOURCE_LANGUAGES.get(ext)!, + fileCount, + }); + } + + gaps.sort((a, b) => b.fileCount - a.fileCount); + return gaps; +} + +/** + * Format gaps as a multi-line CLI warning block. Returns null when there are + * no gaps to report so callers can keep the no-op path silent. + */ +export function formatCoverageGapWarning(gaps: readonly CoverageGap[]): string | null { + if (gaps.length === 0) return null; + + const lines: string[] = []; + lines.push(' Coverage gaps detected:'); + for (const gap of gaps) { + lines.push( + ` ${gap.fileCount.toLocaleString()} ${gap.extension} files — ` + + `${gap.language} not supported. No symbols extracted from these files.`, + ); + } + lines.push( + ' These files were registered but produced no callable symbols in the graph.', + ); + return lines.join('\n'); +} diff --git a/gitnexus/src/core/ingestion/pipeline.ts b/gitnexus/src/core/ingestion/pipeline.ts index c220ea224..80322848a 100644 --- a/gitnexus/src/core/ingestion/pipeline.ts +++ b/gitnexus/src/core/ingestion/pipeline.ts @@ -18,10 +18,12 @@ import { createKnowledgeGraph } from '../graph/graph.js'; import { type PipelineProgress } from 'gitnexus-shared'; import { PipelineResult } from '../../types/pipeline.js'; +import { detectCoverageGaps } from '../coverage-gaps.js'; import { runPipeline, getPhaseOutput, scanPhase, + type ScanOutput, structurePhase, markdownPhase, cobolPhase, @@ -117,6 +119,13 @@ export const runPipelineFromRepo = async ( usedWorkerPool: boolean; }>(results, 'parse'); + // Coverage-gap detection runs against the scan output: it tallies file + // extensions in the repo against the LanguageProvider registry to surface + // languages with N+ source files but no provider (silent indexing gaps). + // Pure tally on already-collected paths — no extra I/O. + const { allPaths } = getPhaseOutput(results, 'scan'); + const coverageGaps = detectCoverageGaps(allPaths); + let communityResult: CommunitiesOutput['communityResult'] | undefined; let processResult: ProcessesOutput['processResult'] | undefined; @@ -146,5 +155,6 @@ export const runPipelineFromRepo = async ( communityResult, processResult, usedWorkerPool, + coverageGaps, }; }; diff --git a/gitnexus/src/types/pipeline.ts b/gitnexus/src/types/pipeline.ts index 331bdd6fc..1c16c3bda 100644 --- a/gitnexus/src/types/pipeline.ts +++ b/gitnexus/src/types/pipeline.ts @@ -1,6 +1,7 @@ import type { KnowledgeGraph } from '../core/graph/types.js'; import { CommunityDetectionResult } from '../core/ingestion/community-processor.js'; import { ProcessDetectionResult } from '../core/ingestion/process-processor.js'; +import type { CoverageGap } from '../core/coverage-gaps.js'; // CLI-specific: in-memory result with graph + detection results export interface PipelineResult { @@ -17,4 +18,11 @@ export interface PipelineResult { * so regression suites can prove which path executed. */ usedWorkerPool: boolean; + /** + * Languages with a meaningful presence in the repo (≥ 10 files by default) + * that GitNexus cannot index because no LanguageProvider exists. Surfaced + * by the CLI so callers see when "analyze succeeded" actually means + * "analyzed only a minority of the source tree." + */ + coverageGaps: CoverageGap[]; } diff --git a/gitnexus/test/unit/coverage-gaps.test.ts b/gitnexus/test/unit/coverage-gaps.test.ts new file mode 100644 index 000000000..f554d39aa --- /dev/null +++ b/gitnexus/test/unit/coverage-gaps.test.ts @@ -0,0 +1,96 @@ +import { describe, it, expect } from 'vitest'; +import { + detectCoverageGaps, + formatCoverageGapWarning, +} from '../../src/core/coverage-gaps.js'; + +describe('detectCoverageGaps', () => { + it('returns empty when there are no unsupported source files', () => { + const paths = [ + 'src/main.ts', + 'src/util.ts', + 'src/lib.py', + 'README.md', + 'package.json', + ]; + expect(detectCoverageGaps(paths)).toEqual([]); + }); + + it('flags an unsupported language present above the default threshold', () => { + const paths = Array.from({ length: 25 }, (_, i) => `src/ns/${i}.cljs`); + const gaps = detectCoverageGaps(paths); + expect(gaps).toEqual([ + { extension: '.cljs', language: 'ClojureScript', fileCount: 25 }, + ]); + }); + + it('does not flag an unsupported language below the threshold', () => { + const paths = ['a.clj', 'b.clj', 'c.clj']; + expect(detectCoverageGaps(paths)).toEqual([]); + }); + + it('respects a custom minFiles threshold', () => { + const paths = ['a.clj', 'b.clj', 'c.clj']; + expect(detectCoverageGaps(paths, { minFiles: 3 })).toEqual([ + { extension: '.clj', language: 'Clojure', fileCount: 3 }, + ]); + }); + + it('aggregates multiple unsupported languages and sorts by count desc', () => { + const paths = [ + ...Array.from({ length: 30 }, (_, i) => `frontend/${i}.cljs`), + ...Array.from({ length: 15 }, (_, i) => `backend/${i}.clj`), + ...Array.from({ length: 12 }, (_, i) => `common/${i}.cljc`), + ...Array.from({ length: 50 }, (_, i) => `core/${i}.ts`), // supported, ignored + ]; + const gaps = detectCoverageGaps(paths); + expect(gaps).toEqual([ + { extension: '.cljs', language: 'ClojureScript', fileCount: 30 }, + { extension: '.clj', language: 'Clojure', fileCount: 15 }, + { extension: '.cljc', language: 'Clojure (cross-platform)', fileCount: 12 }, + ]); + }); + + it('treats extensions case-insensitively', () => { + const paths = Array.from({ length: 12 }, (_, i) => `src/${i}.HS`); + const gaps = detectCoverageGaps(paths); + expect(gaps).toEqual([ + { extension: '.hs', language: 'Haskell', fileCount: 12 }, + ]); + }); + + it('ignores files without an extension', () => { + const paths = Array.from({ length: 50 }, (_, i) => `bin/exe-${i}`); + expect(detectCoverageGaps(paths)).toEqual([]); + }); + + it('ignores supported languages even when present in large numbers', () => { + const paths = Array.from({ length: 500 }, (_, i) => `src/${i}.ts`); + expect(detectCoverageGaps(paths)).toEqual([]); + }); +}); + +describe('formatCoverageGapWarning', () => { + it('returns null for empty gaps', () => { + expect(formatCoverageGapWarning([])).toBeNull(); + }); + + it('formats a single gap with file count and language', () => { + const out = formatCoverageGapWarning([ + { extension: '.cljs', language: 'ClojureScript', fileCount: 1865 }, + ]); + expect(out).toContain('Coverage gaps detected'); + expect(out).toContain('1,865 .cljs files'); + expect(out).toContain('ClojureScript not supported'); + }); + + it('formats multiple gaps on separate lines', () => { + const out = formatCoverageGapWarning([ + { extension: '.cljs', language: 'ClojureScript', fileCount: 1146 }, + { extension: '.clj', language: 'Clojure', fileCount: 719 }, + ]); + expect(out).toContain('1,146 .cljs'); + expect(out).toContain('719 .clj'); + expect(out!.split('\n').length).toBeGreaterThanOrEqual(4); + }); +});