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

#### Symbols, tests and the viewer

- A method called on the result of another call — `d.setdefault(k, []).append(v)`, `make().run()` — no longer produces a call edge to an unrelated top-level function that merely shares the name, in Python and JavaScript/TypeScript. The receiver is kept so the inner call still resolves; the outer method stays unresolved rather than guessed. Re-index after upgrading. (#1683, #1681)

- **Files under an `e2e/` directory count as tests.** Their calls no longer appear as production callers in Steps, dead-code and test badges.

- **Production code under a `samples` or `examples` package path is no longer treated as test code.** A Kotlin or Java project whose package path runs through `com/google/samples/…` (Now in Android, for one) had nearly every file counted as a fixture, so the Map opened on `build-logic`, the entry points hid the app, and dead-code and test badges were wrong. Only the project layout above a `src/` folder decides now; the package path below it never does.
Expand Down
81 changes: 81 additions & 0 deletions __tests__/call-receiver-no-fabrication.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,81 @@
/**
* A member call whose receiver is itself a call never fabricates an edge
* (#1683, #1681). `d.setdefault(k, []).append(v)` used to lose its receiver at
* extraction time, degrade to the bare `append`, and exact-match any top-level
* project function of that name — a call edge from an unrelated function,
* reproduced in Python and JavaScript alike. The receiver is now kept as
* `<inner>().<method>`, which nothing name-matches; the inner call resolves
* on its own as before.
*/
import { describe, it, expect, beforeAll, afterAll } from 'vitest';
import * as fs from 'fs';
import * as os from 'os';
import * as path from 'path';
import { CodeGraph } from '../src';
import { extractFromSource } from '../src/extraction';
import { initGrammars, loadAllGrammars } from '../src/extraction/grammars';

let dir: string;
let cg: CodeGraph;

beforeAll(async () => {
await initGrammars();
await loadAllGrammars();
dir = fs.mkdtempSync(path.join(os.tmpdir(), 'cg-1683-'));
fs.mkdirSync(path.join(dir, 'py'));
fs.mkdirSync(path.join(dir, 'js'));
fs.writeFileSync(path.join(dir, 'py', '__init__.py'), '');
fs.writeFileSync(
path.join(dir, 'py', 'collect.py'),
'def append(item):\n return item\n\ndef get(key):\n return key\n\ndef make():\n return {}\n\n' +
'def bucket(d, k, v):\n d.setdefault(k, []).append(v)\n return d.items().get(k)\n\n' +
'def fresh():\n return make().get("x")\n'
);
fs.writeFileSync(
path.join(dir, 'js', 'collect.js'),
'function append(item) { return item; }\nfunction run() { return 1; }\nfunction make() { return {}; }\n' +
'function bucket(d, k, v) { d.setdefault(k, []).append(v); make().run(); (0, make)().run(); }\n' +
'module.exports = { append, run, make, bucket };\n'
);
cg = CodeGraph.initSync(dir);
await cg.indexAll();
});

afterAll(() => {
cg.destroy();
fs.rmSync(dir, { recursive: true, force: true });
});

const fn = (name: string, file: string) => cg.getNodesByName(name).find((n) => n.kind === 'function' && n.filePath.endsWith(file))!;
const calleesOf = (name: string, file: string) =>
cg.getCallees(fn(name, file).id).filter(({ edge }) => edge.kind === 'calls').map(({ node }) => node.name).sort();
// Callers through `calls` edges only — a `module.exports = { run }` value reference is not a call.
const callersOf = (name: string, file: string) =>
cg.getCallers(fn(name, file).id).filter(({ edge }) => edge.kind === 'calls').map(({ node }) => node.name);

describe('call-expression receivers (#1683)', () => {
it('Python: no edge from a call-result receiver to a same-named top-level function', () => {
expect(calleesOf('bucket', 'collect.py')).toEqual([]);
expect(callersOf('append', 'collect.py')).toEqual([]);
expect(callersOf('get', 'collect.py')).toEqual([]);
// The inner call still resolves on its own; `.get` on its unknown product does not.
expect(calleesOf('fresh', 'collect.py')).toEqual(['make']);
});

it('JavaScript: the same shape, and the inner call keeps its edge', () => {
expect(callersOf('append', 'collect.js')).toEqual([]);
// `make().run()` — what `make` returns is unknown, so `run` is not guessed.
expect(callersOf('run', 'collect.js')).toEqual([]);
expect(calleesOf('bucket', 'collect.js')).toEqual(['make']);
});

it('encodes the receiver as `<inner>().<method>` and drops a receiver with no static callee', () => {
const r = extractFromSource('src/x.js', 'function f(d) { d.setdefault("k", []).append(1); make().run(); (0, make)().run(); arr[0]().go(); }');
const names = r.unresolvedReferences.filter((u) => u.referenceKind === 'calls').map((u) => u.referenceName).sort();
// `(0, make)` and `arr[0]` are the inner calls' own refs, unchanged; their chains are dropped.
expect(names).toEqual(['(0, make)', 'arr[0]', 'd.setdefault', 'd.setdefault().append', 'make', 'make().run']);
const py = extractFromSource('x.py', 'def f(d):\n d.setdefault("k", []).append(1)\n d.items().get(2)\n');
expect(py.unresolvedReferences.filter((u) => u.referenceKind === 'calls').map((u) => u.referenceName).sort())
.toEqual(['d.items', 'd.items().get', 'd.setdefault', 'd.setdefault().append']);
});
});
9 changes: 9 additions & 0 deletions __tests__/fixtures/kernel-parity/torture.js
Original file line number Diff line number Diff line change
Expand Up @@ -73,3 +73,12 @@ export default {
},
},
};

