mirror of
https://github.com/colbymchenry/codegraph.git
synced 2026-11-15 04:21:31 +08:00
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:
co-authored by
Claude Opus 5.5
parent
f4c4dae91c
commit
7889da6697
+50
-8
@@ -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) {
|
||||
|
||||
@@ -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
|
||||
*/
|
||||
|
||||
@@ -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
@@ -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
@@ -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)
|
||||
|
||||
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user