From 4fd816b610a6fbf469dbdff8d2945c78e5dcc01a Mon Sep 17 00:00:00 2001 From: Colby Mchenry Date: Tue, 8 Sep 2026 16:00:58 -0500 Subject: [PATCH 1/3] fix(cli): index never rebuilds an ancestor instead (#1524) (#1797) An explicit path names the project to rebuild; it is not a hint to go looking for one. resolveProjectPath's upward walk is right for a query run from a subdirectory, but for a full re-index it silently rebuilt the nearest initialized parent's graph when had no index of its own. Refuse with the parent's path and the way to index itself; a bare `codegraph index` still resolves from cwd as before. Fixes #1524 Co-authored-by: danusha2345 --- CHANGELOG.md | 2 + __tests__/cli-index-explicit-path.test.ts | 64 +++++++++++++++++++++++ src/bin/codegraph.ts | 15 +++++- 3 files changed, 79 insertions(+), 2 deletions(-) create mode 100644 __tests__/cli-index-explicit-path.test.ts diff --git a/CHANGELOG.md b/CHANGELOG.md index e390d566..d1173042 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 +- `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) - `codegraph_explore` now re-serves source to fresh subagents and after context compaction, with cross-call dedup available only through an explicit `CODEGRAPH_EXPLORE_DEDUP=1` opt-in; thanks @danusha2345. (#1620, #1624) diff --git a/__tests__/cli-index-explicit-path.test.ts b/__tests__/cli-index-explicit-path.test.ts new file mode 100644 index 00000000..c880716a --- /dev/null +++ b/__tests__/cli-index-explicit-path.test.ts @@ -0,0 +1,64 @@ +/** + * `codegraph index ` rebuilds , never an ancestor (#1524). + * + * The command used to resolve an uninitialized upward to the nearest + * initialized parent and rebuild THAT under a normal "Done" — so + * `codegraph index child` from a monorepo re-indexed the whole container and + * never said so. An explicit path that is not initialized is now an error that + * names the ancestor it would have picked. + */ +import { describe, it, expect, beforeAll, afterAll } from 'vitest'; +import { spawnSync } from 'child_process'; +import * as fs from 'fs'; +import * as os from 'os'; +import * as path from 'path'; +import { CodeGraph } from '../src'; + +const BIN = path.resolve(__dirname, '../dist/bin/codegraph.js'); + +function run(cwd: string, args: string[]) { + const r = spawnSync(process.execPath, [BIN, ...args], { + cwd, + encoding: 'utf-8', + env: { ...process.env, CODEGRAPH_NO_DAEMON: '1', CODEGRAPH_WASM_RELAUNCHED: '1', NO_COLOR: '1' }, + }); + return { status: r.status, out: (r.stdout ?? '') + (r.stderr ?? '') }; +} + +describe('codegraph index (#1524)', () => { + let root: string; + let parent: string; + let child: string; + + beforeAll(async () => { + root = fs.mkdtempSync(path.join(os.tmpdir(), 'codegraph-index-path-')); + parent = path.join(root, 'parent'); + child = path.join(parent, 'child'); + fs.mkdirSync(child, { recursive: true }); + fs.writeFileSync(path.join(parent, 'p.py'), 'def parent_only():\n return 1\n'); + fs.writeFileSync(path.join(child, 'c.py'), 'def child_only():\n return 2\n'); + const cg = CodeGraph.initSync(parent); + await cg.indexAll(); + cg.close(); + }); + + afterAll(() => { + fs.rmSync(root, { recursive: true, force: true }); + }); + + it('refuses an explicit path that has no index of its own, naming the ancestor it would have rebuilt', () => { + const before = fs.statSync(path.join(parent, '.codegraph', 'codegraph.db')).mtimeMs; + const r = run(root, ['index', child, '--quiet']); + expect(r.status).toBe(1); + expect(r.out).toContain(`not initialized in ${child}`); + expect(r.out).toContain(parent); + // The parent's index was not touched. + expect(fs.statSync(path.join(parent, '.codegraph', 'codegraph.db')).mtimeMs).toBe(before); + expect(fs.existsSync(path.join(child, '.codegraph'))).toBe(false); + }); + + it('rebuilds the explicit path when it is initialized, and a bare `index` still resolves upward from a subdirectory', () => { + expect(run(root, ['index', parent, '--quiet']).status).toBe(0); + expect(run(child, ['index', '--quiet']).status).toBe(0); + }); +}); diff --git a/src/bin/codegraph.ts b/src/bin/codegraph.ts index 3642818f..fdbc32b5 100644 --- a/src/bin/codegraph.ts +++ b/src/bin/codegraph.ts @@ -791,7 +791,13 @@ program .option('-q, --quiet', 'Suppress progress output') .option('-v, --verbose', 'Show detailed worker lifecycle and memory info') .action(async (pathArg: string | undefined, options: { force?: boolean; quiet?: boolean; verbose?: boolean }) => { - const projectPath = resolveProjectPath(pathArg); + // An EXPLICIT path names the project to rebuild — it is never a hint to go + // looking for one. resolveProjectPath walks up to the nearest initialized + // ancestor, which is right for `codegraph query` run from a subdirectory, + // but for a full re-index it silently rebuilt the parent's graph under a + // normal "Done" when had no index of its own (#1524). Only a bare + // `codegraph index` (cwd) may resolve upward. + const projectPath = pathArg ? path.resolve(pathArg) : resolveProjectPath(); try { // Don't (re)index your home directory / a filesystem root (#845). --force @@ -804,7 +810,12 @@ program if (!isInitialized(projectPath)) { error(`CodeGraph not initialized in ${projectPath}`); - info('Run "codegraph init" first'); + const ancestor = pathArg ? resolveProjectPath(pathArg) : projectPath; + if (ancestor !== projectPath) { + info(`The nearest initialized project is ${ancestor} — pass that path to rebuild it, or run "codegraph init" in ${projectPath} to index it on its own.`); + } else { + info('Run "codegraph init" first'); + } process.exit(1); } From 7b339373b40c26a6791b42abd82cf8f41165996a Mon Sep 17 00:00:00 2001 From: Colby Mchenry Date: Tue, 8 Sep 2026 16:17:28 -0500 Subject: [PATCH 2/3] 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 d1173042..076e1a68 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 00000000..e4aa7035 --- /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 00000000..04166ef4 --- /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 fdbc32b5..750cbb3b 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 cedf7714..8e5784af 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); From 71d049cd2804e9be06e365d562ae192cf6a5c54c Mon Sep 17 00:00:00 2001 From: Colby Mchenry Date: Tue, 8 Sep 2026 16:53:08 -0500 Subject: [PATCH 3/3] =?UTF-8?q?fix(rust):=20index=20unit=20structs=20?= =?UTF-8?q?=E2=80=94=20bodiless=20is=20a=20definition,=20not=20a=20forward?= =?UTF-8?q?=20decl=20(#1800)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Land upstream PR #1514 for issue #1513 by cherry-picking ctype_lab's a94c9dc94eee348bf63c7e2dd567c0b43577678a. Keep allowBodilessStruct as a Rust-only opt-in, with matching behavior in the wasm/TypeScript walker and native kernel. Create the node before checking for a body and walk members only when present. Resolve against main's shared struct/union walker while retaining its stack guard, kinds, fields, and existing impl-receiver fixes. Verified FAIL to PASS on Linux x64 with Node 22.19.0 after rebuilding via tsc, copy-assets, and build:kernel. The identical fixture on main 7b339373 had two structs and two implements edges; fresh wasm (CODEGRAPH_KERNEL=0) and native indexes now have three of each. UnitStruct and its Greet implements edge are recovered; tuple and brace structs remain intact. The native run loaded the rebuilt kernel and completed without fallback. Focused extraction.test.ts and kernel-rustlang-parity.test.ts runs: 645 tests passed with the kernel disabled, and 645 with it enabled; all three parity tests ran in each configuration, with no skips. (cherry picked from commit a94c9dc94eee348bf63c7e2dd567c0b43577678a) Co-authored-by: ctype_lab Co-authored-by: Claude Opus 5 --- CHANGELOG.md | 1 + __tests__/extraction.test.ts | 29 +++++++++++++++++++ __tests__/kernel-rustlang-parity.test.ts | 3 +- codegraph-kernel/src/rustlang.rs | 13 ++++++--- .../design/rust-lang-kernel-port-checklist.md | 4 +-- src/extraction/languages/rust.ts | 3 ++ src/extraction/tree-sitter-types.ts | 13 +++++++++ src/extraction/tree-sitter.ts | 10 ++++++- 8 files changed, 68 insertions(+), 8 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 076e1a68..427599cf 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -225,6 +225,7 @@ and adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html). #### Symbols, tests and the viewer +- Rust unit structs (`struct Unit;`) and their trait implementation relationships now appear in the graph after re-indexing. (#1513, #1514) - Imports from Node built-ins or npm packages no longer connect to unrelated type members with matching names; re-index after upgrading to clear existing false dependencies. Thanks @ctype-lab. (#1537) - Inheritance relationships no longer attach external Rust or npm supertypes to unrelated local symbols with the same name, including in Svelte, Vue and Astro components; re-index after upgrading to clear existing false relationships. Thanks @ctype-lab. (#1536) diff --git a/__tests__/extraction.test.ts b/__tests__/extraction.test.ts index 02c069ce..83593e8e 100644 --- a/__tests__/extraction.test.ts +++ b/__tests__/extraction.test.ts @@ -1252,6 +1252,35 @@ pub struct User { expect(structNode?.name).toBe('User'); }); + it('should extract unit and tuple structs, not just brace structs', () => { + // A unit struct has no body field, but it IS a complete definition — + // Rust has no forward declarations. Skipping it dropped the type and + // every `impl Trait for UnitStruct` edge with it. + const code = ` +pub struct Unit; +pub struct Tuple(pub u32); +pub struct Brace { pub x: u32 } +`; + const result = extractFromSource('shapes.rs', code); + + const structs = result.nodes.filter((n) => n.kind === 'struct').map((n) => n.name).sort(); + expect(structs).toEqual(['Brace', 'Tuple', 'Unit']); + }); + + it('should link impl Trait for a unit struct', () => { + const code = ` +pub struct Unit; +pub trait Greet { fn hi(&self) -> String; } +impl Greet for Unit { fn hi(&self) -> String { "unit".into() } } +`; + const result = extractFromSource('greet.rs', code); + + const unit = result.nodes.find((n) => n.kind === 'struct' && n.name === 'Unit'); + expect(unit).toBeDefined(); + const trait = result.nodes.find((n) => n.kind === 'trait' && n.name === 'Greet'); + expect(trait).toBeDefined(); + }); + it('should extract trait declarations', () => { const code = ` pub trait Repository { diff --git a/__tests__/kernel-rustlang-parity.test.ts b/__tests__/kernel-rustlang-parity.test.ts index 07c897b5..7ba73fed 100644 --- a/__tests__/kernel-rustlang-parity.test.ts +++ b/__tests__/kernel-rustlang-parity.test.ts @@ -5,7 +5,8 @@ * SAME ExtractionResult as the wasm TreeSitterExtractor — nodes, edges, and * unresolved refs compared as canonicalized multisets — over the checked-in * torture fixture (torture.rs: impl/trait quirks incl. generic / lifetime / - * reference / scoped / generic-trait impl receivers (#1588), unit-struct skip, phantom + * reference / scoped / generic-trait impl receivers (#1588), unit structs + * (a bodiless struct IS a definition — both walkers mint a node), phantom * const identifiers, use-binding refs incl. nested groups + wildcard-emits- * nothing, chained-call re-encode, turbofish, Rocket route macros body-only, * fn-ref shapes, value-ref shadowing, attribute-broken docstrings, dead-code diff --git a/codegraph-kernel/src/rustlang.rs b/codegraph-kernel/src/rustlang.rs index 3e880fa1..f078cf9a 100644 --- a/codegraph-kernel/src/rustlang.rs +++ b/codegraph-kernel/src/rustlang.rs @@ -20,8 +20,7 @@ //! kind is always `variable`, no signature, and EVERY direct `identifier` //! child mints a node (`const MAX: u32 = OTHER;` → two nodes, `MAX` + the //! phantom `OTHER`). Top-level initializer values are never body-walked. -//! - Unit structs (`struct Unit;`, no body field) mint NO node; `mod_item` -//! mints no module node and adds no QN prefix. +//! - `mod_item` mints no module node and adds no QN prefix. //! - Chained-call re-encode is scoped_identifier-gated (`Foo::new().bar()` → //! `Foo::new().bar`); a call through a field of the enclosing type keeps //! the owner-field shape (`self.inner.run()` → `self.inner.run`, #1585); @@ -590,10 +589,12 @@ impl<'t> Walker<'t> { self.stack.pop(); } - /// Extract a Rust struct or union with a body; unit structs remain skipped. + /// Extract a Rust struct or union — the body field is OPTIONAL. A unit + /// struct (`struct U;`) has no body and is still a complete definition, + /// so it mints a node with no members; tuple structs' ordered_field_declaration_list + /// is a body. Mirrors the TS reference's `allowBodilessStruct`. fn extract_aggregate(&mut self, node: Node<'t>, kind: &'static str) { stack_guard!(); - let Some(body) = node.child_by_field_name("body") else { return }; let name = self.extract_name(node); let extra = Extra { docstring: preceding_docstring(node, self.src), @@ -603,6 +604,10 @@ impl<'t> Walker<'t> { let Some(row) = self.create_node(kind, &name, node, extra) else { return }; self.extract_inheritance(node, row); + // Unit structs have no body to walk — the node itself is the whole + // definition. + let Some(body) = node.child_by_field_name("body") else { return }; + self.stack.push(Scope { row, kind, name }); for i in 0..body.named_child_count() { if let Some(c) = body.named_child(i) { diff --git a/docs/design/rust-lang-kernel-port-checklist.md b/docs/design/rust-lang-kernel-port-checklist.md index 831e3ca1..6b44e6ed 100644 --- a/docs/design/rust-lang-kernel-port-checklist.md +++ b/docs/design/rust-lang-kernel-port-checklist.md @@ -140,7 +140,7 @@ undefined; **no isConst means `const_item`/`static_item` extract as kind |---|---|---| | `function_item` (top level) | functionTypes, tree-sitter.ts:994 → extractFunction:1517 | not inside class-like at file scope → extractFunction; **first line of extractFunction (1522): if getReceiverType returns a value → extractMethod instead** (this is how impl-block fns become methods — impl_item does NOT push a scope) | | `function_signature_item` | same | in a trait body (trait pushed, class-like) → extractMethod; no `body` field → no body walk | -| `struct_item` | structTypes:1059 → extractStruct:1869 | `body` field required: **unit structs `struct Unit;` have no body → NO node minted** (1876, `record_declaration` exemption is C#-only). Tuple structs have body `ordered_field_declaration_list` → extracted. `field_declaration` children make NO nodes (rust has no fieldTypes) — visitNode recurses into them and finds nothing | +| `struct_item` | structTypes:1059 → extractStruct:1869 | ~~`body` field required: unit structs `struct Unit;` have no body → NO node minted~~ — **superseded: Rust now sets `allowBodilessStruct`, so `struct Unit;` mints a node with no members.** Rust has no forward declarations, so the bodiless skip (meant for C/C++) never applied here; the `record_declaration` exemption is the C# form of the same carve-out. Tuple structs have body `ordered_field_declaration_list` → extracted. `field_declaration` children make NO nodes (rust has no fieldTypes) — visitNode recurses into them and finds nothing | | `enum_item` | enumTypes:1064 → extractEnum:1914 | body `enum_variant_list`; `enum_variant` children → extractEnumMembers:1958 — **`name` field path: one `enum_member` node from `getChildByField(node,'name')`, then return** (variant payload bodies `B(u32)` / `C { x }` are never walked). Non-variant children (e.g. `attribute_item`) → visitNode (no-op) | | `trait_item` | interfaceTypes:1054 → extractInterface:1834 | kind `'trait'` (interfaceKind); extractInheritance sees the `trait_bounds` child (see below); body `declaration_list` children visited with the trait pushed → fn items become methods with QN `Trait::name` via nodeStack | | `impl_item` | dedicated branch:1273-1276 → extractRustImplItem:5690 | emits the implements back-reference (below); **skipChildren stays false** → the `declaration_list` is then visited normally by the loop at 1295 (that's how impl members are reached; impl pushes NOTHING on the nodeStack) | @@ -490,7 +490,7 @@ inner `array_expression`, but `const CB: fn() = handler;` captures nothing ## Gates (per plan §5, no exceptions) - **Torture fixture `torture.rs`** (+ CRLF variant, derived in-memory), pinning - at minimum: unit struct (NO node) / tuple struct / field struct; enum with + at minimum: unit struct (node, no members) / tuple struct / field struct; enum with unit+tuple+struct variants; trait with supertraits incl. a SCOPED one (`fmt::Debug` — dropped) + `function_signature_item` + default method + associated type/const (no node; const value call attributes to trait); diff --git a/src/extraction/languages/rust.ts b/src/extraction/languages/rust.ts index cef4c208..9c471002 100644 --- a/src/extraction/languages/rust.ts +++ b/src/extraction/languages/rust.ts @@ -81,6 +81,9 @@ export const rustExtractor: LanguageExtractor = { methodTypes: ['function_item', 'function_signature_item'], interfaceTypes: ['trait_item'], structTypes: ['struct_item'], + // `struct Unit;` is a unit struct — a complete definition with no body + // field, not a forward declaration. Rust has no forward declarations. + allowBodilessStruct: true, // Unions share struct member syntax and impl attachment, but retain their // distinct semantic kind in the graph. unionTypes: ['union_item'], diff --git a/src/extraction/tree-sitter-types.ts b/src/extraction/tree-sitter-types.ts index 5a4f8d3b..19f749cf 100644 --- a/src/extraction/tree-sitter-types.ts +++ b/src/extraction/tree-sitter-types.ts @@ -187,6 +187,19 @@ export interface LanguageExtractor { * bodiless class IS complete (Kotlin `class Empty`, Scala `case object`). (#1093) */ skipBodilessClass?: boolean; + /** + * Keep a bodiless struct node — it IS a complete definition, not a forward + * declaration. Set only for languages where a bodiless `struct` is complete: + * Rust's unit struct (`struct Unit;`). Leave unset for C/C++, where + * `struct Foo;` is a forward declaration. + * + * Opposite polarity from `skipBodilessClass` (#1093) because the defaults + * differ: a bodiless CLASS is kept unless a language opts into skipping, + * a bodiless STRUCT is skipped unless a language opts into keeping. The + * hardcoded C# `record_declaration` carve-out (#831) is the same situation + * predating this flag. + */ + allowBodilessStruct?: boolean; /** NodeKind to use for interface-like declarations (Rust: 'trait'). Default: 'interface' */ interfaceKind?: NodeKind; diff --git a/src/extraction/tree-sitter.ts b/src/extraction/tree-sitter.ts index 8e5784af..70637825 100644 --- a/src/extraction/tree-sitter.ts +++ b/src/extraction/tree-sitter.ts @@ -1974,8 +1974,16 @@ export class TreeSitterExtractor { // Skip forward declarations and type references (no body = not a definition) // — EXCEPT C# positional records (`record struct M(decimal Amount);`), // complete definitions with no body block. (#831) + // + // `allowBodilessStruct` is the per-language escape hatch for the same + // situation: a bodiless struct that IS a complete definition (Rust's unit + // struct `struct Unit;`). Opposite polarity from `skipBodilessClass` + // (#1093) because the two defaults differ — a bodiless CLASS is kept + // unless a language opts into skipping, a bodiless STRUCT is skipped + // unless a language opts into keeping. const body = getChildByField(node, this.extractor.bodyField); - if (!body && node.type !== 'record_declaration') return; + if (!body && node.type !== 'record_declaration' && !this.extractor.allowBodilessStruct) + return; const name = extractName(node, this.source, this.extractor); const docstring = getPrecedingDocstring(node, this.source);