// --- call-expression receivers (#1683) ----------------------------------------
function bucketChains(d, k, v) {
d.setdefault(k, []).append(v);
make().run();
(0, make)().run();
arr[0]().go();
obj.make().run().again();
}
9 changes: 9 additions & 0 deletions __tests__/fixtures/kernel-parity/torture.py
Original file line number Diff line number Diff line change
Expand Up @@ -47,3 +47,12 @@ def shadowed():

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


# --- call receivers (#1683) ---------------------------------------------------
def bucket_chains(d, k, v):
d.setdefault(k, []).append(v)
d.items().get(k)
make().run()
(lambda: make)()().run()
obj.make().run().again()
5 changes: 4 additions & 1 deletion __tests__/object-literal-methods.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -55,7 +55,10 @@ describe('object-literal method extraction', () => {
// so an in-store calls edge will resolve once the pipeline runs.
const fetchUser = result.nodes.find((n) => n.name === 'fetchUser')!;
const fetchUserRefs = result.unresolvedReferences.filter((r) => r.fromNodeId === fetchUser.id);
expect(fetchUserRefs.map((r) => r.referenceName)).toContain('reset');
// `get().reset()` keeps its call receiver (#1683): the ref is the chain
// `get().reset`, which the resolver binds to the store's own `reset`.
expect(fetchUserRefs.map((r) => r.referenceName)).toContain('get().reset');
expect(fetchUserRefs.map((r) => r.referenceName)).not.toContain('reset');

// The action's body wasn't mis-attributed to the file scope (the reason we
// skip the generic body-visit for the store-factory call).
Expand Down
23 changes: 23 additions & 0 deletions codegraph-kernel/src/python.rs
Original file line number Diff line number Diff line change
Expand Up @@ -608,6 +608,13 @@ impl<'t> Walker<'t> {
} else {
callee_name = method_name.to_string();
}
} else if let Some(r) = receiver.filter(|r| r.kind() == "call") {
// Call receiver — `d.setdefault(k, []).append(v)` (#1683):
// `<inner>().<method>`, or nothing when the inner callee
// is not a plain name / attribute chain. Mirrors
// TreeSitterExtractor.extractCall.
let Some(inner) = self.plain_inner_callee(r) else { return };
callee_name = format!("{inner}().{method_name}");
} else {
callee_name = method_name.to_string();
}
Expand All @@ -626,6 +633,22 @@ impl<'t> Walker<'t> {
}
}

