Skip to content
Open
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
9 changes: 9 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -27,6 +27,15 @@ and adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
- On Windows, the Claude Code prompt hook written by `codegraph install` failed with "command not found" when hooks run through Git Bash, which needs the `.cmd` extension to find the launcher. The installer now writes the platform-correct command, and re-running `codegraph install` (or `codegraph upgrade`) repairs an existing install in place. (#1466)
- Python classes used as values — `return SomeSerializer` from a factory method, `handler = SomeClass` aliases, registry dicts and lists, and classes passed as arguments — now produce reference edges in the graph. Previously these idioms were invisible, so on Django and Django REST Framework projects, asking for a serializer's callers or the impact of editing it missed the views that actually use it. Re-index after upgrading to pick up the new edges. (#1478)
- When a file changed on disk after its last index sync, `codegraph_node` and `codegraph_explore` could return a different symbol's code under the requested name — current file bytes cut at outdated line positions — while presenting it as verbatim, trustworthy source. This hit hardest on projects queried through `projectPath` (for example, sub-projects of a monorepo), which have no live file watcher to flag pending edits. Both tools now verify each file against the index before showing sliced code: an out-of-date file is either shown whole with its full current source, or its code is withheld with a clear "changed on disk" notice — never served as a wrong slice. A fresh re-index restores normal output automatically. Thanks @inth3shadows for the thorough report and verification passes. (#1474)
- Python module-level assignments now contribute the calls their right-hand side makes — `app = FastAPI()`, `ENGINE = create_engine(url)`, `handler = lambda: run()`, a registry dict or list of handlers. Previously everything a module wires up at import time was missing from the graph, so the objects it builds looked unreferenced. Re-index with `codegraph index -f` after upgrading to pick up the new edges.
- Rust `const` and `static` initializers now contribute their calls: `static REGISTRY: Lazy<Cfg> = Lazy::new(|| build())`, `const LEN: usize = compute_len()`. Previously anything a lazily-built singleton or a computed constant called was missing from the graph entirely. Re-index with `codegraph index -f` after upgrading to pick up the new edges.
- Scala `val`/`var` definitions now contribute the calls their initializer makes — `val handler = () => process(msg)`, `val client = buildClient()`, `lazy val engine = start()`. Previously everything on the right-hand side was dropped, which on a val-heavy codebase (SpinalHDL hardware descriptions, Akka wiring) is most of the wiring: a 32-file SpinalHDL project gained 812 references it had been missing. Re-index with `codegraph index -f` after upgrading to pick up the new edges.
- TypeScript and JavaScript module-level declarations now name themselves as the caller of whatever their initializer runs. `const cfg = loadConfig()` recorded the *file* as loadConfig's caller, which is no use for callers or impact; it now records `cfg`. And an object literal that wasn't exported — `const handlers = { onSave: () => persist() }` — was skipped entirely, so nothing inside it reached the graph at all. Re-index with `codegraph index -f` after upgrading to pick up the new edges.
- `codegraph_explore` again lists a dynamic-dispatch link when the same two symbols are also joined by an ordinary call, instead of dropping it from the summary.
- Java fields initialized with a lambda or an anonymous class — `private final Runnable r = () -> doWork();`, `new LocationListener() { … }`, `Parcelable.Creator` — now contribute call edges, and the anonymous class and its overrides become real symbols instead of being invisible. Previously everything inside a field initializer was dropped, so a method reached only from one looked like it had no callers. Re-index with `codegraph index -f` after upgrading to pick up the new edges.
- 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.
- The blast-radius section of `codegraph_explore` flagged "no covering tests found" whenever no test called a symbol directly — falsely branding helpers that tests exercise through their callers as untested (about 40% of flagged symbols in a measured sample). The check now follows caller chains up to 3 hops and reports indirect coverage as "tested via callers"; when nothing is found it states exactly what was checked instead of an unconditional warning. Thanks @inth3shadows for measuring the false-positive rate. (#1475)

## [1.5.0] - 2026-07-21
Expand Down
266 changes: 266 additions & 0 deletions __tests__/extraction.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -931,6 +931,42 @@ const token = getTokenMp();
);
expect(call).toBeDefined();
});

