Просмотр исходного кода

fix(extraction): warn when parse errors leave no symbols (#1522) (#1799)

Co-authored-by: Colby McHenry <colbymchenry@users.noreply.github.com>
Colby Mchenry 22 часов назад
Родитель
Сommit
7b339373b4

+ 2 - 0
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 <path>` 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)

+ 63 - 0
__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);
+});

+ 77 - 0
__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 <stdio.h>\n#include <stdlib.h>\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([]);
+  });
+});

+ 5 - 0
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

+ 12 - 0
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);