mirror of
https://github.com/abhigyanpatwari/GitNexus.git
synced 2026-09-13 23:14:20 +00:00
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:
parent
2727a8ca2a
commit
0f4a2c9e88
5 changed files with 267 additions and 0 deletions
|
|
@ -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 {
|
||||
|
|
|
|||
141
gitnexus/src/core/coverage-gaps.ts
Normal file
141
gitnexus/src/core/coverage-gaps.ts
Normal 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');
|
||||
}
|
||||
|
|
@ -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,
|
||||
};
|
||||
};
|
||||
|
|
|
|||
|
|
@ -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[];
|
||||
}
|
||||
|
|
|
|||
96
gitnexus/test/unit/coverage-gaps.test.ts
Normal file
96
gitnexus/test/unit/coverage-gaps.test.ts
Normal 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);
|
||||
});
|
||||
});
|
||||
Loading…
Add table
Reference in a new issue