describe('initializer walk is scoped to the declared symbol (#693 for TS/JS)', () => {
const code = `
const eager = load();
const obj = { handler: () => target(), plain: target() };
const list = [() => target()];
export const exported = { handler: () => target() };
`;
const callersOf = (name: string) => {
const result = extractFromSource('app.ts', code);
const byId = new Map(result.nodes.map((n) => [n.id, n]));
return result.unresolvedReferences
.filter((u) => u.referenceKind === 'calls' && u.referenceName === name)
.map((u) => byId.get(u.fromNodeId))
.map((n) => (n ? `${n.kind}:${n.name}` : '?'))
.sort();
};

it("a plain call initializer names the CONSTANT as caller, not the file", () => {
// The walk ran with only the file on the stack, so `load` recorded the
// file as its caller — useless for callers/impact.
expect(callersOf('load')).toEqual(['constant:eager']);
});

it('a non-exported object literal contributes calls (it was skipped outright)', () => {
// `exported`'s members are minted as their own function nodes, so its
// arrow's call comes from `handler`; the non-exported ones attribute to
// the declared constant.
expect(callersOf('target')).toEqual([
'constant:list',
'constant:obj',
'constant:obj',
'function:handler',
]);
});
});
});

describe('File Node Extraction', () => {
Expand Down Expand Up @@ -1019,6 +1055,42 @@ class UserService:
expect(classNode).toBeDefined();
expect(classNode?.name).toBe('UserService');
});

it('walks a module-level assignment initializer scoped to the name (#693 for Python)', () => {
// The assignment minted a node and stopped, so everything a module builds
// at import time — `app = FastAPI()`, `ENGINE = create_engine(url)` — was
// missing from the graph. A tuple target mints no symbol, so its
// right-hand side attributes to the enclosing scope instead of vanishing.
const code = `
def target(): pass
def compute(): return 1

APP = compute()
handler = lambda: target()
MAPPING = {"a": compute()}
first, second = compute(), target()

class K:
ATTR = compute()
`;
const result = extractFromSource('app.py', code);
const byId = new Map(result.nodes.map((n) => [n.id, n]));
const owners = result.unresolvedReferences
.filter((u) => u.referenceKind === 'calls')
.map((u) => {
const n = byId.get(u.fromNodeId);
return `${u.referenceName}<-${n ? `${n.kind}:${n.name}` : '?'}`;
})
.sort();
expect(owners).toEqual([
'compute<-class:K', // a class attribute still rides the class (no node of its own)
'compute<-file:app.py', // the tuple target mints nothing
'compute<-variable:APP',
'compute<-variable:MAPPING',
'target<-file:app.py',
'target<-variable:handler',
]);
});
});

describe('Go Extraction', () => {
Expand Down Expand Up @@ -1174,6 +1246,26 @@ impl Counter {
);
expect(implRefs).toHaveLength(0);
});

it('walks a const/static initializer scoped to the declared symbol (#693 for Rust)', () => {
// The declaration minted a node and stopped, so a handler table, a
// lazily-built singleton or any computed const linked to nothing.
const code = `
const LEN: usize = compute_len();
static REGISTRY: Lazy<Cfg> = Lazy::new(|| build_cfg());
`;
const result = extractFromSource('lib.rs', code);
const byId = new Map(result.nodes.map((n) => [n.id, n]));
const owner = (name: string) => {
const u = result.unresolvedReferences.find(
(r) => r.referenceKind === 'calls' && r.referenceName === name
);
const n = u ? byId.get(u.fromNodeId) : undefined;
return n ? `${n.kind}:${n.name}` : undefined;
};
expect(owner('compute_len')).toBe('variable:LEN');
expect(owner('build_cfg')).toBe('variable:REGISTRY');
});
});

describe('Java Extraction', () => {
Expand Down Expand Up @@ -1337,6 +1429,37 @@ public class Splitter {
);
expect(sepStart, 'override inside the lambda-returned anon class should be a method node').toBeDefined();
});

it('walks a field initializer scoped to the field (#693 for Java)', () => {
// The dispatcher only scanned a field_declaration for function-as-value
// candidates, so a lambda or anonymous class holding the work — the
// Android listener idiom — contributed no call edge and `target` looked
// callerless.
const code = `
package p;
class T {
private final Runnable fieldLambda = () -> target();
private final Runnable anonClass = new Runnable() {
public void run() { target(); }
};
private final int eager = compute();
void directCall() { target(); }
private void target() {}
private static int compute() { return 1; }
}
`;
const result = extractFromSource('T.java', code);
const byId = new Map(result.nodes.map((n) => [n.id, n]));
const callersOf = (name: string) =>
result.unresolvedReferences
.filter((u) => u.referenceKind === 'calls' && u.referenceName === name)
.map((u) => byId.get(u.fromNodeId)?.name)
.sort();

// `run` is the anonymous class's override, itself extracted under the field.
expect(callersOf('target')).toEqual(['directCall', 'fieldLambda', 'run']);
expect(callersOf('compute')).toEqual(['eager']);
});
});