/// The callee of a call receiver when it is a plain identifier or attribute
/// chain (`make`, `d.setdefault`), whitespace stripped (#1683).
fn plain_inner_callee(&self, call: Node<'t>) -> Option<String> {
let inner = call.child_by_field_name("function")?;
let text: String = self.text(inner).chars().filter(|c| !c.is_whitespace()).collect();
if text.is_empty() {
return None;
}
let ok = text.split('.').all(|seg| {
let mut chars = seg.chars();
matches!(chars.next(), Some(c) if c.is_ascii_alphabetic() || c == '_')
&& chars.all(|c| c.is_ascii_alphanumeric() || c == '_')
});
if ok { Some(text) } else { None }
}

/// extractDecoratorsFor — python decorators are PRECEDING SIBLINGS inside
/// decorated_definition. Only bare-identifier decorators yield a target
/// (python's `call` kind isn't `call_expression`, and `attribute` isn't in
Expand Down
25 changes: 23 additions & 2 deletions codegraph-kernel/src/tsjs/extractors.rs
Original file line number Diff line number Diff line change
Expand Up @@ -1103,9 +1103,14 @@ impl<'t> Walker<'t> {
} else {
callee_name = method_name.to_string();
}
} else if let Some(r) = receiver.filter(|r| r.kind() == "call_expression") {
// Call receiver — `make().run()` (#1683): keep the inner
// callee as `<inner>().<method>`, or emit nothing when it
// is not a plain name / member chain. Mirrors
// TreeSitterExtractor.extractCall.
let Some(inner) = self.plain_inner_callee(r) else { return };
callee_name = format!("{inner}().{method_name}");
} else {
// (the call-receiver re-encode branches are other
// languages'; TS/JS keeps the bare method name)
callee_name = method_name.to_string();
}
}
Expand All @@ -1128,6 +1133,22 @@ impl<'t> Walker<'t> {

// --- extractInstantiation -----------------------------------------------------------

/// The callee of a call-expression receiver when it is a plain identifier
/// or member chain (`make`, `d.setdefault`), whitespace stripped (#1683).
fn plain_inner_callee(&self, call: Node<'t>) -> Option<String> {
let inner = call.child_by_field_name("function")?;
let text: String = self.text(inner).chars().filter(|c| !c.is_whitespace()).collect();
if text.is_empty() {
return None;
}
let ok = text.split('.').all(|seg| {
let mut chars = seg.chars();
matches!(chars.next(), Some(c) if c.is_ascii_alphabetic() || c == '_' || c == '$')
&& chars.all(|c| c.is_ascii_alphanumeric() || c == '_' || c == '$')
});
if ok { Some(text) } else { None }
}

pub(super) fn extract_instantiation(&mut self, node: Node<'t>) {
if self.stack.is_empty() {
return;
Expand Down
26 changes: 26 additions & 0 deletions src/extraction/tree-sitter.ts
Original file line number Diff line number Diff line change
Expand Up @@ -4573,6 +4573,32 @@ export class TreeSitterExtractor {
// scope keywords: such calls previously emitted a bare method
// name, which either failed to resolve or resolved ambiguously.
calleeName = `${getNodeText(receiver, this.source)}.${methodName}`;
} else if (
(this.language === 'typescript' ||
this.language === 'javascript' ||
this.language === 'tsx' ||
this.language === 'jsx' ||
this.language === 'python') &&
receiver &&
(receiver.type === 'call_expression' || receiver.type === 'call')
) {
// Receiver that is itself a call — `d.setdefault(k, []).append(v)`,
// `make().run()`, `res.json().data` (#1683). The bare method name
// this used to emit exact-matched any top-level project symbol of
// that name and fabricated a call edge from an unrelated function
// (`append`, `get`, `run`…). Keep the inner callee, encoded as
// `<inner>().<method>` like the Java/Kotlin/C++ chains: the
// marker never appears in an ordinary ref, so nothing name-matches
// it, and a chain resolver can later infer the receiver's type
// from what the inner call returns. An inner callee that is not a
// plain name or member chain (`(await x)()`, `arr[0]()`) has no
// static receiver at all — emit nothing: a silent miss, never a
// wrong edge. The inner call is visited on its own either way.
// Mirrored in the kernel (tsjs/extractors.rs, python.rs).
const innerFn = getChildByField(receiver, 'function');
const innerCallee = innerFn ? getNodeText(innerFn, this.source).replace(/\s+/g, '') : '';
if (!/^[A-Za-z_$][\w$]*(?:\.[A-Za-z_$][\w$]*)*$/.test(innerCallee)) return;
calleeName = `${innerCallee}().${methodName}`;
} else if (
this.language === 'go' &&
receiver &&
Expand Down
13 changes: 13 additions & 0 deletions src/resolution/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -974,6 +974,19 @@ export class ReferenceResolver {
if (fwEarly) return fwEarly;

// 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
// it binds the call to the imported store constant and the method is
// never looked up. The name-matcher owns the chain shape for these
// languages — the Java/Kotlin/C++ chains keep their existing path.
if (
ref.referenceKind === 'calls' &&
CHAIN_SHAPE.test(ref.referenceName) &&
(ref.language === 'typescript' || ref.language === 'javascript' || ref.language === 'tsx' || ref.language === 'jsx' || ref.language === 'python')
) {
return this.gateLanguage(matchReference(ref, this.context), ref);
}

const tImp = this.profileStages ? process.hrtime.bigint() : 0n;
const importResult = this.gateLanguage(resolveViaImport(ref, this.context), ref);
if (this.profileStages) this.stageAdd('viaImport', ref, !!importResult, tImp);
Expand Down
37 changes: 37 additions & 0 deletions src/resolution/name-matcher.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2245,6 +2245,30 @@ function matchRustSelfFieldCall(
return null;
}

/**
* The one fallback a TS/JS/Python call-receiver chain keeps (#1683): a STORE
* ACCESSOR. Zustand's `get()` inside the store factory and
* `useStore.getState()` outside it hand back the store whose actions are
* indexed as functions (#1573), so a unique callable of the method's name in
* the same language family is what `get().reset()` reaches. Nothing else
* qualifies: a chain rooted in a project value still says nothing about what
* the inner call RETURNS — `db.prepare(sql).all()` would bind to any project
* function named `all` — so it resolves to nothing, exactly like a chain
* rooted in a parameter (`d.setdefault(k, []).append(v)`).
*/
function matchStoreAccessorChain(ref: UnresolvedRef, context: ResolutionContext): ResolvedRef | null {
const m = ref.referenceName.match(/^([\w$.]+)\(\)\.(\w+)$/);
if (!m || !m[1] || !m[2]) return null;
const inner = m[1];
const method = m[2];
if (!(inner === 'get' || inner === 'getState' || inner.endsWith('.getState'))) return null;
const callables = context
.getNodesByName(method)
.filter((n) => (n.kind === 'function' || n.kind === 'method') && sameLanguageFamily(n.language, ref.language) && n.id !== ref.fromNodeId);
if (callables.length !== 1) return null;
return { original: ref, targetNodeId: callables[0]!.id, confidence: 0.6, resolvedBy: 'exact-match' };
}

/**
* Split a camelCase or PascalCase string into words.
*/
Expand Down Expand Up @@ -2644,6 +2668,19 @@ export function matchReference(
if (result) return result;
}

// A call-receiver chain the extractor encoded as `<inner>().<method>` for a
// language with no chain resolver above (TS/JS, Python — #1683) is a
// receiver whose type is unknown. Nothing below may guess for it: the
// method-call pattern rejects the parens, exact name never matches, but the
// fuzzy strategy splits on `.` and would hand `make().run` to any `run` —
// the fabricated edge the encoding exists to prevent.
if (
ref.referenceName.includes('().') &&
(ref.language === 'typescript' || ref.language === 'javascript' || ref.language === 'tsx' || ref.language === 'jsx' || ref.language === 'python')
) {
return nmTimed('storeAccessorChain', ref, () => matchStoreAccessorChain(ref, context));
}

// 2. Method call pattern
result = nmTimed('methodCall', ref, () => matchMethodCall(ref, context));
if (result) return result;
Expand Down