feat(analyze): warn on silent language coverage gaps

`analyze` previously reported "success" even when the parser pipeline
produced zero symbols for the bulk of a repo's source. On a
Clojure-majority codebase (e.g. penpot: 1,865 .cljs / 1,146 .clj /
3,360 .cljc files), the run finishes in ~6s with files registered as
File nodes but no Function/Macro/Namespace nodes — leaving callers to
trust a precise but dangerously partial graph.

Add a reporting-only check that runs after the scan phase, tallies file
extensions against a curated set of known-source languages with no
LanguageProvider, and surfaces gaps (≥10 files by default) in the CLI
summary. Pure tally over already-collected paths — no extra I/O, no
behavioral change.

The set of unsupported source languages is co-located in
`coverage-gaps.ts` so providers added later can simply remove their
extensions and the warning self-deactivates. `getProviderForFile`
remains the source of truth: any extension that gains a real provider
is skipped even if still listed.
This commit is contained in:
LiuWei 2026-04-28 01:15:40 +08:00
parent 2727a8ca2a
commit 0f4a2c9e88
5 changed files with 267 additions and 0 deletions

View file

@ -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 {

View file

@ -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<string, string> = 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<string, number>();
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');
}

View file

@ -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<ScanOutput>(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,
};
};

View file

@ -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[];
}

View file

@ -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);
});
});