Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
3 changes: 3 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -12,6 +12,9 @@ and adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).

## [Unreleased]

### New Features

- `codegraph status` now shows how many files of each language actually produced symbols, not just how many were found. The file count is based on the file extension, so a language whose parser is missing or failing still reported its full count and looked healthy — indexing succeeded, the language was listed, and symbol lookups still ran, while nothing from those files was in the graph. When a language yields no symbols at all, status now says so in plain terms, and `codegraph status --strict` exits non-zero so packaging and CI can gate on it. The same seen-versus-parsed breakdown is shown to connected agents.

## [1.6.0] - 2026-08-26

Expand Down
127 changes: 127 additions & 0 deletions __tests__/status-parsed-vs-seen.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,127 @@
/**
* `status` must distinguish files SEEN from files PARSED.
*
* The per-language file count is derived from the file EXTENSION, so it counts
* a file the parser never understood identically to one it parsed perfectly.
* That makes a broken language very cheap to ship: with a grammar missing,
* `init` still succeeds, `status` still lists the language with its full file
* count, and a symbol lookup still "runs" — every obvious check stays green
* while the language contributes nothing to the graph.
*
* The parsed count is the cheap signal that separates the two, and it is only
* meaningful if it excludes the `file` node every indexed file gets regardless.
*/

import { describe, it, expect, beforeAll, beforeEach, afterEach } from 'vitest';
import * as fs from 'fs';
import * as path from 'path';
import * as os from 'os';
import { initGrammars, loadAllGrammars, hasGrammar } from '../src/extraction/grammars';

beforeAll(async () => {
await initGrammars();
await loadAllGrammars();
});

function hasSqliteBindings(): boolean {
try {
const { DatabaseSync } = require('node:sqlite');
const db = new DatabaseSync(':memory:');
db.close();
return true;
} catch {
return false;
}
}
const HAS_SQLITE = hasSqliteBindings();

describe('hasGrammar — which languages are expected to yield symbols', () => {
it('is true for grammar-backed languages', () => {
expect(hasGrammar('typescript')).toBe(true);
expect(hasGrammar('python')).toBe(true);
expect(hasGrammar('rust')).toBe(true);
});

it('is false for the formats tracked at file level on purpose', () => {
// These must never raise a "parsed nothing" warning — producing no symbols
// is their designed behaviour, not a broken grammar.
expect(hasGrammar('yaml')).toBe(false);
expect(hasGrammar('twig')).toBe(false);
expect(hasGrammar('xml')).toBe(false);
expect(hasGrammar('unknown')).toBe(false);
});
});

describe.skipIf(!HAS_SQLITE)('getStats — parsedFilesByLanguage', () => {
let projectRoot: string;
let cg: any;

beforeEach(() => {
projectRoot = fs.mkdtempSync(path.join(os.tmpdir(), 'codegraph-parsed-'));
});

afterEach(() => {
cg?.destroy();
if (fs.existsSync(projectRoot)) fs.rmSync(projectRoot, { recursive: true, force: true });
});

async function index(files: Record<string, string>, include: string[]): Promise<any> {
for (const [rel, body] of Object.entries(files)) {
const full = path.join(projectRoot, rel);
fs.mkdirSync(path.dirname(full), { recursive: true });
fs.writeFileSync(full, body);
}
const CodeGraph = (await import('../src/index')).default;
cg = CodeGraph.initSync(projectRoot, { config: { include, exclude: [] } });
await cg.indexAll();
return cg.getStats();
}

it('counts a file that yielded symbols as parsed', async () => {
const stats = await index(
{ 'src/a.ts': 'export function alpha(): number { return 1; }\n' },
['**/*.ts']
);
expect(stats.filesByLanguage.typescript).toBe(1);
expect(stats.parsedFilesByLanguage.typescript).toBe(1);
}, 60000);

it('does NOT count a file whose only node is its own file node', async () => {
// An empty source file is indexed — it gets a `file` node like every other
// — but nothing was extracted from it. If the parsed count included the
// file node it would equal the seen count always, and detect nothing.
const stats = await index({ 'src/empty.ts': '\n\n' }, ['**/*.ts']);
expect(stats.filesByLanguage.typescript).toBe(1);
expect(stats.parsedFilesByLanguage.typescript ?? 0).toBe(0);
}, 60000);

it('reports seen and parsed separately per language', async () => {
const stats = await index(
{
'src/a.ts': 'export function alpha(): number { return 1; }\n',
'src/empty.ts': '\n',
'src/b.py': 'def beta():\n return 2\n',
},
['**/*.ts', '**/*.py']
);
expect(stats.filesByLanguage.typescript).toBe(2);
expect(stats.parsedFilesByLanguage.typescript).toBe(1);
expect(stats.filesByLanguage.python).toBe(1);
expect(stats.parsedFilesByLanguage.python).toBe(1);
}, 60000);

it('a language that parsed nothing is detectable from the stats alone', async () => {
// The shape `status --strict` gates on: files present, none parsed, and the
// language is one a grammar is supposed to handle.
const stats = await index({ 'src/empty.ts': '\n' }, ['**/*.ts']);
const unparsed = Object.entries(stats.filesByLanguage as Record<string, number>)
.filter(
([lang, count]) =>
count > 0 &&
((stats.parsedFilesByLanguage as Record<string, number>)[lang] ?? 0) === 0 &&
hasGrammar(lang as never)
)
.map(([lang]) => lang);
expect(unparsed).toEqual(['typescript']);
}, 60000);
});
51 changes: 48 additions & 3 deletions src/bin/codegraph.ts
Original file line number Diff line number Diff line change
Expand Up @@ -53,6 +53,8 @@ import { relaunchWithWasmRuntimeFlagsIfNeeded } from '../extraction/wasm-runtime
import { installCommandSupervision } from './command-supervision';
import { EXTRACTION_VERSION } from '../extraction/extraction-version';
import { getTelemetry, TELEMETRY_DOCS, recordIndexEvent } from '../telemetry';
// Type-only: erased at runtime, so it adds nothing to CLI startup.
import type { Language } from '../types';

