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

### Fixes

- Nested JavaScript and TypeScript calls now remain available to Steps and framework resolution without linking built-in collection calls to unrelated project methods; rebuild existing indexes to refresh these results. (#1794, #1566)
- Spring mappings now include every declared path combination and resolve constants declared in the same file, while unresolved paths no longer appear as false root routes. (#1461)
- `codegraph callers`, `codegraph callees` and `codegraph impact` now resolve qualified names, group results and JSON edges by definition, and accept `--file` to narrow ambiguous names; thanks @ferrine. (#1512, #1656)
- `codegraph callers`, `codegraph callees` and `codegraph impact` (CLI and MCP) now report missing names with did-you-mean suggestions instead of another symbol's results, and exact matches with no callers stay empty; thanks @uvmplus. (#1473, #1481)
Expand Down
25 changes: 20 additions & 5 deletions __tests__/kernel-tsjs-parity.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -98,24 +98,39 @@ describe.skipIf(!kernelBuilt)('kernel TS/JS extraction parity', () => {

it.each([
['ts', 'typescript'], ['tsx', 'tsx'], ['js', 'javascript'], ['jsx', 'jsx'],
] as const)('leaves nested identifier receivers unresolved and keeps argument calls: %s (#1566)', (ext, language) => {
const result = assertParity(`fixture.${ext}`, `
] as const)('preserves nested receivers and argument calls: %s (#1794)', (ext, language) => {
const source = `
function readKey() { return 'answer'; }
function local() {
const values = new Map();
return values.get(readKey());
}
function nested(holder) {
function nested(holder, höldér) {
holder.values.get(readKey());
holder.values?.get(readKey());
holder['values'].get(readKey());
holder.deep.values.get(readKey());
holder?.values.get(readKey());
holder[readKey()].get(readKey());
holder[0].get(readKey());
holder["odd.key"].get(readKey());
holder /* receiver */.values.get(readKey());
höldér.values.get(readKey());
}
`, language);
`;
const result = assertParity(`fixture.${ext}`, source, language);
assertParity(`fixture-crlf.${ext}`, source.replace(/\n/g, '\r\n'), language);
const nested = result.nodes.find((n) => n.name === 'nested' && n.kind === 'function');
expect(nested).toBeDefined();
expect(result.unresolvedReferences.filter((r) => r.referenceKind === 'calls' && r.fromNodeId === nested!.id)
.map((r) => r.referenceName)).toEqual(['readKey', 'readKey', 'readKey', 'readKey']);
.map((r) => r.referenceName)).toEqual([
'holder.values.get', 'readKey', 'holder.values.get', 'readKey',
"holder['values'].get", 'readKey', 'holder.deep.values.get', 'readKey',
'holder?.values.get', 'readKey', 'holder[readKey()].get', 'readKey', 'readKey',
'holder[0].get', 'readKey', 'holder["odd.key"].get', 'readKey',
'holder /* receiver */.values.get', 'readKey',
'höldér.values.get', 'readKey',
]);
expect(result.unresolvedReferences.some((r) => r.referenceName === 'values.get')).toBe(true);
});

Expand Down
24 changes: 24 additions & 0 deletions __tests__/resolution.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2316,6 +2316,30 @@ func main() {
});

describe('Local-variable receiver-type inference (#1108)', () => {
it.each(['ts', 'tsx', 'js', 'jsx'])('keeps compound receiver evidence without guessing targets — %s (#1794)', async (ext) => {
fs.writeFileSync(path.join(tempDir, `holder.${ext}`), 'export const holder = { values: new Map() };');
const receivers = [
'holder?.values', 'holder[readKey()]', 'holder[0]', 'holder["odd.key"]',
'holder /* receiver */.values', 'höldér.values', 'imported.values', 'data.holder.values',
];
fs.writeFileSync(path.join(tempDir, `calls.${ext}`), `
import { holder as imported } from './holder';
import * as data from './holder';
export class Collision { get(key) { return key; } }
export function readKey() { return 'values'; }
${receivers.map((receiver, i) => `export function nested${i}(holder, höldér) { return ${receiver}.get(readKey()); }`).join('\n')}
`);
cg = await CodeGraph.init(tempDir, { index: true });
for (let i = 0; i < receivers.length; i++) {
const caller = cg.getNodesByName(`nested${i}`).find(n => n.kind === 'function')!;
const callees = cg.getCallees(caller.id).filter(({ edge }) => edge.kind === 'calls');
// Argument and computed-key calls survive, but neither an imported
// root object nor Collision.get is evidence of the called member.
expect(callees.length, receivers[i]).toBeGreaterThan(0);
expect(callees.every(({ node }) => node.name === 'readKey'), receivers[i]).toBe(true);
}
});

it.each(['ts', 'tsx', 'js', 'jsx'])('keeps built-in Map calls off project methods — %s (#1566)', async (ext) => {
const typed = ext === 'ts' || ext === 'tsx';
fs.writeFileSync(path.join(tempDir, `cache.${ext}`), `
Expand Down
4 changes: 2 additions & 2 deletions __tests__/ts-chained-receiver.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -3,8 +3,8 @@
* .get(k)`, `document.body.querySelector(s)` — ends in a platform API. Emitting
* the bare method name for it let every such call exact-match whatever project
* symbol shared the name, so a storage wrapper's `get` called itself (#1707).
* Those are dropped, as are untyped identifier chains (#1566). The existing
* `window.MyNs.run()` and `this.<field>.m()` paths remain outside that guard.
* Complete call references are retained; only proven targets become edges.
* `window.MyNs.run()` and `this.<field>.m()` keep their existing resolution.
*/

import { describe, it, expect, beforeAll, afterAll } from 'vitest';
Expand Down
19 changes: 7 additions & 12 deletions codegraph-kernel/src/tsjs/extractors.rs
Original file line number Diff line number Diff line change
Expand Up @@ -1130,10 +1130,9 @@ impl<'t> Walker<'t> {

// --- extractCall (TS/JS generic tail) -------------------------------------------------

/// Identifier-rooted member chains have no inferred property type (#1566),
/// including host API chains (#1707). Keep the existing window namespace
/// escape; call-result and `this` receivers are outside this guard.
fn is_unresolved_member_chain(&self, receiver: Node<'t>) -> bool {
/// Identifier-rooted nested receivers retain their full call-site text.
/// Preserve the existing window namespace escape (#1794, #1566).
fn is_identifier_chain(&self, receiver: Node<'t>) -> bool {
let mut cur = receiver;
if !matches!(cur.kind(), "member_expression" | "subscript_expression") {
return false;
Expand Down Expand Up @@ -1174,14 +1173,6 @@ impl<'t> Walker<'t> {
if is_literal_receiver(r.kind()) {
return;
}
// `holder.values.get()` has no inferred property type
// (#1566). Dropping the receiver or merely preserving it
// would allow unrelated same-name method guesses. Emit
// nothing, as for host chains (#1707); argument calls are
// visited independently. Mirrors extractCall in TS.
if self.is_unresolved_member_chain(r) {
return;
}
}
let recv_ident = receiver.filter(|r| {
matches!(r.kind(), "identifier" | "simple_identifier" | "field_identifier")
Expand All @@ -1205,6 +1196,10 @@ impl<'t> Walker<'t> {
// TreeSitterExtractor.extractCall.
let Some(inner) = self.plain_inner_callee(r) else { return };
callee_name = format!("{inner}().{method_name}");
} else if let Some(r) = receiver.filter(|r| self.is_identifier_chain(*r)) {
// Frameworks and Steps need the call site even when
// generic resolution cannot prove a target (#1794).
callee_name = format!("{}.{method_name}", self.text(r));
} else {
callee_name = method_name.to_string();
}
Expand Down
2 changes: 1 addition & 1 deletion src/extraction/extraction-version.ts
Original file line number Diff line number Diff line change
Expand Up @@ -21,4 +21,4 @@
* turns the re-index hint into noise — keep it honest (see CLAUDE.md, "Honesty
* in the product is load-bearing").
*/
export const EXTRACTION_VERSION = 26;
export const EXTRACTION_VERSION = 27;
22 changes: 8 additions & 14 deletions src/extraction/tree-sitter.ts
Original file line number Diff line number Diff line change
Expand Up @@ -411,12 +411,10 @@ const TS_JS_CHAIN_LANGUAGES = new Set(['typescript', 'tsx', 'javascript', 'jsx']
const TS_JS_CHAIN_RECEIVER_TYPES = new Set(['member_expression', 'subscript_expression']);

/**
* Identifier-rooted member chains have no inferred property type (#1566),
* including host API chains (#1707). Keep the existing `window.MyNamespace`
* escape for project globals; call-result and `this` receivers have their own
* paths and are outside this guard.
* Nested identifier receivers retain their call-site text; window keeps its
* existing project-namespace behavior. Call-result and this paths are separate.
*/
function isUnresolvedTsJsChain(node: SyntaxNode, source: string): boolean {
function isTsJsIdentifierChain(node: SyntaxNode, source: string): boolean {
let cur: SyntaxNode | null = node;
while (cur && TS_JS_CHAIN_RECEIVER_TYPES.has(cur.type)) {
cur = getChildByField(cur, 'object');
Expand Down Expand Up @@ -4868,16 +4866,12 @@ export class TreeSitterExtractor {
TS_JS_CHAIN_LANGUAGES.has(this.language) &&
receiver &&
TS_JS_CHAIN_RECEIVER_TYPES.has(receiver.type) &&
isUnresolvedTsJsChain(receiver, this.source)
isTsJsIdentifierChain(receiver, this.source)
) {
// `holder.values.get()` has no inferred property type (#1566).
// Emitting bare `get` exact-matches an unrelated project method;
// preserving the chain alone would still allow receiver guessing.
// Emit nothing until the property type can be established. This
// also covers host chains such as `chrome.storage.local.get()`
// (#1707). Calls inside arguments are visited independently.
// Mirrored in the kernel's extract_call (tsjs/extractors.rs).
return;
// Keep call-site evidence for framework resolution and Steps.
// Generic resolution must not guess a target from the last name
// when the nested receiver's type is unknown (#1794, #1566).
calleeName = `${getNodeText(receiver, this.source)}.${methodName}`;
} else {
calleeName = methodName;
}
Expand Down
10 changes: 9 additions & 1 deletion src/resolution/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -33,7 +33,7 @@ import { logDebug } from '../errors';
import { lexicalPathWithinRoot } from '../utils';
import type { ReExport } from './types';
import { LRUCache } from './lru-cache';
import { JS_BUILT_INS } from './js-builtins';
import { JS_BUILT_INS, isTsJsNestedCall } from './js-builtins';

/** Node kinds that can declare supertypes (extends/implements). */
const SUPERTYPE_BEARING_KINDS = new Set<Node['kind']>([
Expand Down Expand Up @@ -1021,6 +1021,14 @@ export class ReferenceResolver {
if (this.profileStages) this.stageAdd('frameworks', ref, fwEarly !== null, tFw);
if (fwEarly) return fwEarly;

// An imported root is not the called nested member. Keep framework
// evidence, but never bind holder.values.get to holder or an unrelated get.
if (isTsJsNestedCall(ref)) {
return candidates.length > 0
? candidates.reduce((best, curr) => curr.confidence > best.confidence ? curr : best)
: null;
}

// Strategy 2: Try import-based resolution
// A TS/JS/Python call-receiver chain (`useStore.getState().reset`, #1683)
// names the ROOT's import, not the method's: letting resolveViaImport see
Expand Down
15 changes: 15 additions & 0 deletions src/resolution/js-builtins.ts
Original file line number Diff line number Diff line change
@@ -1,3 +1,5 @@
import type { UnresolvedRef } from './types';

/** Shared JS/TS built-ins for direct references and inferred receiver types. */
export const JS_BUILT_INS = new Set([
'console', 'window', 'document', 'global', 'process',
Expand All @@ -6,3 +8,16 @@ export const JS_BUILT_INS = new Set([
'setTimeout', 'setInterval', 'clearTimeout', 'clearInterval',
'fetch', 'require', 'module', 'exports', '__dirname', '__filename',
]);

/** Nested property calls need framework evidence, not a last-name guess. */
export function isTsJsNestedCall(ref: UnresolvedRef): boolean {
if (ref.referenceKind !== 'calls' ||
!['typescript', 'tsx', 'javascript', 'jsx'].includes(ref.language)) return false;
const receiver = ref.referenceName.slice(0, ref.referenceName.lastIndexOf('.')).trim();
// Call-result chains have their own validated store/factory resolution.
if (receiver.endsWith('()')) return false;
// The extractors preserve compound receiver text, including brackets,
// optional access and comments. A simple receiver is just the root itself.
const root = receiver.split(/[.\[?\s]/, 1)[0];
return receiver !== root && root !== 'this' && root !== 'window';
}
5 changes: 4 additions & 1 deletion src/resolution/name-matcher.ts
Original file line number Diff line number Diff line change
Expand Up @@ -8,7 +8,7 @@ import * as path from 'path';
import { Language, Node } from '../types';
import { UnresolvedRef, ResolvedRef, ResolutionContext, SUPERTYPE_TARGET_KINDS, isInheritanceRef, isImportableKind } from './types';
import { blankStringContents, stripCommentsForRegex } from './strip-comments';
import { JS_BUILT_INS } from './js-builtins';
import { JS_BUILT_INS, isTsJsNestedCall } from './js-builtins';

/**
* Ceiling on how many same-named definitions a FUZZY name-match strategy will
Expand Down Expand Up @@ -3149,6 +3149,9 @@ export function matchReference(
}
}

// No generic fallback can establish an unknown nested receiver's type.
if (isTsJsNestedCall(ref)) return null;

// Try strategies in order of confidence
let result: ResolvedRef | null;

Expand Down