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

## [Unreleased]

### Fixes

- In JavaScript and TypeScript, the methods of a named object literal now get their own symbols even when the object isn't exported: a plain `const api = { load() {…} }`, an object declared inside an IIFE, or a namespace hung off the page like `window.App = { load() {…} }`. A call like `api.load()` or `window.App.load()` now reaches that method, and the calls made inside it are its own instead of the object's, so script-tag apps no longer lose most of their code from callers and impact. Re-index JavaScript and TypeScript projects after upgrading. (#2300)


## [1.6.2] - 2026-10-03

Expand Down
2 changes: 1 addition & 1 deletion __tests__/expression-receiver-calls.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -124,7 +124,7 @@ describe('expression receivers', () => {
.map((r) => r.referenceName);
expect(refs).toEqual([
'list().map', 'list', 'x.run', 'y.run', 'x.stop', 'getTarget().install', 'getTarget',
'f', 'run', 'go', 'start',
'f', 'run', 'go', 'window.Api.start',
]);
});
});
11 changes: 4 additions & 7 deletions __tests__/extraction.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -1180,7 +1180,7 @@ export const exported = { handler: () => target() };
return result.unresolvedReferences
.filter((u) => u.referenceKind === 'calls' && u.referenceName === name)
.map((u) => byId.get(u.fromNodeId))
.map((n) => (n ? `${n.kind}:${n.name}` : '?'))
.map((n) => (n ? `${n.kind}:${n.qualifiedName}` : '?'))
.sort();
};

Expand All @@ -1190,15 +1190,12 @@ export const exported = { handler: () => target() };
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.
it('plain and exported object methods own their calls; eager values keep their constant owner', () => {
expect(callersOf('target')).toEqual([
'constant:list',
'constant:obj',
'constant:obj',
'function:handler',
'function:exported::handler',
'function:obj::handler',
]);
});
});
Expand Down
4 changes: 2 additions & 2 deletions __tests__/js-builtin-method-calls.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -108,8 +108,8 @@ ${typed ? 'export function typed(cart: Cart) { cart.map(); }' : ''}
for (const name of ['tidy', 'cached', 'unknownCache', 'literalArray', 'literalString']) expect(callees(name), name).toEqual([]);
expect(callees('constructed')).toContain('Cart::add');
expect(callees('imported')).toContain('Cart::bind');
expect(callees('objectLiteral')).toEqual(['map']);
expect(callees('localLiteral')).toEqual(['map']);
expect(callees('objectLiteral')).toEqual(['api::map']);
expect(callees('localLiteral')).toEqual(['local::map']);
if (typed) expect(callees('typed')).toEqual(['Cart::map']);
const self = cg.getNodesByKind('method').find(n => n.qualifiedName === 'Cart::self')!;
expect(cg.getCallees(self.id).map(c => c.node.qualifiedName)).toEqual(['Cart::add']);
Expand Down
299 changes: 299 additions & 0 deletions __tests__/js-named-object-members.test.ts

Large diffs are not rendered by default.

