From 7b339373b40c26a6791b42abd82cf8f41165996a Mon Sep 17 00:00:00 2001 From: Colby Mchenry Date: Tue, 8 Sep 2026 16:17:28 -0500 Subject: [PATCH] fix(extraction): warn when parse errors leave no symbols (#1522) (#1799) Co-authored-by: Colby McHenry --- CHANGELOG.md | 2 + __tests__/cli-parse-warning.test.ts | 63 +++++++++++++++ .../cpp-raw-string-delimiter-haserror.test.ts | 77 +++++++++++++++++++ src/bin/codegraph.ts | 5 ++ src/extraction/tree-sitter.ts | 12 +++ 5 files changed, 159 insertions(+) create mode 100644 __tests__/cli-parse-warning.test.ts create mode 100644 __tests__/cpp-raw-string-delimiter-haserror.test.ts diff --git a/CHANGELOG.md b/CHANGELOG.md index d117304..076e1a6 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -137,6 +137,8 @@ and adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html). #### MCP / indexing +- Indexing now warns when parser errors leave a file with no symbols, including C++ raw strings with 16-character delimiters, so missing code is no longer silent. (#1522) + - `codegraph index ` now refuses uninitialized paths and names the nearest initialized parent instead of silently rebuilding it; thanks @danusha2345. (#1524, #1689) - Sync now recovers the same connections as a clean index after interrupted reference resolution, including inherited calls and callbacks that previously stayed missing. (#1577) diff --git a/__tests__/cli-parse-warning.test.ts b/__tests__/cli-parse-warning.test.ts new file mode 100644 index 0000000..e4aa703 --- /dev/null +++ b/__tests__/cli-parse-warning.test.ts @@ -0,0 +1,63 @@ +import { afterEach, beforeEach, describe, expect, it } from 'vitest'; +import { spawnSync } from 'child_process'; +import * as fs from 'fs'; +import * as os from 'os'; +import * as path from 'path'; + +const BIN = path.resolve(__dirname, '../dist/bin/codegraph.js'); +const COLLAPSE_WARNING = 'parse produced no symbols (tree has errors)'; +const SOURCE = `const char* kTemplate = R"FILE_TEMPLATE_V1( +struct Ignored { int v; }; +)FILE_TEMPLATE_V1"; + +int after_the_raw_string(int x) { + return x + 1; +} +`; + +describe('CLI parse warnings (#1522)', () => { + let root: string; + + beforeEach(() => { + root = fs.mkdtempSync(path.join(os.tmpdir(), 'codegraph-parse-warning-')); + }); + + afterEach(() => { + fs.rmSync(root, { recursive: true, force: true }); + }); + + function run(args: string[]) { + const result = spawnSync(process.execPath, [BIN, ...args], { + cwd: root, + encoding: 'utf-8', + timeout: 20_000, + env: { + ...process.env, + CODEGRAPH_NO_DAEMON: '1', + CODEGRAPH_WASM_RELAUNCHED: '1', + CODEGRAPH_TELEMETRY: '0', + NO_COLOR: '1', + }, + }); + return { status: result.status, out: (result.stdout ?? '') + (result.stderr ?? '') }; + } + + it('shows a collapsed parse without failing, then stays quiet after a healthy re-index', () => { + const sourcePath = path.join(root, 'min.cpp'); + fs.writeFileSync(sourcePath, SOURCE); + + const collapsed = run(['init', '--yes']); + expect(collapsed.status, collapsed.out).toBe(0); + expect(collapsed.out).toContain('Indexed 1 files'); + expect(collapsed.out).toContain(`min.cpp: ${COLLAPSE_WARNING}`); + + fs.writeFileSync(sourcePath, SOURCE.replaceAll('FILE_TEMPLATE_V1', 'FILE_TEMPLATE_V')); + const healthy = run(['index']); + expect(healthy.status, healthy.out).toBe(0); + expect(healthy.out).not.toContain(COLLAPSE_WARNING); + + const query = run(['query', 'after_the_raw_string']); + expect(query.status, query.out).toBe(0); + expect(query.out).toMatch(/function\s+after_the_raw_string/); + }, 30_000); +}); diff --git a/__tests__/cpp-raw-string-delimiter-haserror.test.ts b/__tests__/cpp-raw-string-delimiter-haserror.test.ts new file mode 100644 index 0000000..04166ef --- /dev/null +++ b/__tests__/cpp-raw-string-delimiter-haserror.test.ts @@ -0,0 +1,77 @@ +import { beforeAll, describe, expect, it } from 'vitest'; +import { extractFromSource } from '../src/extraction'; +import { getParser, initGrammars, loadGrammarsForLanguages } from '../src/extraction/grammars'; + +function rawStringSource(delimiter: string): string { + return `const char* kTemplate = R"${delimiter}( +struct Ignored { int v; }; +)${delimiter}"; + +int after_the_raw_string(int x) { + return x + 1; +} +`; +} + +describe('C++ raw-string delimiter parse collapse (#1522)', () => { + beforeAll(async () => { + await initGrammars(); + await loadGrammarsForLanguages(['cpp', 'c']); + }); + + it('warns when a legal 16-character delimiter swallows every symbol', () => { + const result = extractFromSource('min.cpp', rawStringSource('FILE_TEMPLATE_V1')); + + // The vendored tree-sitter-cpp scanner currently rejects the standard's + // maximum delimiter length, consuming the following function as ERROR. + expect(result.nodes.filter((n) => n.kind === 'function')).toEqual([]); + expect(result.nodes.map((n) => n.kind)).toEqual(['file']); + expect(result.errors).toEqual([ + { + message: + 'min.cpp: parse produced no symbols (tree has errors) — ' + + 'the file is indexed but contributes nothing to the graph', + severity: 'warning', + code: 'parse_error', + }, + ]); + }); + + it('extracts the function after a 15-character delimiter without warning', () => { + const result = extractFromSource('min.cpp', rawStringSource('FILE_TEMPLATE_V')); + + expect(result.nodes.filter((n) => n.kind === 'function').map((n) => n.name)) + .toEqual(['after_the_raw_string']); + expect(result.errors).toEqual([]); + }); + + it.each(['min.cpp', 'min.c', 'min.h'])('does not warn on a healthy include-only %s', (filePath) => { + const result = extractFromSource(filePath, '#include \n#include \n'); + + expect(result.nodes.filter((n) => n.kind !== 'file' && n.kind !== 'import')).toEqual([]); + expect(result.errors).toEqual([]); + }); + + it('does not warn on a healthy empty file with zero symbols', () => { + const result = extractFromSource('empty.cpp', ''); + + expect(result.nodes.map((n) => n.kind)).toEqual(['file']); + expect(result.errors).toEqual([]); + }); + + it('does not warn on parse errors when a function survives', () => { + const source = 'int before_the_raw_string() { return 0; }\n' + rawStringSource('FILE_TEMPLATE_V1'); + const tree = getParser('cpp')!.parse(source)!; + try { + expect(tree.rootNode.hasError).toBe(true); + } finally { + tree.delete(); + } + + const result = extractFromSource('min.cpp', source); + + expect(result.nodes.filter((n) => n.kind === 'function').map((n) => n.name)) + .toEqual(['before_the_raw_string']); + expect(result.errors).toEqual([]); + }); +}); diff --git a/src/bin/codegraph.ts b/src/bin/codegraph.ts index fdbc32b..750cbb3 100644 --- a/src/bin/codegraph.ts +++ b/src/bin/codegraph.ts @@ -378,6 +378,7 @@ type IndexResult = { */ function printIndexResult(clack: typeof import('@clack/prompts'), result: IndexResult, projectPath?: string): void { const hasErrors = result.filesErrored > 0; + const parseWarnings = result.errors.filter((e) => e.code === 'parse_error' && e.severity === 'warning'); // Surface non-file-level failures (e.g. lock-acquisition failure // when another indexer is running) before the file-count branches. @@ -403,6 +404,10 @@ function printIndexResult(clack: typeof import('@clack/prompts'), result: IndexR clack.log.success(`Indexed ${formatNumber(result.filesIndexed)} files`); } clack.log.info(`${formatNumber(result.nodesCreated)} nodes, ${formatNumber(result.edgesCreated)} edges in ${formatDuration(result.durationMs)}`); + // Warning-only parse failures keep indexing successful, but must be visible. + for (const warning of parseWarnings) { + clack.log.warn(warning.message); + } // A PARTIAL index (files silently dropped mid-pipeline) must not pass // as a clean run — it's the difference between "indexed the repo" and // "indexed most of the repo, quietly". Only the completeness diff --git a/src/extraction/tree-sitter.ts b/src/extraction/tree-sitter.ts index cedf771..8e5784a 100644 --- a/src/extraction/tree-sitter.ts +++ b/src/extraction/tree-sitter.ts @@ -584,6 +584,18 @@ export class TreeSitterExtractor { if (packageNodeId) this.nodeStack.pop(); this.nodeStack.pop(); + + // hasError is routine for several grammars; warn only when no symbols survived. + const symbolCount = this.nodes.filter((n) => n.kind !== 'file').length; + if (this.tree?.rootNode.hasError && symbolCount === 0) { + this.errors.push({ + message: + `${this.filePath}: parse produced no symbols (tree has errors) — ` + + `the file is indexed but contributes nothing to the graph`, + severity: 'warning', + code: 'parse_error', + }); + } } catch (error) { const msg = error instanceof Error ? error.message : String(error);