// Decided once, before `--color`/`--no-color` are stripped from argv below
// (#1281). Piped/redirected stdout, NO_COLOR, or --no-color -> plain output.
Expand Down Expand Up @@ -941,7 +943,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 non-zero when a language was indexed but parsed no files')
.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
Expand Down Expand Up @@ -975,6 +978,26 @@ program
const backend = cg.getBackend();
const journalMode = cg.getJournalMode();

// Languages whose files were all indexed and none parsed. The headline
// file count is keyed on the extension, so it stays reassuringly
// non-zero when a language's grammar is missing entirely — every obvious
// check (init succeeds, status lists the language, a symbol lookup
// "runs") stays green while the language contributes nothing to the
// graph. Restricted to grammar-backed languages: config formats are
// tracked at file level ON PURPOSE and must not raise this.
// Imported here rather than at module scope: grammars.ts pulls in
// web-tree-sitter, which every other command would then pay for at
// startup.
const { hasGrammar } = await import('../extraction/grammars');
const parsedByLanguage = stats.parsedFilesByLanguage ?? ({} as Record<Language, number>);
const unparsedLanguages = Object.entries(stats.filesByLanguage)
.filter(([lang, count]) =>
count > 0 &&
(parsedByLanguage[lang as Language] ?? 0) === 0 &&
hasGrammar(lang as Language)
)
.map(([lang]) => lang);

const buildInfo = cg.getIndexBuildInfo();
const reindexRecommended = cg.isIndexStale();
const indexState = cg.getIndexState();
Expand All @@ -1000,6 +1023,10 @@ 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, vs the extension-keyed count above.
parsedFilesByLanguage: stats.parsedFilesByLanguage,
unparsedLanguages,
pendingChanges: {
added: changes.added.length,
modified: changes.modified.length,
Expand Down Expand Up @@ -1089,16 +1116,30 @@ program
}
console.log();

// Language breakdown
// Language breakdown. The file count comes from the EXTENSION, so it says
// nothing about whether anything parsed — show the parsed count beside it
// so a language that produced no symbols is visible at a glance.
console.log(chalk.bold('Files by Language:'));
const filesByLang = Object.entries(stats.filesByLanguage)
.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 = parsedByLanguage[lang as Language] ?? 0;
const detail = parsed === count ? '' : ` ${parsed} parsed`;
const line = ` ${lang.padEnd(15)} ${formatNumber(count)}`;
console.log(unparsedLanguages.includes(lang) ? chalk.yellow(line + detail) : line + chalk.dim(detail));
}
console.log();

if (unparsedLanguages.length > 0) {
warn(
`no symbols were extracted from any ${unparsedLanguages.join(' / ')} file. ` +
'Those files are indexed but their contents are not searchable — usually a ' +
'missing or failed grammar for that language.'
);
console.log();
}

