feat(status): show files parsed beside files found, and add --strict (#3096)

A language's file count says what the files are, not whether anything came
of them, so a language with nothing in the graph could read as healthy.

`codegraph status` and `codegraph_status` now show how many of a language's
files yielded symbols when that isn't all of them, and warn about a language
where none did. `status --json` gains filesByLanguage,
parsedFilesByLanguage and unparsedLanguages. `status --strict` exits 1 unless
the index is finished, up to date and every language that should yield
symbols did.

The count stays out of getStats(), which every explore runs. A plain
EXISTS probe per file would have doubled getStats() on a 14,569-file index,
from about 40 ms to about 80 ms. The query status runs instead takes 4.3 ms
there, because the stored node count settles nearly every row, and only
status runs it.

The gate covers more than a zero count because a grammar that can't load no
longer leaves one: since PR 2335 nothing is stored for such a file, the
language drops out of the rows, and the only trace is pending files. A gate
checking zeroes alone would pass that.

Text changes: a language row gains "  (N with symbols)" only when the two
numbers differ, and a warning line appears for a language with no symbols.
Every other line, and every existing JSON field, is unchanged.

Across 115 real indexes, 9 repos have a code language with no symbols, all
of them stubs in languages with at most 3 files, such as ripgrep's
.nvim.lua. A file-count threshold would have hidden a real failure in a
small smoke-test project, so the rule stays plain and the warning names the
files instead.

Reimplements PR 1658 by @ferrine.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
Colby Mchenry
2026-10-30 18:53:03 +00:00
committed by GitHub
co-authored by Claude Opus 5.5
parent f4c4dae91c
commit 7889da6697
10 changed files with 499 additions and 13 deletions
+50 -8
View File
@@ -75,7 +75,7 @@ import { lookupSymbolNodes, describeSymbolNode, groupDefinitions } from '../grap
import { callSiteLines, formatCallSiteLines } from '../graph/call-sites';
import { goPackageReaderFor } from '../graph/go-package';
import { groupFilesByPackage, packageGroupTitle, packageListingTitle } from '../graph/owning-package';
import type { Node, Edge } from '../types';
import type { Node, Edge, Language } from '../types';
import { isTestPath } from '../search/query-utils';
// Decided once, before `--color`/`--no-color` are stripped from argv below
@@ -1092,6 +1092,11 @@ program
}
});
/** Up to three of `paths`, and how many more there are. */
function samplePaths(paths: string[]): string {
return paths.slice(0, 3).join(', ') + (paths.length > 3 ? ` (+${formatNumber(paths.length - 3)} more)` : '');
}
/**
* The `status` warnings for indexed files whose symbols are missing although
* their content is current (#2335, #2336): one line per group, saying what is
@@ -1099,21 +1104,19 @@ program
*/
function describeFilesMissingSymbols(health: { needsReindex: string[]; parseErrors: string[] }): string[] {
const dash = getGlyphs().dash;
const sample = (paths: string[]): string =>
paths.slice(0, 3).join(', ') + (paths.length > 3 ? ` (+${formatNumber(paths.length - 3)} more)` : '');
const lines: string[] = [];
const reindex = health.needsReindex.length;
if (reindex > 0) {
lines.push(
`${formatNumber(reindex)} ${reindex === 1 ? 'file is missing its' : 'files are missing their'} symbols ${dash} ` +
`run "codegraph sync" to re-index ${reindex === 1 ? 'it' : 'them'}: ${sample(health.needsReindex)}`
`run "codegraph sync" to re-index ${reindex === 1 ? 'it' : 'them'}: ${samplePaths(health.needsReindex)}`
);
}
const unparsed = health.parseErrors.length;
if (unparsed > 0) {
lines.push(
`${formatNumber(unparsed)} ${unparsed === 1 ? 'file could not be parsed, so its symbols are' : 'files could not be parsed, so their symbols are'} ` +
`missing: ${sample(health.parseErrors)} ${dash} "codegraph files --json" shows the errors`
`missing: ${samplePaths(health.parseErrors)} ${dash} "codegraph files --json" shows the errors`
);
}
return lines;
@@ -1126,7 +1129,8 @@ program
.command('status [path]')
.description('Show index status and statistics')
.option('-j, --json', 'Output as JSON')
.action(async (pathArg: string | undefined, options: { json?: boolean }) => {
.option('--strict', 'Exit 1 unless the index is finished, up to date, and every language that should yield symbols did')
.action(async (pathArg: string | undefined, options: { json?: boolean; strict?: boolean }) => {
const projectPath = resolveProjectPath(pathArg);
// The directory the user actually ran from, before walking up to the index
// root. Used to detect when the resolved index lives in a different git
@@ -1136,6 +1140,8 @@ program
try {
if (!isInitialized(projectPath)) {
// A gate with no index to read has proved nothing.
if (options.strict) process.exitCode = 1;
if (options.json) {
console.log(JSON.stringify({
initialized: false,
@@ -1183,6 +1189,24 @@ program
// Files whose content is current but whose symbols are missing — the
// content-hash check behind "up to date" cannot see them (#2336).
const health = cg.getIndexHealth();
// Files found against files that yielded symbols: a language can be
// listed with its full count and have nothing in the graph.
const coverage = cg.getLanguageCoverage();
// Reached by every output mode below. A grammar that can't load leaves no
// row and no zero to count, only files the index doesn't have yet, so
// "up to date" is part of the gate as much as the language check is.
// Parse errors are not: one bad fixture shouldn't fail a whole project.
const totalChanges = changes.added.length + changes.modified.length + changes.removed.length;
if (options.strict && (
rebuildInProgress ||
indexState === 'indexing' || indexState === 'partial' || indexState === 'failed' ||
totalChanges > 0 ||
health.needsReindex.length > 0 ||
coverage.unparsedLanguages.length > 0
)) {
process.exitCode = 1;
}
// JSON output mode
if (options.json) {
@@ -1202,6 +1226,11 @@ program
journalMode,
nodesByKind: stats.nodesByKind,
languages: Object.entries(stats.filesByLanguage).filter(([, count]) => count > 0).map(([lang]) => lang),
filesByLanguage: stats.filesByLanguage,
// Files that yielded a symbol, for the languages expected to.
parsedFilesByLanguage: coverage.parsedFilesByLanguage,
// Of those, the languages with files and no symbols at all.
unparsedLanguages: coverage.unparsedLanguages,
pendingChanges: {
added: changes.added.length,
modified: changes.modified.length,
@@ -1313,7 +1342,9 @@ program
.filter(([, count]) => count > 0)
.sort((a, b) => b[1] - a[1]);
for (const [lang, count] of filesByLang) {
console.log(` ${lang.padEnd(15)} ${formatNumber(count)}`);
const parsed = coverage.parsedFilesByLanguage[lang as Language];
const withSymbols = parsed === undefined || parsed === count ? '' : ` (${formatNumber(parsed)} with symbols)`;
console.log(` ${lang.padEnd(15)} ${formatNumber(count)}${chalk.dim(withSymbols)}`);
}
console.log();
@@ -1324,8 +1355,19 @@ program
return;
}
// Worded for both causes: a lone config stub with nothing to extract
// (a `.nvim.lua`, a comment-only `site.js`) reads exactly like a parser
// that isn't working, and the file names are what tell them apart.
if (coverage.unparsedLanguages.length > 0) {
const files = cg.getFiles();
for (const language of coverage.unparsedLanguages) {
const paths = files.filter((file) => file.language === language).map((file) => file.path);
warn(`No symbols came from any ${language} file ${getGlyphs().dash} fine if they hold no code, otherwise its parser isn't working: ${samplePaths(paths)}`);
}
console.log();
}
// Pending changes
const totalChanges = changes.added.length + changes.modified.length + changes.removed.length;
if (totalChanges > 0) {
console.log(chalk.bold('Pending Changes:'));
if (changes.added.length > 0) {
+24
View File
@@ -5593,6 +5593,30 @@ export class QueryBuilder {
.get() as { nodes: number; edges: number };
}
/**
* For each language, how many files it has and how many yielded a symbol: a
* node other than the file's own. More than one stored node settles a row, so
* `nodes` is only probed for the few it can't, such as a file whose one symbol
* came from a framework pass. Probing every row costs as much as the whole of
* getStats() on a 14,000-file index; this costs a tenth of that. Not part of
* getStats() either way, because every explore runs that.
*/
getParsedFileCounts(): Array<{ language: Language; files: number; parsed: number }> {
return this.db
.prepare(`
SELECT f.language AS language, COUNT(*) AS files,
SUM(CASE
WHEN f.node_count > 1 THEN 1
WHEN EXISTS (SELECT 1 FROM nodes n WHERE n.file_path = f.path AND n.kind <> 'file') THEN 1
ELSE 0
END) AS parsed
FROM files f
GROUP BY f.language
ORDER BY f.language
`)
.all() as Array<{ language: Language; files: number; parsed: number }>;
}
/**
* Get graph statistics
*/
+9
View File
@@ -837,6 +837,15 @@ export function isFileLevelOnlyLanguage(language: Language): boolean {
return language === 'yaml' || language === 'twig' || language === 'properties';
}
/**
* Whether files of this language are expected to yield symbols, so that a
* language with none is news. Not so for the file-level languages, nor for
* `xml`, where only MyBatis mappers yield anything.
*/
export function yieldsSymbols(language: Language): boolean {
return !isFileLevelOnlyLanguage(language) && language !== 'xml' && language !== 'unknown';
}
/**
* Get all supported languages (those with grammar definitions).
*/
+17 -1
View File
@@ -26,6 +26,7 @@ import {
FindRelevantContextOptions,
UnresolvedReference,
IndexHealth,
LanguageCoverage,
Language,
} from './types';
import { DatabaseConnection, getDatabasePath, removeDatabaseFiles } from './db';
@@ -46,7 +47,7 @@ import {
extractFromSource,
initGrammars,
} from './extraction';
import { detectLanguage, hasGrammarLoadFailure, isFileLevelOnlyLanguage } from './extraction/grammars';
import { detectLanguage, hasGrammarLoadFailure, isFileLevelOnlyLanguage, yieldsSymbols } from './extraction/grammars';
import {
ReferenceResolver,
changedRoutes,
@@ -2770,6 +2771,21 @@ export class CodeGraph {
return { needsReindex, parseErrors };
}
/**
* Files found against files that yielded symbols, by language. Reported by
* `status`, whose `--strict` fails on a language that yielded none.
*/
getLanguageCoverage(): LanguageCoverage {
const parsedFilesByLanguage: LanguageCoverage['parsedFilesByLanguage'] = {};
const unparsedLanguages: Language[] = [];
for (const { language, parsed } of this.queries.getParsedFileCounts()) {
if (!yieldsSymbols(language)) continue;
parsedFilesByLanguage[language] = parsed;
if (parsed === 0) unparsedLanguages.push(language);
}
return { parsedFilesByLanguage, unparsedLanguages };
}
// ===========================================================================
// Graph Query Methods
// ===========================================================================
+13 -2
View File
@@ -35,7 +35,7 @@ import {
type WorktreeIndexMismatch,
} from '../sync/worktree';
import type { PendingFile } from '../sync';
import type { Node, Edge, SearchResult, Subgraph, NodeKind } from '../types';
import type { Node, Edge, SearchResult, Subgraph, NodeKind, Language } from '../types';
import { isTestFile, normalizeNameToken, splitAtCjkSwitches, isQueryNameToken } from '../search/query-utils';
import { groupDefinitions, isQualifiedSymbol, lastQualifierPart, matchesSymbol, qualifiedSymbolMatches } from '../graph/symbol-lookup';
import { goPackageReaderFor } from '../graph/go-package';
@@ -8548,12 +8548,23 @@ export class ToolHandler {
}
}
// Beside the file count, how many yielded symbols: it is what tells a
// language that parsed from one that is only listed.
const coverage = cg.getLanguageCoverage();
lines.push('', '**Languages:**');
for (const [lang, count] of Object.entries(stats.filesByLanguage)) {
if ((count as number) > 0) {
lines.push(`- ${lang}: ${count}`);
const parsed = coverage.parsedFilesByLanguage[lang as Language];
lines.push(parsed === undefined || parsed === count ? `- ${lang}: ${count}` : `- ${lang}: ${count} (${parsed} with symbols)`);
}
}
if (coverage.unparsedLanguages.length > 0) {
lines.push(
'',
`**No symbols from:** ⚠ ${coverage.unparsedLanguages.join(', ')} ` +
`— those files are indexed, but nothing in them can be searched or traced; Read them directly.`
);
}
// Whole-index degradation (#876): when live watching has permanently
// stopped, getPendingFiles() is empty (so no "Pending sync" section below)
+15
View File
@@ -272,6 +272,21 @@ export interface FileRecord {
generated?: boolean;
}
/**
* Files found against files that yielded symbols, by language, for `status`.
* `GraphStats.filesByLanguage` counts files by what they are, so on its own it
* reads the same for a language that parsed and one that yielded nothing.
*/
export interface LanguageCoverage {
/**
* Files that yielded a symbol, for the languages expected to. A language
* tracked at file level (yaml, most xml) has no entry: nothing is missing.
*/
parsedFilesByLanguage: Partial<Record<Language, number>>;
/** Languages expected to yield symbols that have files, none of which did. */
unparsedLanguages: Language[];
}
/**
* Indexed files whose symbols are missing even though their content hash is
* current — what a hash comparison alone calls "up to date" (#2336). Paths are