Skip to content
Closed
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
2 changes: 2 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -21,6 +21,8 @@ and adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).

### Fixes

- (fork) A `calls` edge created by electing one of SEVERAL same-named methods for a bare-name ref (file/directory proximity, no receiver typing) now says so: its metadata carries `methodCandidates: K`. Set `CODEGRAPH_STRICT_METHOD_RESOLUTION=1` to decline such elections instead — the ref stays in `unresolved_refs` rather than becoming a confident wrong edge (Rust extraction records method calls bare, and `(*uptr).assume_init_mut().queue.init()` resolved to a same-directory `init` on an unrelated type). Default resolution behavior is unchanged.

- C, C++, Objective-C and Rust unions are now indexed as first-class `union` nodes. A `union` declaration previously produced no symbol at all, so it never appeared in search or `codegraph_explore`, and anything attached to it disappeared with it — in Rust, every `impl SomeTrait for MyUnion` lost its edge, the methods from that impl were left pointing at a type the graph did not contain, and asking which types implement a trait quietly skipped the union ones. A union-shaped dispatch table in C now resolves its function pointers like a struct-shaped one. A `typedef union { … } Name;` in C keeps the typedef's name and is no longer mistaken for a plain type alias. Thanks @ctype-lab. Re-index after upgrading to pick up unions in existing projects. (#1515)