describe('C# Extraction', () => {
Expand Down Expand Up @@ -1940,6 +2063,120 @@ class Bar {
const cls = result.nodes.find((n) => n.kind === 'class' && n.name === 'Bar');
expect(cls?.qualifiedName).toBe('Bar');
});

describe('property initializers are walked, attributed to the property (#693 for Kotlin)', () => {
// The property hook consumes the whole property_declaration subtree, so
// before this the initializer was only scanned for function-as-value
// candidates and every call inside it vanished from the graph. Android/MSDK
// callbacks are declared exactly this way (`private val l = Listener { … }`),
// so anything reached only through one looked like it had no callers at all.
const code = `
package repro

class Repro {
private val fieldLambda: () -> Unit = { target() }
private val samField = Runnable { target() }
private val plain = target()
private val delegated by lazy { target() }
private val anonObject = object : Runnable { override fun run() { target() } }

fun directCall() { target() }
fun lambdaInMethod() { run { target() } }

private fun target() {}
}

object Holder {
val topLevelLambda: () -> Unit = { hit() }
private fun hit() {}
}
`;
const callersOf = (target: string) => {
const result = extractFromSource('Repro.kt', code);
const byId = new Map(result.nodes.map((n) => [n.id, n]));
return result.unresolvedReferences
.filter((u) => u.referenceKind === 'calls' && u.referenceName === target)
.map((u) => byId.get(u.fromNodeId)?.name)
.sort();
};

it('a lambda / SAM / plain / delegated / object initializer calls FROM the property', () => {
// `run` is the anonymous object's override, extracted as its own node
// under `anonObject` — the same shape Go's initializer walk produces.
expect(callersOf('target')).toEqual([
'delegated',
'directCall',
'fieldLambda',
'lambdaInMethod',
'plain',
'run',
'samField',
]);
});

it('a property in an `object` singleton is a caller too', () => {
expect(callersOf('hit')).toEqual(['topLevelLambda']);
});

it('an accessor body belongs to its property, written on either line', () => {
// `val x: T get() = …` nests the accessor UNDER the declaration; written
// on its own line the grammar makes it a following SIBLING instead. Both
// used to lose their calls (the nested one) or hand them to the enclosing
// class (the sibling); both now attribute to the property.
const src = `
package p

class C {
val sameLine: Int get() = compute()
val nextLine: Int
get() = compute()
var written: Int = 0
set(v) { store(v) }
private fun compute(): Int = 1
private fun store(v: Int) {}
}
`;
const result = extractFromSource('C.kt', src);
const byId = new Map(result.nodes.map((n) => [n.id, n]));
const ownersOf = (name: string) =>
result.unresolvedReferences
.filter((u) => u.referenceKind === 'calls' && u.referenceName === name)
.map((u) => {
const n = byId.get(u.fromNodeId);
return n ? `${n.kind}:${n.name}` : '?';
})
.sort();
expect(ownersOf('compute')).toEqual(['field:nextLine', 'field:sameLine']);
expect(ownersOf('store')).toEqual(['field:written']);
});

it('an `init` block and a destructuring RHS no longer vanish', () => {
// Both mint no symbol of their own, so the hook consumed them and their
// code disappeared entirely; they now attribute to the enclosing scope.
const src = `
package p

class C {
init { val q = initCall() }
val (a, b) = makePair()
}

val (t1, t2) = topMakePair()
`;
const result = extractFromSource('C.kt', src);
const byId = new Map(result.nodes.map((n) => [n.id, n]));
const owner = (name: string) => {
const u = result.unresolvedReferences.find(
(r) => r.referenceKind === 'calls' && r.referenceName === name
);
const n = u ? byId.get(u.fromNodeId) : undefined;
return n ? `${n.kind}:${n.name}` : undefined;
};
expect(owner('initCall')).toBe('class:C');
expect(owner('makePair')).toBe('class:C');
expect(owner('topMakePair')).toBe('namespace:p');
});
});
});

