Merge remote-tracking branch 'origin/main' into fix/kotlin-property-initializer-walk

# Conflicts:
#	CHANGELOG.md
This commit is contained in:
danusha2345
2026-09-09 00:56:48 +03:00
12 changed files with 306 additions and 10 deletions
+5
View File
@@ -137,6 +137,10 @@ 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)
- `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)
@@ -230,6 +234,7 @@ and adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
- Kotlin `init { }` blocks and destructuring declarations no longer swallow their code: `init { val cfg = load() }` and `val (a, b) = makePair()` contributed no call edge at all, and now attribute to the enclosing class or file.
- A Kotlin property's accessor body now belongs to the property whichever line it is written on, instead of being dropped (same line) or handed to the enclosing class (own line).
- Kotlin properties that hold a lambda, a SAM callback or an anonymous object — `private val frameListener = CameraFrameListener { … }`, the way Android and MSDK callbacks are almost always declared — now contribute call edges. Previously everything inside such an initializer was dropped, so a function reached only through one of these callbacks looked like it had no callers at all and its blast radius came back far too small. Delegated properties (`by lazy { … }`) and plain initializers (`val x = compute()`) were affected the same way and are fixed too. Re-index with `codegraph index -f` after upgrading to pick up the new edges.
- 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)
+64
View File
@@ -0,0 +1,64 @@
/**
* `codegraph index <path>` rebuilds <path>, never an ancestor (#1524).
*
* The command used to resolve an uninitialized <path> 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 <path> (#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);
});
});
+63
View File
@@ -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);
});
@@ -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([]);
});
});
+29
View File
@@ -1324,6 +1324,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 {
+2 -1
View File
@@ -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
+9 -4
View File
@@ -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) {
@@ -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);
+18 -2
View File
@@ -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
@@ -791,7 +796,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 <path> 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 +815,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);
}
+3
View File
@@ -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'],
+13
View File
@@ -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;
+21 -1
View File
@@ -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);
@@ -1962,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);