- A long-lived index no longer drifts away from what a fresh `codegraph index` would produce. When a file gained or lost a symbol, references to that name in files the sync never touched kept pointing at the definition that was correct before the change, and — because nothing distinguished two same-named definitions — the winner could come down to the order files happened to be written, which differs between a full index and a sync. On this project's own repository, replaying 80 commits through `sync` left 5.7% of connections wrong; it is now 1.3%, and the wrong-answers-still-being-asserted half drops by 99.7%. Since call edges are what flow questions follow and what `codegraph_explore` ranks files by, this quietly degraded answers as an index aged, with nothing to indicate it. Syncing is unchanged in speed, and an edit that only changes a function's body does no extra work at all. Set `CODEGRAPH_NO_REBIND=1` to opt out.
Expand Down
3 changes: 2 additions & 1 deletion FORK.md
Original file line number Diff line number Diff line change
Expand Up @@ -15,13 +15,14 @@ Upstream shipped the C macro-attribute extraction fix (issue #1211, PR #1311) in
v1.5.0, so the base carries it natively; the fork no longer needs its own patch
for it.

**Patches:** one, listed below. Apart from it the tree matches the pinned base, so
**Patches:** two, listed below. Apart from them the tree matches the pinned base, so
the fork stays cheap to re-sync. Every patch lives as a merged pull request here
and must be **re-applied on each upstream sync** — if a merge drops one, this list
is what catches it.

| File | Patch |
|------|-------|
| `src/resolution/name-matcher.ts` (+ `resolution/types.ts`, `resolution/index.ts`) | Bare-name refs elected among SEVERAL same-named METHODS by proximity are now labeled and optionally declined. Method dispatch is decided by the receiver's type, which a bare ref has lost — Rust extraction records every method call bare, so `(*uptr).assume_init_mut().queue.init()` resolved to the neighboring `ProcessManager::init` instead of `StaticLinkedList::init`, and FM-Agent's verification pipeline reasoned about the wrong function. Default behavior is unchanged (the election is what makes a monorepo's per-app same-named services resolve per app, #764) but the edge metadata now carries `methodCandidates: K`, so a type-aware consumer knows the edge was a guess among K and can re-check it. `CODEGRAPH_STRICT_METHOD_RESOLUTION=1` declines these elections instead (the ref stays unresolved) for consumers that derive facts from `calls` edges and re-add what their own typing can prove — FM-Agent indexes with it set. Unique names and receiver-bearing refs are unaffected in both modes. Test: `__tests__/bare-name-method-ambiguity.test.ts`. |
| `src/extraction/index.ts` | `fm_agent` added to `DEFAULT_IGNORE_DIRS`. FM-Agent writes its work directory into the project it analyses, holding one copy of every function it extracts plus the scripts staged to produce them, so indexing it lists each function twice and mixes tool code in with project code. Upstream deliberately keeps names that could be real source out of that list, so this stays fork-only; a project that does own an `fm_agent/` directory opts back in with a `.gitignore` negation (`!fm_agent/`). |

**Version marker:** `codegraph --version` → `1.5.0-fmagent.N` identifies a build
Expand Down
97 changes: 97 additions & 0 deletions __tests__/bare-name-method-ambiguity.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,97 @@
/**
* A bare-name ref must not be fuzzy-matched onto one of SEVERAL same-named
* METHODS (fork patch): method dispatch is decided by the receiver's type,
* which a bare ref has lost, and directory proximity actively prefers a
* same-module wrong candidate. Modeled on the case that motivated it: Rust's
* `(*uptr).assume_init_mut().queue.init()` extracted as plain `init` in a
* codebase with eight `init` methods resolved to the neighbor
* `ProcessManager::init` instead of `StaticLinkedList::init`.
*/

import { describe, it, expect } from 'vitest';
import { matchReference } from '../src/resolution/name-matcher';
import type { UnresolvedRef, ResolutionContext } from '../src/resolution/types';
import { Node } from '../src/types';

function node(id: string, kind: Node['kind'], name: string, filePath: string): Node {
return {
id, kind, name,
qualifiedName: `${filePath}::${name}`,
filePath, language: 'rust',
startLine: 1, endLine: 5, startColumn: 0, endColumn: 0,
updatedAt: Date.now(),
} as Node;
}

function contextFor(nodes: Node[]): ResolutionContext {
return {
getNodesInFile: (f: string) => nodes.filter((n) => n.filePath === f),
getNodesByName: (name: string) => nodes.filter((n) => n.name === name),
getAllNodes: () => nodes,
getNodesByQualifiedName: (qn: string) => nodes.filter((n) => n.qualifiedName === qn),
getNodesByLowerName: (name: string) =>
nodes.filter((n) => n.name.toLowerCase() === name.toLowerCase()),
readFile: () => null,
getImportsForFile: () => [],
} as unknown as ResolutionContext;
}

function bareCall(name: string, filePath: string): UnresolvedRef {
return {
id: 1, fromNodeId: 'fn:caller', referenceName: name, referenceKind: 'calls',
filePath, line: 115, column: 8, language: 'rust',
} as unknown as UnresolvedRef;
}

describe('bare-name refs onto ambiguous methods', () => {
const methods = [
node('m1', 'method', 'init', 'process_manager/impl_base.rs'),
node('m2', 'method', 'init', 'slinkedlist/spec_impl_u.rs'),
node('m3', 'method', 'init', 'memory_manager/root_table.rs'),
];

it('default mode: still elects, but says it guessed (methodCandidates)', () => {
delete process.env.CODEGRAPH_STRICT_METHOD_RESOLUTION;
const ref = bareCall('init', 'process_manager/endpoint_util_t.rs');
const result = matchReference(ref, contextFor(methods));
expect(result).not.toBeNull();
expect(result!.methodCandidates).toBe(3);
});

it('strict mode declines instead of electing the same-directory neighbor', () => {
process.env.CODEGRAPH_STRICT_METHOD_RESOLUTION = '1';
try {
const ref = bareCall('init', 'process_manager/endpoint_util_t.rs');
const result = matchReference(ref, contextFor(methods));
expect(result).toBeNull();
} finally {
delete process.env.CODEGRAPH_STRICT_METHOD_RESOLUTION;
}
});

it('strict mode still resolves a UNIQUE method name', () => {
process.env.CODEGRAPH_STRICT_METHOD_RESOLUTION = '1';
try {
const one = [methods[0]!];
const ref = bareCall('init', 'process_manager/endpoint_util_t.rs');
const result = matchReference(ref, contextFor(one));
expect(result).not.toBeNull();
expect(result!.targetNodeId).toBe('m1');
expect(result!.methodCandidates).toBeUndefined();
} finally {
delete process.env.CODEGRAPH_STRICT_METHOD_RESOLUTION;
}
});

it('strict mode still matches when candidates include a free function', () => {
process.env.CODEGRAPH_STRICT_METHOD_RESOLUTION = '1';
try {
const mixed = [...methods, node('f1', 'function', 'init', 'process_manager/util.rs')];
const ref = bareCall('init', 'process_manager/endpoint_util_t.rs');
const result = matchReference(ref, contextFor(mixed));
expect(result).not.toBeNull();
} finally {
delete process.env.CODEGRAPH_STRICT_METHOD_RESOLUTION;
}
});
});
2 changes: 1 addition & 1 deletion package.json
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
{
"name": "@colbymchenry/codegraph",
"version": "1.5.0-fmagent.2",
"version": "1.5.0-fmagent.3",
"description": "Supercharge AI coding agents with semantic code intelligence — surgical context, fewer tool calls, faster answers. 100% local.",
"repository": {
"type": "git",
Expand Down
4 changes: 4 additions & 0 deletions src/resolution/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -1119,6 +1119,10 @@ export class ReferenceResolver {
// tooling label "callback registration" and lets validation diff
// exactly the edges this feature added.
...(ref.original.referenceKind === 'function_ref' ? { fnRef: true } : {}),
// A bare-name ref elected among K same-named METHODS by proximity —
// a heuristic guess a type-aware consumer may want to re-check (see
// ResolvedRef.methodCandidates / CODEGRAPH_STRICT_METHOD_RESOLUTION).
...(ref.methodCandidates ? { methodCandidates: ref.methodCandidates } : {}),
},
};
});
Expand Down
30 changes: 30 additions & 0 deletions src/resolution/name-matcher.ts
Original file line number Diff line number Diff line change
Expand Up @@ -433,6 +433,35 @@ export function matchByExactName(
return null;
}

// A METHOD's identity is decided by its receiver's type, and a bare-name
// ref means the resolver has lost (or never had) that receiver. With SEVERAL
// same-named methods, file/directory proximity is a heuristic stand-in: it
// is what makes a monorepo's per-app `UserService.findAll` resolve to the
// right app (#764), and it is also what resolved Rust's
// `(*uptr).assume_init_mut().queue.init()` — extracted as plain `init` — to
// the neighboring `ProcessManager::init` instead of `slinkedlist`'s
// `StaticLinkedList::init`, feeding a verification pipeline a wrong edge.
// Which trade is right depends on the consumer, so both are supported:
// * default: elect by proximity as before, but SAY SO — the edge metadata
// carries `methodCandidates: K`, so a consumer that can re-check the
// receiver's type knows this edge was a guess among K;
// * CODEGRAPH_STRICT_METHOD_RESOLUTION=1: decline instead — the ref stays
// unresolved ("unknown" rather than a confident wrong answer), for
// consumers that derive facts from `calls` edges and have their own
// type-aware resolution to re-add what they can prove.
// A UNIQUE name still resolves via the single-candidate branch above, and
// receiver-bearing refs resolve through matchMethodCall's typed strategies —
// both unchanged in both modes.
const sameLangCandidates = candidates.filter((c) => c.language === ref.language);
const methodPool = sameLangCandidates.length > 0 ? sameLangCandidates : candidates;
const ambiguousMethods =
methodPool.length > 1 && methodPool.every((c) => c.kind === 'method')
? methodPool.length
: 0;
if (ambiguousMethods && process.env.CODEGRAPH_STRICT_METHOD_RESOLUTION === '1') {
return null;
}

// Multiple matches - try to narrow down
const bestMatch = findBestMatch(ref, candidates, context);
if (bestMatch) {
Expand All @@ -444,6 +473,7 @@ export function matchByExactName(
targetNodeId: bestMatch.id,
confidence,
resolvedBy: 'exact-match',
...(ambiguousMethods ? { methodCandidates: ambiguousMethods } : {}),
};
}

Expand Down
6 changes: 6 additions & 0 deletions src/resolution/types.ts
Original file line number Diff line number Diff line change
Expand Up @@ -43,6 +43,12 @@ export interface ResolvedRef {
confidence: number;
/** How it was resolved */
resolvedBy: 'exact-match' | 'import' | 'qualified-name' | 'framework' | 'fuzzy' | 'instance-method' | 'file-path' | 'function-ref';
/** Set when a bare-name ref was elected among K same-named METHODS by
* file/directory proximity — a heuristic guess, not a typed resolution.
* Consumers that derive facts from `calls` edges can re-check these with
* their own receiver typing (or index under
* CODEGRAPH_STRICT_METHOD_RESOLUTION=1, which declines them instead). */
methodCandidates?: number;
}

/**
Expand Down