describe('Dart Extraction', () => {
Expand Down Expand Up @@ -7561,6 +7798,35 @@ def processData(): Unit = {
const calls = result.unresolvedReferences.filter((r) => r.referenceKind === 'calls');
expect(calls.length).toBeGreaterThan(0);
});

it('walks a val/var initializer scoped to the declared symbol (#693 for Scala)', () => {
// The val/var hook minted the node and returned true, so the dispatcher
// only scanned the subtree for function-as-value candidates — every call
// in an initializer was dropped, which on a `val`-heavy codebase
// (SpinalHDL, Akka wiring) is most of the wiring.
const code = `
class C {
val fieldLambda: () => Unit = () => target()
val direct = target()
lazy val lazily = target()
private def target(): Unit = {}
}

object O {
val topLambda = () => hit()
def hit(): Unit = {}
}
`;
const result = extractFromSource('C.scala', code);
const byId = new Map(result.nodes.map((n) => [n.id, n]));
const callersOf = (name: string) =>
result.unresolvedReferences
.filter((u) => u.referenceKind === 'calls' && u.referenceName === name)
.map((u) => byId.get(u.fromNodeId)?.name)
.sort();
expect(callersOf('target')).toEqual(['direct', 'fieldLambda', 'lazily']);
expect(callersOf('hit')).toEqual(['topLambda']);
});
});
});

Expand Down
9 changes: 9 additions & 0 deletions __tests__/fixtures/kernel-parity/Torture.java
Original file line number Diff line number Diff line change
Expand Up @@ -22,6 +22,15 @@ public class TortureService extends BaseService implements Runnable, AutoCloseab
protected int count = 0;
private final List<String> names;
int packagePrivate, secondDeclarator;
/** Field initializers — walked scoped to the field (#693). */
private final Runnable fieldLambda = () -> helper(RETRY_LIMITS);
private final Runnable fieldAnonClass = new Runnable() {
@Override
public void run() {
helper(RETRY_LIMITS);
}
};
private final Runnable fieldMethodRef = TortureService::compute;

/** Ctor javadoc. */
public TortureService(List<String> names) {
Expand Down
6 changes: 6 additions & 0 deletions __tests__/fixtures/kernel-parity/torture.js
Original file line number Diff line number Diff line change
Expand Up @@ -73,3 +73,9 @@ export default {
},
},
};

// Initializer walks attributed to the declared symbol (#693). A plain call
// leaked to the FILE node; a non-exported object literal was skipped outright.
const eagerConfig = loadConfig();
const handlerMap = { onSave: () => persist(eagerConfig), onLoad: loadConfig() };
const lazyList = [() => persist(eagerConfig)];
24 changes: 24 additions & 0 deletions __tests__/fixtures/kernel-parity/torture.kt
Original file line number Diff line number Diff line change
Expand Up @@ -50,6 +50,13 @@ val topDelegated by lazy { WidgetK(1) }
val (destA, destB) = makePair()
val withGetter: Int
get() = 42
val initLambda: () -> Unit = { caller() }
val initSam = Runnable { caller() }
val initObject = object : Runnable {
override fun run() {
caller()
}
}

class WidgetK(val size: Int, private var name: String = defaultName()) {
val area: Int = size * size
Expand Down Expand Up @@ -265,3 +272,20 @@ fun labeledLambda() {
}

fun whereClause(): Int where Int : Comparable<Int> = 1

class AccessorK {
val sameLineGetter: Int get() = compute()
var sameLinePair: Int get() = compute()
set(v) { draw(v) }
}

class SiblingAccessorK {
var nextLine: Int = 0
get() = compute()
set(v) { draw(v) }
val (localA, localB) = makePair()
init {
val fromInit = compute()
register(fromInit)
}
}
6 changes: 6 additions & 0 deletions __tests__/fixtures/kernel-parity/torture.py
Original file line number Diff line number Diff line change
Expand Up @@ -47,3 +47,9 @@ def shadowed():

handlers = {"recv": target_cb}
callbacks = [target_cb, view]

# Initializer walks attributed to the assigned name (#693).
INIT_EAGER = helper()
INIT_LAMBDA = lambda: target_cb()
INIT_MAP = {"a": helper()}
init_a, init_b = helper(), view()
Loading