// Pending changes
const totalChanges = changes.added.length + changes.modified.length + changes.removed.length;
if (totalChanges > 0) {
Expand Down Expand Up @@ -1128,6 +1169,10 @@ program
}

cg.destroy();
// Opt-in gate for packaging/CI: a language that parsed nothing is a
// broken build, but making it fail by default would break every existing
// caller of `status`.
if (options.strict && unparsedLanguages.length > 0) process.exit(1);
} catch (err) {
error(`Failed to get status: ${err instanceof Error ? err.message : String(err)}`);
process.exit(1);
Expand Down
18 changes: 18 additions & 0 deletions src/db/queries.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2834,13 +2834,31 @@ export class QueryBuilder {
filesByLanguage[row.language as Language] = row.count;
}

// Files that produced a symbol. Every indexed file gets a `file` node
// whether or not it parsed, so that one is excluded — the presence of
// anything else is what distinguishes a parsed file from a merely seen one.
const parsedFilesByLanguage = {} as Record<Language, number>;
const parsedRows = this.db
.prepare(
`SELECT f.language, COUNT(*) as count FROM files f
WHERE EXISTS (
SELECT 1 FROM nodes n WHERE n.file_path = f.path AND n.kind <> 'file'
)
GROUP BY f.language`
)
.all() as Array<{ language: string; count: number }>;
for (const row of parsedRows) {
parsedFilesByLanguage[row.language as Language] = row.count;
}

return {
nodeCount: counts.node_count,
edgeCount: counts.edge_count,
fileCount: counts.file_count,
nodesByKind,
edgesByKind,
filesByLanguage,
parsedFilesByLanguage,
dbSizeBytes: 0, // Set by caller using DatabaseConnection.getSize()
walSizeBytes: 0, // Set by caller using DatabaseConnection.getWalSizeBytes()
lastUpdated: Date.now(),
Expand Down
14 changes: 14 additions & 0 deletions src/extraction/grammars.ts
Original file line number Diff line number Diff line change
Expand Up @@ -172,6 +172,20 @@ export const EXTENSION_MAP: Record<string, Language> = {
'.tofu': 'terraform',
};

/**
* Whether this language is backed by a grammar, i.e. whether files of this
* language are EXPECTED to yield symbols.
*
* Several indexed languages track files without extracting symbols from them
* (yaml/twig/xml config, and the SFC wrappers that delegate their script
* content elsewhere). Distinguishing them matters for any check on "this
* language produced nothing": for a grammar language that is a broken parse,
* for the others it is business as usual.
*/
export function hasGrammar(language: Language): boolean {
return language in WASM_GRAMMAR_FILES;
}

/**
* Whether a file is one CodeGraph can parse, based purely on its extension.
* This is the single source of truth for "should we index this file" — derived
Expand Down
11 changes: 10 additions & 1 deletion src/mcp/tools.ts
Original file line number Diff line number Diff line change
Expand Up @@ -6464,10 +6464,19 @@ export class ToolHandler {
}
}

// The file count is keyed on the EXTENSION, so it stays non-zero for a
// language nothing could parse. Show the parsed count whenever it differs,
// so "indexed" is never mistaken for "searchable".
lines.push('', '**Languages:**');
const parsedByLanguage = stats.parsedFilesByLanguage ?? {};
for (const [lang, count] of Object.entries(stats.filesByLanguage)) {
if ((count as number) > 0) {
lines.push(`- ${lang}: ${count}`);
const parsed = (parsedByLanguage as Record<string, number>)[lang] ?? 0;
lines.push(
parsed === count
? `- ${lang}: ${count}`
: `- ${lang}: ${count} files, ${parsed} with extracted symbols`
);
}
}

Expand Down
13 changes: 12 additions & 1 deletion src/types.ts
Original file line number Diff line number Diff line change
Expand Up @@ -578,9 +578,20 @@ export interface GraphStats {
/** Edge counts by kind */
edgesByKind: Record<EdgeKind, number>;

/** File counts by language */
/** File counts by language, keyed on the file's detected language. */
filesByLanguage: Record<Language, number>;

/**
* Files that actually yielded a symbol, by language.
*
* `filesByLanguage` is derived from the file EXTENSION, so it counts a file
* the parser never understood exactly the same as one it parsed perfectly —
* a language whose grammar is missing still reports its full file count. This
* is the count that separates *seen* from *parsed*, and the gap between the
* two is the only cheap signal that a language's extraction is broken.
*/
parsedFilesByLanguage: Record<Language, number>;

/** Database size in bytes */
dbSizeBytes: number;

Expand Down