31 changes: 30 additions & 1 deletion __tests__/kernel-tsjs-parity.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -96,6 +96,35 @@ describe.skipIf(!kernelBuilt)('kernel TS/JS extraction parity', () => {
return viaWasm;
}

it.each([
['ts', 'typescript'], ['tsx', 'tsx'], ['js', 'javascript'], ['jsx', 'jsx'],
] as const)('named literal owners, source scopes and qualified candidates: %s (#2300)', (ext, language) => {
const source = `/* русский 😀 */ const Api={read(){helper()},close:()=>helper()};
function helper(){}
function use(){Api.read()}
(function(){const Local={read(){helper()}};Local.read();window.A={run(){Local.read()}}})();
function global(){window.A.run()}
function shadow(window){window.A.run()}
function varScope(){if(flag){var Local={read(){helper()}}}Local.read()}
function mixed(){const Members={run(){helper()}}, data${language === 'typescript' || language === 'tsx' ? ': Foo' : ''}={x:1};Members.run()}
const Rename={read:function recur(){recur()},opaque:function recur(recur){recur()}};
const Mutable={run(){helper()}};Mutable.run=external;function changed(){Mutable.run()}
function init(){Api.sub={go(){helper()}}}
`;
const result = assertParity(`literal.${ext}`, source, language);
expect(result.nodes.some(node => node.qualifiedName === 'Api::read')).toBe(true);
expect(result.edges.some(edge => edge.metadata?.jsObjectMember === true)).toBe(true);
expect(result.unresolvedReferences.some(ref => ref.referenceName === 'window.A.run' && ref.candidates?.includes('window.A::run'))).toBe(true);
expect(result.unresolvedReferences.some(ref => ref.candidates?.length === 0)).toBe(true);
expect(result.nodes.some(node => node.name === 'data')).toBe(false);
expect(result.nodes.find(node => node.qualifiedName === 'mixed::Members::run')?.kind).toBe('function');
expect(result.nodes.find(node => node.name === 'go')?.qualifiedName).toBe('Api.sub::go');
if (language === 'typescript' || language === 'tsx') {
const mixed = result.nodes.find(node => node.qualifiedName === 'mixed')!;
expect(result.unresolvedReferences.filter(ref => ref.referenceName === 'Foo').map(ref => ref.fromNodeId)).toEqual([mixed.id]);
}
});

it.each([
['ts', 'typescript'], ['tsx', 'tsx'], ['js', 'javascript'], ['jsx', 'jsx'],
] as const)('same-line accessors retain distinct identities after Unicode: %s (#1349)', (ext, language) => {
Expand Down Expand Up @@ -184,7 +213,7 @@ async function exprReceivers(x, y) {
.map((r) => r.referenceName)).toEqual([
'list().map', 'list', 'x.run',
...(typed ? ['x.run', 'y.run', 'x.stop', 'getTarget().install', 'getTarget', 'has'] : []),
'f', 'run', 'stop', 'go', 'start',
'f', 'run', 'stop', 'go', 'window.Api.start',
]);
});

Expand Down
2 changes: 1 addition & 1 deletion __tests__/route-inline-handler-calls.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -70,6 +70,6 @@ describe('inline route handler calls', () => {
});

it('reach the service a member call names', () => {
expect(routeCalls('/users/:id')).toContain('src/services.ts: lookup');
expect(routeCalls('/users/:id')).toEqual(['src/services.ts: userService::lookup']);
});
});
6 changes: 5 additions & 1 deletion __tests__/ts-chained-receiver.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -5,7 +5,8 @@
* symbol shared the name, so a storage wrapper's `get` called itself (#1707).
* Those calls stay unresolved, as do untyped identifier chains (#1566);
* their qualified source references remain available for effect reporting. The existing
* `window.MyNs.run()` and `this.<field>.m()` paths remain outside that guard.
* `window.MyNs.run()` and `this.<field>.m()` paths remain outside that guard; a
* `window.Known = { ping }` literal in the index names its own member (#2300).
*/

import { describe, it, expect, beforeAll, afterAll } from 'vitest';
Expand Down Expand Up @@ -44,6 +45,8 @@ beforeAll(async () => {
'export function viaGlobal(): string {\n' +
' return window.MyNs.ping();\n' +
'}\n' +
'window.Known = { ping };\n' +
'export function viaKnown(): string { return window.Known.ping(); }\n' +
'export class PingService { ping(): string { return "service"; } }\n' +
'export class Runner {\n' +
' constructor(private svc: PingService) {}\n' +
Expand Down Expand Up @@ -92,6 +95,7 @@ describe('TS/JS call through a host-global chain (#1707)', () => {
it('keeps a chain rooted at a project value — window.MyNs.m() and this.<field>.m()', () => {
const ping = fn('ping', 'service.ts').id;
expect(callTargets(fn('viaGlobal', 'service.ts').id)).toContain(ping);
expect(callTargets(fn('viaKnown', 'service.ts').id)).toEqual([ping]);
expect(callTargets(method('Runner::run').id)).toEqual([method('PingService::ping').id]);
});

Expand Down
5 changes: 2 additions & 3 deletions __tests__/ts-this-field-call.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -114,9 +114,8 @@ describe('this.<field>.<method>() (#1496)', () => {
});

it('resolves a field typed `typeof <objectLiteral>` onto the literal\'s member', () => {
// The members are bare-named functions inside the constant's extent (#1573).
expect(calleesOf('Keeper::settings')).toEqual(['getSettings']);
expect(calleesOf('Keeper::get')).toEqual(['get']);
expect(calleesOf('Keeper::settings')).toEqual(['DraftHubStorage::getSettings']);
expect(calleesOf('Keeper::get')).toEqual(['DraftHubStorage::get']);
const self = cg.getCallers(method('Keeper::get').id).some(({ node }) => node.id === method('Keeper::get').id);
expect(self).toBe(false);
});
Expand Down
20 changes: 11 additions & 9 deletions __tests__/vue-store-extraction.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -9,8 +9,8 @@
* - Vuex module: non-exported `const actions = {…}` / `const mutations = {…}`.
* - Pinia options: `defineStore({ actions: {…}, getters: {…} })`.
* - Pinia setup: `defineStore('id', () => { const foo = …; return { foo } })`.
* And the precision gate: a non-exported `const actions = {…}` in a file that
* isn't a Vue store contributes nothing.
* Plain named literals also have generic member definitions; their presence
* does not turn dynamic dispatch into a Vue store call.
*/
import { describe, it, expect, beforeEach, afterEach } from 'vitest';
import * as fs from 'node:fs';
Expand Down Expand Up @@ -111,9 +111,7 @@ export const useChatStore = defineStore('chat', () => {
cg.close?.();
});

it('does not extract a non-exported `const actions = {…}` outside a Vue store file', async () => {
// A plain module that happens to hold a non-exported `const actions` object of
// functions, but lacks any second Vue-store signal — the gate must not fire.
it('plain named actions have generic ownership without synthesizing dynamic Vue dispatch', async () => {
fs.writeFileSync(
path.join(dir, 'commands.js'),
`const actions = {
Expand All @@ -128,10 +126,14 @@ export function run(key) { return actions[key](); }
await cg.indexAll();
const db = (cg as any).db.db;

expect(db.prepare(`SELECT count(*) c FROM nodes WHERE name = 'doThing'`).get().c).toBe(0);
expect(db.prepare(`SELECT count(*) c FROM nodes WHERE name = 'doOther'`).get().c).toBe(0);
// The real exported function is still extracted normally.
expect(db.prepare(`SELECT count(*) c FROM nodes WHERE name = 'run' AND kind='function'`).get().c).toBeGreaterThan(0);
expect(db.prepare(`SELECT kind, qualified_name FROM nodes WHERE name IN ('doThing', 'doOther') ORDER BY qualified_name`).all())
.toEqual([
{ kind: 'function', qualified_name: 'actions::doOther' },
{ kind: 'function', qualified_name: 'actions::doThing' },
]);
const run = cg.getNodesByQualifiedName('run');
expect(run.map(n => n.kind)).toEqual(['function']);
expect(cg.getOutgoingEdgesFrom(run.map(n => n.id)).filter(e => e.kind === 'calls')).toEqual([]);

cg.close?.();
});
Expand Down
10 changes: 10 additions & 0 deletions codegraph-kernel/src/buffers.rs
Original file line number Diff line number Diff line change
Expand Up @@ -75,6 +75,9 @@ pub const META_SIZE: usize = 36;
pub const NODE_ROW_SIZE: usize = 96;
pub const EDGE_ROW_SIZE: usize = 44;
pub const REF_ROW_SIZE: usize = 40;
/// Byte offsets of a ref row's string fields (layout above), for `patch_ref_str`.
pub const REF_REFERENCE_NAME: usize = 16;
pub const REF_CANDIDATES: usize = 24;

/// Mirror of NODE_KINDS in src/types.ts — order is the wire contract.
pub const NODE_KINDS: [&str; 23] = [
Expand Down Expand Up @@ -315,6 +318,13 @@ impl Tables {
self.push_ref_flagged(r, 0);
}

/// Rewrite one string field of a ref row already pushed at `row_offset` in `refs`.
pub fn patch_ref_str(&mut self, row_offset: usize, field: usize, value: StrRef) {
let at = row_offset + field;
self.refs[at..at + 4].copy_from_slice(&value.0.to_le_bytes());
self.refs[at + 4..at + 8].copy_from_slice(&value.1.to_le_bytes());
}

pub fn push_ref_flagged(&mut self, r: &RefRow, flags: u8) {
let buf = &mut self.refs;
buf.extend_from_slice(&r.from_idx.to_le_bytes());
Expand Down
Loading