fix(js,ts): index the functions in every named object literal, not only exported ones (#2300) (#2363)

Issue #2300: a function written inside an object literal became a symbol
only when the object was an `export const`. A plain `const api = {...}`,
an object declared inside an IIFE or a function, and a namespace hung on
the page (`window.WS = {...}`, `ns.mod = {...}`) produced no member nodes,
and no node for `WS` either. Calls made inside those members were credited
to the enclosing constant or lost, and calls into them resolved to
nothing. Script-tag JavaScript, written almost entirely this way, was
mostly missing from callers and impact.

Cause: the TS/JS extractor, and the kernel's tsjs mirror of it, minted
members only for exported object-of-functions; any other literal was
walked as one opaque initializer, and an assignment to a member path was
never a declaration. Resolution had no way to reach a member through
`App.init()`, `window.App.init()` or `App.utils.pad()`.

Fix (extraction, TS and kernel byte-identical): a named object literal
owns its function members (method shorthand, `key: function`, `key: () =>`,
generators; static keys only), whether it is declared at module scope,
in a function body or IIFE, assigned to a path (`window.App = {...}`,
`App.utils = {...}`) or assigned to a plain name at module level
(`dw_page = {...}`). Members are `function` nodes qualified under the owner
(`api::load`, `window.App::init`, `App.utils::pad`), the exported case
included; a path owner is named by its last link and qualified by the
path. Calls in a member are the member's; other values are walked where
they were before. CommonJS exports, prototypes, `this.x = {...}`,
call-argument literals, a name reassigned inside a function, and
generated or minified files keep the old shape. Minified files are now
also recognised by content, in both extractors, and the kernel's
`.min.js` pattern matches the TS one.

Fix (resolution): a member is reached through its object only:
`App.init()`, `window.App.init()`, `App.utils.pad()`, a sibling's
`this.render()`, a `const { init } = App` binding; never a bare `init()`.
A same-file holder is chosen by lexical block (an IIFE's own `App` first);
otherwise a global one (`window.App = {...}`, or a classic script's
top-level `App`) when the caller neither imports nor binds the name.
Arrow members keep the `this` of the method around the literal, in the
`this.x` resolvers too. A dotted-path holder is never reached by its last
name alone, and Svelte's `$store` rule now applies only in `.svelte`
components, so the new local holders are not taken for `$n` in plain
scripts. The lookups read each file once (destructuring patterns only
when the raw text has a `} =`) and keep only the last 32 files' scans.

Contributor PR #2310 (@danusha2345): adopted its model (owner-qualified
members exported or not, path owners, IIFE/local owners, static keys,
no bare-name reach, `<script setup>` contains edges, SFC languages, test
scenarios), re-implemented on current main. Not taken: the
EXTRACTION_VERSION bump (already 28), the extraction-time binding oracle
with its ref/edge metadata and kernel ref patching (replaced by
resolution-time lookups), naming the owner `window.WS`, rewriting
`window.X.m()` ref names, flipping exported members' isExported, and
unrelated bare-call changes.

Verification: new js-object-literal-members suite (native + wasm, 18
tests); the issue's shapes fail on main and the guard test fails without
the guards. kernel-tsjs-parity passes with CODEGRAPH_KERNEL_EXPECT=1
(object-literal owners LF/CRLF, minified bundles); kernel parity sweeps
show 0 diffs on DokuWiki, vue-realworld, TodoMVC, excalidraw and this repo.

Validation (kernel loaded, before = origin/main 023fc311):
- DokuWiki: nodes +85, calls +105; 17 removed edges = 13 moved to the
  member, 4 re-resolved from a wrong LinkWizard::init to the right init.
- TodoMVC: nodes +427, calls +148; 306 removed = 226 moved, 33
  re-resolved (14 fixed `this.render()`/`this.save()`/`this.track()`, 19
  wrong-to-wrong fallback guesses), 47 dropped wrong edges (43 to a
  helper local to jQuery Mobile's scrollstart setup).
- excalidraw: nodes +86, calls +75; 34 removed = 33 moved, 1 fuzzy 0.3
  guess dropped.
- vue-realworld: nodes +10, calls +11; 7 calls re-resolved from the
  ApiService constant to its members.
New self-loops are real recursion, bar one lazily redefined method that
calls itself as written. 81 sampled added edges, 80 correct
(the other is main's own window.open guess, re-attributed). Whole-run
index time, median of 3 interleaved: vue-realworld 1.2s -> 1.2s,
DokuWiki 8.6s -> 9.1s, excalidraw 10.8s -> 10.7s, TodoMVC 16.9s -> 16.9s
(within run-to-run noise).

Co-authored-by: danusha2345 <ewidusoc498@gmail.com>
Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
Colby Mchenry
2026-10-05 22:45:43 +00:00
committed by GitHub
parent 023fc3111b
commit f40db4b9cc
16 changed files with 1496 additions and 62 deletions
+1
View File
@@ -39,6 +39,7 @@ and adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
- The time `codegraph init` and `codegraph index` print beside the node and edge counts now covers the whole run, resolving references and linking included, and `codegraph sync` reports its whole run the same way. Before, it counted only reading and parsing the files, which can be a small part of an index, so a slow index looked fast. Thanks @bompus for the report. (#2334)
- In Rust, code that uses an enum only through its variants, like `mode::Mode::A` in an expression, a `Mode::B =>` match arm or `Self::A` inside the enum's own `impl`, now shows up in that enum's callers and impact, linked to the enum that actually declares the variant rather than to a same-named type elsewhere. Standard-library variants like `Ordering::Less` or `Option::Some` and associated items like `Foo::new()` or `Foo::MAX` don't count as using a project enum. Re-index Rust projects after upgrading. Thanks @mg-mg-mg for the report and @danusha2345. (#2328)
- Axum routes now link to their handler when rustfmt wraps the `.route(` call so the handler sits on a later line, and when the handler's name also appears elsewhere on the line, as in `delete(handlers::user::delete)` or `.route("/login", post(handlers::user::login))`; Actix's `web::resource(…)` and `.route(…)` registrations get the same fix. A call inside a closure handler is no longer mistaken for a route, and an Actix resource no longer takes the next route's handler as its own. Thanks @mg-mg-mg for the report and @danusha2345. (#2326)
- In JavaScript and TypeScript, the functions written inside a named object literal are now symbols of their own even when the object isn't exported: a plain `const api = { load() {…} }`, an object declared inside an IIFE or a function, and a namespace hung on the page or on another object, like `window.App = { init() {…} }`, `App.utils = {…}` or `dw_page = {…}`. Calls such as `api.load()`, `window.App.init()`, `App.utils.pad()`, a sibling's `this.render()`, and `App.init()` from another script on the page now reach them, the calls made inside a member belong to that member instead of the object, and a bare `init()` is no longer taken for `App.init`, so script-tag apps no longer lose most of their code from `callers` and impact. A member's qualified name now carries its object, exported or not (`api::load`, `window.App::init`), so asking for `App.init` finds it. Re-index JavaScript and TypeScript projects after upgrading. Thanks @tkhoaaa for the report and @danusha2345. (#2300)
## [1.6.2] - 2026-10-03
+6 -6
View File
@@ -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();
};
@@ -1191,14 +1191,14 @@ export const exported = { handler: () => target() };
});
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.
// A named literal's function members are its own nodes, exported or not
// (#2300), so each arrow's call comes from its `handler`; the literal's
// eager value and the array's arrow attribute to the declared constant.
expect(callersOf('target')).toEqual([
'constant:list',
'constant:obj',
'constant:obj',
'function:handler',
'function:exported::handler',
'function:obj::handler',
]);
});
});
+2 -2
View File
@@ -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']);
+320
View File
@@ -0,0 +1,320 @@
/**
* A named object literal owns its function members (#2300). Members used to
* become nodes only when the object was an `export const`; a plain `const`, an
* object inside an IIFE and one hung on a path (`window.WS = {…}`) produced
* none, so script-tag JavaScript was nearly invisible and the calls inside a
* member were credited to the enclosing constant (or lost).
*
* Now each member is a `function` node qualified under its owner
* (`store::shorthand`, `window.WS::wsM`), the calls written in it are its own,
* and the calls that reach it — `App.init()`, `window.App.init()`, a sibling's
* `this.render()`, `App.utils.pad()`, a classic script's `WS.wsM()` from
* another file — resolve to it. A bare `init()` never does.
*/
import { describe, it, expect, beforeEach, afterEach, vi } from 'vitest';
import * as fs from 'fs';
import * as os from 'os';
import * as path from 'path';
import { CodeGraph } from '../src';
import { getKernel, resetKernelForTests } from '../src/extraction/kernel';
const kernelBuilt = fs.existsSync(path.join(__dirname, '../codegraph-kernel/prebuilds',
`${process.platform}-${process.arch}`, 'codegraph-kernel.node'));
describe.each(['native', 'wasm'].filter((backend) => backend === 'wasm' || kernelBuilt))(
'object-literal members (%s, #2300)', (backend) => {
let dir: string;
let cg: CodeGraph | undefined;
beforeEach(() => {
vi.stubEnv('CODEGRAPH_KERNEL', backend === 'wasm' ? '0' : '1');
vi.stubEnv('CODEGRAPH_KERNEL_LANGS', 'all');
resetKernelForTests();
if (backend === 'native') expect(getKernel()).not.toBeNull();
dir = fs.mkdtempSync(path.join(os.tmpdir(), 'cg-2300-'));
});
afterEach(() => {
cg?.close();
cg = undefined;
fs.rmSync(dir, { recursive: true, force: true });
vi.unstubAllEnvs();
resetKernelForTests();
});
async function index(files: Record<string, string>): Promise<CodeGraph> {
for (const [rel, body] of Object.entries(files)) {
fs.mkdirSync(path.dirname(path.join(dir, rel)), { recursive: true });
fs.writeFileSync(path.join(dir, rel), body);
}
cg = await CodeGraph.init(dir, { index: true });
return cg;
}
/** The one node with this qualified name (in `file`, when several share it). */
function node(g: CodeGraph, qualifiedName: string, file?: string) {
const found = g.getNodesByQualifiedName(qualifiedName).filter((n) => !file || n.filePath === file);
expect(found, qualifiedName).toHaveLength(1);
return found[0]!;
}
/** The qualified names at the other end of a node's `calls` edges. */
const calls = (g: CodeGraph, id: string, direction: 'in' | 'out') =>
(direction === 'in' ? g.getIncomingEdges(id) : g.getOutgoingEdges(id))
.filter((e) => e.kind === 'calls')
.map((e) => g.getNode(direction === 'in' ? e.source : e.target)!.qualifiedName)
.sort();
const callers = (g: CodeGraph, qn: string, file?: string) => calls(g, node(g, qn, file).id, 'in');
const callees = (g: CodeGraph, qn: string, file?: string) => calls(g, node(g, qn, file).id, 'out');
it('gives every container shape from the issue its members, and the calls in a member to that member', async () => {
const g = await index({
'src/a_export.ts': 'export const objTsExp = { tsExpM() { return 1; }, tsExpProp: () => 1 };\n',
'src/b_plain.ts': 'const objTsPlain = { tsPlainM() { return 1; }, tsPlainProp: () => 1 };\n',
'src/c_export.js': 'export const objJsExp = { jsExpM() { return 1; }, jsExpProp: () => 1 };\n',
'src/d_plain.js': 'const objJsPlain = { jsPlainM() { return 1; }, jsPlainProp: () => 1 };\n',
'src/e_iife.js': [
'(function () {',
' const objIife = { iifeM() { return 1; } };',
' window.WS = { wsM() { return objIife.iifeM(); } };',
'})();',
'',
].join('\n'),
'src/f_calls.js': [
'function helper() { return 2; }',
'var store = { shorthand() { return helper(); } };',
'function useStore() { return store.shorthand(); }',
'window.api = { load() { return store.shorthand(); } };',
'function viaWindow() { return window.WS.wsM(); }',
'function viaGlobal() { return WS.wsM(); }',
'',
].join('\n'),
});
for (const qn of [
'objTsExp::tsExpM', 'objTsExp::tsExpProp', 'objTsPlain::tsPlainM', 'objTsPlain::tsPlainProp',
'objJsExp::jsExpM', 'objJsExp::jsExpProp', 'objJsPlain::jsPlainM', 'objJsPlain::jsPlainProp',
'objIife::iifeM', 'window.WS::wsM', 'store::shorthand', 'window.api::load',
]) {
expect(node(g, qn).kind, qn).toBe('function');
}
// The member belongs to its owner, and `window.WS` is the global `WS`.
const plain = node(g, 'objTsPlain');
expect(g.getOutgoingEdges(plain.id).filter((e) => e.kind === 'contains').map((e) => g.getNode(e.target)!.name).sort())
.toEqual(['tsPlainM', 'tsPlainProp']);
expect(g.getNodesByName('WS').map((n) => `${n.kind} ${n.qualifiedName}`)).toEqual(['variable window.WS']);
// An exported literal's members stay exported, a plain one's do not.
expect(node(g, 'objTsExp::tsExpM').isExported).toBe(true);
expect(node(g, 'objTsPlain::tsPlainM').isExported).toBeFalsy();
// The call inside a member is the member's, not the constant's.
expect(callers(g, 'helper')).toEqual(['store::shorthand']);
expect(callers(g, 'objIife::iifeM')).toEqual(['window.WS::wsM']);
expect(callers(g, 'store::shorthand')).toEqual(['useStore', 'window.api::load']);
// `window.WS.wsM()`, and `WS.wsM()` from another classic script.
expect(callers(g, 'window.WS::wsM')).toEqual(['viaGlobal', 'viaWindow']);
});
it('resolves through the object the call names — never by the bare name', async () => {
const g = await index({
'app.js': [
'function init() { return "global"; }',
'var App = {',
' init: function () { this.render(); helpers.fmt(); },',
' render: function () { return 1; },',
'};',
'var helpers = { fmt() { return 2; } };',
'App.utils = { pad(s) { return s; } };',
'function boot() { App.init(); App.utils.pad("x"); init(); }',
'dw_page = { start() { App.render(); } };',
'',
].join('\n'),
'page.js': [
'function main() { App.init(); window.App.render(); dw_page.start(); App.utils.pad("y"); }',
'function shadow(App) { return App.init(); }',
'function bare() { return render(); }',
'',
].join('\n'),
});
expect(callees(g, 'boot')).toEqual(['App.utils::pad', 'App::init', 'init']);
expect(callees(g, 'App::init')).toEqual(['App::render', 'helpers::fmt']);
expect(callees(g, 'dw_page::start')).toEqual(['App::render']);
// Another script reaches the page's globals; a parameter of the same name does not.
expect(callees(g, 'main')).toEqual(['App.utils::pad', 'App::init', 'App::render', 'dw_page::start']);
expect(callees(g, 'shadow')).toEqual([]);
// `render()` alone is not `App.render`, and `init()` stays the global function.
expect(callees(g, 'bare')).toEqual([]);
expect(callers(g, 'init')).toEqual(['boot']);
});
it('never takes an object hung on a path, or one local to an IIFE, for a name elsewhere', async () => {
const g = await index({
// A Svelte project, where `$count` reads the store `count` — in a `.svelte` component.
'package.json': '{ "dependencies": { "svelte": "^4.0.0" } }\n',
'lib.js': [
'$.event.special.swipe = { setup: function () { return 1; } };',
'todos.model = { add: function () { return 2; } };',
'(function () { var n = { touch: function () { return 3; } }; n.touch(); })();',
'',
].join('\n'),
'widget.js': [
'var Widget = {',
' drag: function () { this.swipe(1); },',
' make: function () { return new this.model(); },',
'};',
'',
].join('\n'),
// Two compiled scripts, each with its own `$n`.
'gwt1.js': 'function $n(a) { this.a = a; }\nfunction make1() { return new $n(1); }\n',
'gwt2.js': 'function $n(a) { this.a = a; }\nfunction make2() { return new $n(2); }\n',
});
const edges = (qn: string) => g.getOutgoingEdges(node(g, qn).id).filter((e) => e.kind !== 'contains')
.map((e) => `${e.kind} ${g.getNode(e.target)!.qualifiedName} ${g.getNode(e.target)!.filePath}`);
// `this.swipe()` and `new this.model()` are not the objects at `$.event.special.swipe` and `todos.model`.
expect(edges('Widget::drag')).toEqual([]);
expect(edges('Widget::make')).toEqual([]);
// A plain script's `$n` is its own function, not a store read of the IIFE's `n`.
expect(edges('make1')).toEqual(['instantiates $n gwt1.js']);
expect(edges('make2')).toEqual(['instantiates $n gwt2.js']);
});
it('keeps each IIFE and function its own literal, and a module its private ones', async () => {
const g = await index({
'iife.js': [
'(function () { const Api = { run() { left(); } }; Api.run(); })();',
'(function () { const Api = { run() { right(); } }; Api.run(); })();',
'function left() {}',
'function right() {}',
'function outside() { return Api.run(); }',
'function a() { const local = { go() { left(); } }; local.go(); }',
'function b() { const local = { go() { right(); } }; local.go(); }',
'',
].join('\n'),
'mod.js': 'import { x } from "./x.js";\nconst Local = { run() { return x; } };\nexport function useLocal() { return Local.run(); }\n',
'other.js': 'import { y } from "./y.js";\nexport function nope() { return Local.run(y); }\n',
});
const [first, second] = g.getNodesByQualifiedName('Api::run').sort((p, q) => p.startLine - q.startLine);
expect(calls(g, first!.id, 'out')).toEqual(['left']);
expect(calls(g, second!.id, 'out')).toEqual(['right']);
// Each IIFE's `Api.run()` reaches its own literal (the file runs both).
expect(calls(g, first!.id, 'in')).toEqual(['iife.js']);
expect(calls(g, second!.id, 'in')).toEqual(['iife.js']);
// Outside both IIFEs, `Api` is neither of them — not even as a value read.
expect(callees(g, 'outside')).toEqual([]);
expect(g.getOutgoingEdges(node(g, 'outside').id).filter((e) => e.kind !== 'contains')).toEqual([]);
expect(callees(g, 'a')).toEqual(['a::local::go']);
expect(callees(g, 'b')).toEqual(['b::local::go']);
expect(callees(g, 'useLocal')).toEqual(['Local::run']);
expect(callees(g, 'nope')).toEqual([]);
});
it('gives arrow members and values the `this` of the method around the literal', async () => {
const g = await index({
'app.ts': [
'class Emitter { on(cb: () => void) { return cb; } }',
'export class App {',
' private emitter = new Emitter();',
' updateScene() { return 1; }',
' createApi() {',
' const api = {',
' updateScene: this.updateScene,',
' onChange: (cb: () => void) => this.emitter.on(cb),',
' refresh: () => this.updateScene(),',
' own() { return this.refresh(); },',
' };',
' return api;',
' }',
'}',
'',
].join('\n'),
});
// A value in a method's literal is that method's own reference, as before.
const createApi = node(g, 'App::createApi');
expect(g.getOutgoingEdges(createApi.id).filter((e) => e.kind === 'references')
.map((e) => g.getNode(e.target)!.qualifiedName)).toContain('App::updateScene');
// An arrow member's `this` is the class instance…
expect(callees(g, 'App::createApi::api::onChange')).toEqual(['Emitter::on']);
expect(callees(g, 'App::createApi::api::refresh')).toEqual(['App::updateScene']);
// …an object's own method's `this` is the object.
expect(callees(g, 'App::createApi::api::own')).toEqual(['App::createApi::api::refresh']);
});
it('keeps a cross-file member edge through edits of the defining script', async () => {
const g = await index({
'ns.js': 'window.WS = { a() { return 1; }, wsM() { return 2; } };\n',
'use.js': 'function viaGlobal() { return WS.wsM(); }\n',
});
expect(callers(g, 'window.WS::wsM')).toEqual(['viaGlobal']);
fs.writeFileSync(path.join(dir, 'ns.js'), '// moved\nwindow.WS = {\n wsM() { return 3; },\n};\n');
await g.sync();
expect(callers(g, 'window.WS::wsM')).toEqual(['viaGlobal']);
fs.writeFileSync(path.join(dir, 'ns.js'), 'window.WS = { renamed() { return 4; } };\n');
await g.sync();
expect(g.getNodesByQualifiedName('window.WS::wsM')).toEqual([]);
expect(callees(g, 'viaGlobal')).toEqual([]);
}, 30_000);
it('reaches an imported literal and a binding destructured off one', async () => {
const g = await index({
'api.js': [
'export const api = { get() { return 1; }, post() { return 2; } };',
'const { post } = api;',
'export function b() { return post(); }',
'export function c() { return get(); }',
'',
].join('\n'),
'use.js': [
'import { api } from "./api.js";',
'export function a() { return api.get(); }',
'',
].join('\n'),
});
expect(callees(g, 'a')).toEqual(['api::get']);
expect(callees(g, 'b')).toEqual(['api::post']);
// `get` was never destructured: a bare `get()` is not `api.get`.
expect(callees(g, 'c')).toEqual([]);
});
it('leaves CommonJS exports, prototypes, call arguments, data and minified bundles as they were', async () => {
const g = await index({
'shapes.js': [
'module.exports = { cjs() { return 1; } };',
'Foo.prototype = { proto() { return 1; } };',
'consume({ ephemeral() { return 1; } });',
'const data = { a: 1, b: [1, 2] };',
'this.state = { onThis() { return 1; } };',
'',
].join('\n'),
'vendor/lib.min.js': 'var a={b:function(){return 1},c:function(){return 2}};window.L={d:function(){return a.b()}};\n',
// A bundle not named so: its lines are what gives it away.
'dist/bundle.js': `${'var q={b:function(){return r(1,2)},c:function(s){return s}};window.M={d:function(){return q.b()}};'.repeat(60)}\n`.repeat(2),
'iife.js': '(function () { var e; if (!e) { e = { getItem: function () { return 1; } }; } })();\n',
});
// …and a plain name reassigned inside a function (a bundle's `e = {…}`) is a local.
for (const name of ['cjs', 'proto', 'ephemeral', 'onThis', 'b', 'c', 'd', 'getItem']) {
expect(g.getNodesByName(name), name).toEqual([]);
}
expect(node(g, 'data').kind).toBe('constant');
});
it('keeps a <script setup> literal\'s members under their owner', async () => {
const g = await index({
'Panel.vue': [
'<template><button @click="save">Save</button></template>',
'<script setup>',
'const handlers = { onSave() { persist(); } };',
'function persist() {}',
'function save() { handlers.onSave(); }',
'</script>',
'',
].join('\n'),
});
const owner = node(g, 'handlers');
const member = node(g, 'handlers::onSave');
expect(g.getIncomingEdges(member.id).filter((e) => e.kind === 'contains').map((e) => e.source)).toEqual([owner.id]);
expect(callees(g, 'handlers::onSave')).toEqual(['persist']);
expect(callees(g, 'save')).toEqual(['handlers::onSave']);
});
});
+37
View File
@@ -96,6 +96,43 @@ describe.skipIf(!kernelBuilt)('kernel TS/JS extraction parity', () => {
return viaWasm;
}
it.each([
['ts', 'typescript'], ['tsx', 'tsx'], ['js', 'javascript'], ['jsx', 'jsx'],
] as const)('named object literals own their members: %s (#2300)', (ext, language) => {
const source = [
'/* é😀 */ const Api = { read() { helper(); }, close: () => helper(), gen: function* () { yield 1; }, \'quoted-key\': function () {}, [dyn()]() { helper(); }, eager: helper(), alias: helper, helper };',
'function helper() {}',
'/** Saved. */',
'export const Exported = { save() { helper(); } };',
'(function () { const Local = { run() { helper(); } }, data = { x: 1 }; Local.run(); window.WS = { ws() { Local.run(); } }; })();',
'App.utils = { pad(s) { return s; } };',
'// The page.',
'dw_page = { start() { helper(); } };',
'function setup() { ns.late = { go() {} }; let h; h = { on() {} }; }',
'module.exports = { cjs() {} };',
'Foo.prototype = { proto() {} };',
'self.handlers = { click() {} };',
'consume({ ephemeral() {} });',
'',
].join('\n');
for (const ending of ['\n', '\r\n']) {
const result = assertParity(`literal.${ext}`, source.replace(/\n/g, ending), language);
expect(result.nodes.map((n) => n.qualifiedName)).toEqual(expect.arrayContaining([
'Api::read', 'Api::close', 'Api::gen', 'Api::quoted-key', 'Exported::save', 'Local::run', 'window.WS',
'window.WS::ws', 'App.utils::pad', 'dw_page::start', 'ns.late::go', 'self.handlers::click',
]));
// A plain name reassigned inside a function is a local, not a namespace.
for (const name of ['cjs', 'proto', 'ephemeral', 'data', 'on']) expect(result.nodes.some((n) => n.name === name), name).toBe(false);
}
});
it.each(['bundle.js', 'vendor-min.js', 'vendor.min.js'])('a minified bundle keeps its literals unowned: %s (#2300)', (file) => {
// Mostly long lines dense with code punctuation, as a minifier writes them.
const line = `var a={b:function(){return c(1,2)},d:function(e){return e}};window.L={f:function(){return a.b()}};`.repeat(40);
const result = assertParity(file, `${line}\n${line}\n`, 'javascript');
expect(result.nodes.some((n) => n.kind === 'function' && (n.name === 'b' || n.name === 'f'))).toBe(false);
});
it.each([
['ts', 'typescript'], ['tsx', 'tsx'], ['js', 'javascript'], ['jsx', 'jsx'],
] as const)('same-line accessors retain distinct identities after Unicode: %s (#1349)', (ext, language) => {
+1 -1
View File
@@ -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')).toContain('src/services.ts: userService::lookup');
});
});
+4 -3
View File
@@ -114,9 +114,10 @@ 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']);
// The members are functions inside the constant's extent (#1573), qualified
// under it (#2300).
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);
});
+14 -6
View File
@@ -10,7 +10,8 @@
* - 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.
* isn't a Vue store is not treated as one. (Its members are nodes anyway, as
* every named object literal's are since #2300 — `actions::doThing`.)
*/
import { describe, it, expect, beforeEach, afterEach } from 'vitest';
import * as fs from 'node:fs';
@@ -111,9 +112,11 @@ export const useChatStore = defineStore('chat', () => {
cg.close?.();
});
it('does not extract a non-exported `const actions = {…}` outside a Vue store file', async () => {
it('does not treat a non-exported `const actions = {…}` outside a Vue store file as a store', 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.
// The literal's members are its own nodes (#2300), and nothing reads the
// dynamic `actions[key]()` as a call to one of them.
fs.writeFileSync(
path.join(dir, 'commands.js'),
`const actions = {
@@ -128,10 +131,15 @@ 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' },
]);
// The real exported function is still extracted normally, and calls nothing.
const run = cg.getNodesByQualifiedName('run');
expect(run.map((n) => n.kind)).toEqual(['function']);
expect(cg.getOutgoingEdges(run[0]!.id).filter((e) => e.kind === 'calls')).toEqual([]);
cg.close?.();
});
+51 -1
View File
@@ -55,7 +55,7 @@ pub fn is_generated_file(file_path: &str) -> bool {
r"\.pb\.[jt]s$",
r"_pb\.[jt]s$",
r"_grpc_pb\.[jt]s$",
r"\.min\.m?js$",
r"[.-]min\.m?js$",
r"_pb2(_grpc)?\.py$",
r"_pb2\.pyi$",
r"\.pb\.(cc|h)$",
@@ -78,6 +78,56 @@ pub fn is_generated_file(file_path: &str) -> bool {
patterns.iter().any(|p| p.is_match(file_path))
}
// isMinifiedContent's WEBPACK_RUNTIME (`\b` ASCII, as in JS).
re!(webpack_runtime, r"(?-u:\b)function __webpack_require__\s*\(");
/// isMinifiedContent (src/extraction/generated-detection.ts): a `.js` / `.mjs`
/// / `.cjs` file of at least 4000 UTF-16 units whose text sits mostly on lines
/// of 1000 units or more, dense with `;{}(),` — or that carries a webpack
/// runtime. Lengths are counted in UTF-16 units, as the JS string ops count
/// them, so both extractors agree on every file.
pub fn is_minified_content(file_path: &str, content: &str) -> bool {
const LINE: usize = 1000;
let lower = file_path.to_ascii_lowercase();
if !(lower.ends_with(".js") || lower.ends_with(".mjs") || lower.ends_with(".cjs")) {
return false;
}
// UTF-16 units never outnumber UTF-8 bytes: a short file is out at once.
if content.len() < 4 * LINE {
return false;
}
let total = content.encode_utf16().count();
if total < 4 * LINE {
return false;
}
if webpack_runtime().is_match(content) {
return true;
}
let (mut long, mut punctuation) = (0usize, 0usize);
let (mut line_len, mut line_punct) = (0usize, 0usize);
for unit in content.encode_utf16() {
if unit == 10 {
if line_len >= LINE {
long += line_len;
punctuation += line_punct;
}
line_len = 0;
line_punct = 0;
continue;
}
line_len += 1;
// ; { } ( ) ,
if matches!(unit, 59 | 123 | 125 | 40 | 41 | 44) {
line_punct += 1;
}
}
if line_len >= LINE {
long += line_len;
punctuation += line_punct;
}
long as f64 >= total as f64 * 0.5 && punctuation as f64 >= long as f64 * 0.03
}
/// Byte offsets of each line start, for UTF-16 column conversion.
pub fn line_starts(src: &str) -> Vec<usize> {
let mut out = vec![0usize];
+194 -2
View File
@@ -5,8 +5,8 @@
use crate::textutil as util;
use super::{
body_of, is_builtin_type, is_literal_receiver, is_react_hoc, is_variable_type,
is_vue_collection_name, Extra, Scope, Walker,
body_of, is_builtin_type, is_host_global_root, is_literal_receiver, is_object_member_function,
is_react_hoc, is_static_object_key, is_variable_type, is_vue_collection_name, Extra, Scope, Walker,
};
use crate::buffers::edge_kind_index;
use tree_sitter::Node;
@@ -449,6 +449,15 @@ impl<'t> Walker<'t> {
self.extract_variable_type_annotation(child, row);
}
// A named object literal owns its function members, exported or
// not (#2300): `const api = { load() {…} }` gives `api::load`. The
// rest of the literal is walked under the owner (#693). Mirrors
// the ownedObject branch of TreeSitterExtractor.extractVariable.
if let (Some(row), Some(obj)) = (var_row, self.owned_object_value(value)) {
self.extract_owned_object_members(obj, row, kind, &name, true, false);
continue;
}
// Exported const object-of-functions / store shapes.
let object_of_fns: Option<Node> = match value {
Some(v) if matches!(v.kind(), "object" | "object_expression") => Some(v),
@@ -552,6 +561,189 @@ impl<'t> Walker<'t> {
}
}
// --- owned object literals (#2300) ------------------------------------------------
/// ownsObjectLiterals: a generated or minified bundle — named so or not —
/// keeps the old shape. (Every tsjs variant is TS/JS, so only that check
/// remains; it is computed once per file.)
fn owns_object_literals(&self) -> bool {
self.owns_objects
}
/// ownedMemberFunction: the function an owned literal's member becomes,
/// named by its static key — `load() {…}`, `load: () => {…}`,
/// `load: function () {…}`, `load: function* () {…}`. None for every other
/// member (a computed key, a value, a shorthand, a spread).
fn owned_member_function(&self, member: Node<'t>) -> Option<(Node<'t>, String)> {
if member.kind() == "method_definition" {
let key = member.child_by_field_name("name")?;
if !is_static_object_key(key.kind()) {
return None;
}
return Some((member, util::object_key_name(self.text(key))));
}
if member.kind() != "pair" {
return None;
}
let key = member.child_by_field_name("key")?;
let value = member.child_by_field_name("value")?;
if !is_static_object_key(key.kind()) || !is_object_member_function(value.kind()) {
return None;
}
Some((value, util::object_key_name(self.text(key))))
}
/// ownedObjectValue: `value` when it is an object literal owning at least
/// one function member.
pub(super) fn owned_object_value(&self, value: Option<Node<'t>>) -> Option<Node<'t>> {
let value = value?;
if !matches!(value.kind(), "object" | "object_expression") || !self.owns_object_literals() {
return None;
}
for i in 0..value.named_child_count() {
if let Some(member) = value.named_child(i) {
if self.owned_member_function(member).is_some() {
return Some(value);
}
}
}
None
}
/// Put an owner on the stack — on top of the file alone for one on the
/// global object — returning what was set aside for leave_owner.
fn enter_owner(&mut self, row: u32, kind: &'static str, name: &str, global: bool) -> Vec<Scope> {
let rest = if global { self.stack.split_off(1) } else { Vec::new() };
self.stack.push(Scope { row, kind, name: name.to_string() });
rest
}
fn leave_owner(&mut self, rest: Vec<Scope>) {
self.stack.pop();
self.stack.extend(rest);
}
/// extractOwnedObjectMembers: each function member becomes its own node,
/// qualified under the owner (at file scope for one on the global object).
/// Every other member runs where the literal is written: under the owner
/// for a module-scope declaration (`values_under_owner`, #693), under the
/// enclosing scope for a local or an assignment. The literal's shorthand
/// members go to the function-as-value capture the same way.
fn extract_owned_object_members(&mut self, obj: Node<'t>, row: u32, kind: &'static str, name: &str, values_under_owner: bool, global: bool) {
let held = values_under_owner.then(|| self.enter_owner(row, kind, name, global));
self.maybe_capture_fn_refs(obj);
for i in 0..obj.named_child_count() {
let Some(member) = obj.named_child(i) else { continue };
if let Some((func, member_name)) = self.owned_member_function(member) {
if values_under_owner {
self.extract_function(func, Some(member_name));
} else {
let rest = self.enter_owner(row, kind, name, global);
self.extract_function(func, Some(member_name));
self.leave_owner(rest);
}
} else {
self.visit_function_body(member);
}
}
if let Some(rest) = held {
self.leave_owner(rest);
}
}
/// extractLocalObjectOwner: `const api = { load() {…} }` in a function body
/// or an IIFE gets the node a module-scope declaration would, with its
/// members under it. False when the declarator holds no owned literal.
pub(super) fn extract_local_object_owner(&mut self, declarator: Node<'t>) -> bool {
let Some(name_node) = declarator.child_by_field_name("name") else { return false };
if name_node.kind() != "identifier" {
return false;
}
let Some(obj) = self.owned_object_value(declarator.child_by_field_name("value")) else {
return false;
};
let is_const = declarator.parent().map(|d| self.is_const_decl(d)).unwrap_or(false);
let kind: &'static str = if is_const { "constant" } else { "variable" };
let name = self.text(name_node).to_string();
// A local: never the target of another scope's value read.
let extra = Extra {
docstring: crate::docstring::preceding_docstring(declarator, self.src),
signature: Some(util::init_signature(self.text(obj))),
is_exported: Some(false),
not_value_target: true,
..Extra::default()
};
let Some(row) = self.create_node(kind, &name, declarator, extra) else { return false };
self.extract_variable_type_annotation(declarator, row);
self.extract_owned_object_members(obj, row, kind, &name, false, false);
true
}
/// extractAssignedObjectOwner: `window.App = {…}` / `App.utils = {…}` /
/// `dw_page = {…}`. A plain identifier target is qualified like a
/// declaration. A path's owner is named by its last link and qualified by
/// the path as written, and so are its members (`window.App::init`); a
/// path on the global object is global wherever it is written, so it is
/// contained by the file. False (nothing extracted) for any other assignment.
pub(super) fn extract_assigned_object_owner(&mut self, node: Node<'t>, module_level: bool) -> bool {
let Some(obj) = self.owned_object_value(node.child_by_field_name("right")) else { return false };
let Some(path) = self.object_owner_path(node.child_by_field_name("left")) else { return false };
// A plain name assigned at module level is a global (`dw_page = {…}`);
// in a function it is a local being reassigned.
if path.len() == 1 && !module_level {
return false;
}
let qualified_name = (path.len() > 1).then(|| path.join("."));
let global = path.len() > 1 && is_host_global_root(&path[0]);
let rest = if global { self.stack.split_off(1) } else { Vec::new() };
let statement = node.parent().filter(|p| p.kind() == "expression_statement").unwrap_or(node);
let name = path.last().cloned().unwrap_or_default();
// `App.utils` is read as `App.utils`, never as a bare `utils`.
let extra = Extra {
docstring: crate::docstring::preceding_docstring(statement, self.src),
signature: Some(util::init_signature(self.text(obj))),
is_exported: Some(false),
qualified_name: qualified_name.clone(),
not_value_target: !(path.len() == 1 || global),
..Extra::default()
};
let row = self.create_node("variable", &name, node, extra);
self.stack.extend(rest);
let Some(row) = row else { return false };
if let Some(qualified_name) = qualified_name {
self.object_path_owners.insert(row, qualified_name);
}
self.extract_owned_object_members(obj, row, "variable", &name, false, global);
true
}
/// objectOwnerPath: an assignment target's links written as plain names
/// (`dw_page` → [dw_page], `App.utils.dom` → [App, utils, dom]), or None
/// for anything else — `this.x`, `a[k]`, a call, CommonJS's
/// `module.exports` / `exports.x`, or a prototype.
fn object_owner_path(&self, left: Option<Node<'t>>) -> Option<Vec<String>> {
let mut path: Vec<String> = Vec::new();
let mut cur = left?;
while cur.kind() == "member_expression" {
let property = cur.child_by_field_name("property")?;
if property.kind() != "property_identifier" {
return None;
}
path.push(self.text(property).to_string());
cur = cur.child_by_field_name("object")?;
}
if cur.kind() != "identifier" {
return None;
}
let root = self.text(cur);
if root == "module" || root == "exports" || path.iter().any(|p| p == "prototype") {
return None;
}
path.push(root.to_string());
path.reverse();
Some(path)
}
// --- object-literal / store helpers -------------------------------------------------
pub(super) fn extract_object_literal_functions(&mut self, obj: Node<'t>) {
+57 -3
View File
@@ -136,6 +136,24 @@ fn is_vue_collection_name(name: &str) -> bool {
matches!(name, "actions" | "mutations" | "getters")
}
/// OBJECT_MEMBER_FUNCTION_TYPES (tree-sitter.ts, #2300): the function values an
/// owned object literal's member can hold.
fn is_object_member_function(kind: &str) -> bool {
matches!(kind, "arrow_function" | "function_expression" | "generator_function")
}
/// STATIC_OBJECT_KEY_TYPES (tree-sitter.ts, #2300): keys that name a member — a
/// computed `[expr]` key names nothing static.
fn is_static_object_key(kind: &str) -> bool {
matches!(kind, "property_identifier" | "string" | "number")
}
/// HOST_GLOBAL_ROOTS (tree-sitter.ts, #2300): `window.App = {…}` defines the
/// global `App`. (`self` is usually `var self = this` in page code, not the global.)
fn is_host_global_root(name: &str) -> bool {
matches!(name, "window" | "globalThis")
}
/// One scope-stack entry (TS keeps node IDs; rows are our equivalent).
struct Scope {
row: u32,
@@ -153,6 +171,9 @@ struct Extra {
is_async: Option<bool>,
is_static: Option<bool>,
qualified_name: Option<String>,
/// Never a value-read target: a local, or an object hung on a dotted path
/// (createNode's `valueTarget = false`, #2300).
not_value_target: bool,
}
struct ValueScope<'t> {
@@ -185,6 +206,12 @@ pub struct Walker<'t> {
fs_value_counts: HashMap<String, u32>,
value_scopes: Vec<ValueScope<'t>>,
vue_store_file: Option<bool>,
/// An object literal hung on a path (`window.App = {…}`) is qualified by
/// the path, and so is what it holds (#2300): row → that qualified name.
/// Mirrors TreeSitterExtractor.objectPathOwners.
object_path_owners: HashMap<u32, String>,
/// ownsObjectLiterals: false for a generated or minified bundle (#2300).
owns_objects: bool,
}
const MAX_VALUE_REF_NODES: usize = 20_000;
@@ -234,6 +261,8 @@ pub fn extract(file_path: &str, source: &str, language: &str) -> Result<EmitOut,
fs_value_counts: HashMap::new(),
value_scopes: Vec::new(),
vue_store_file: None,
object_path_owners: HashMap::new(),
owns_objects: !util::is_generated_file(file_path) && !util::is_minified_content(file_path, source),
};
// File node (TreeSitterExtractor.extract): id `file:<path>`, endLine =
@@ -352,6 +381,7 @@ impl<'t> Walker<'t> {
let start_line = self.line_of(node);
let column = self.col_of(node);
let id = self.node_id_allocator.generate(self.file_path, kind, name, start_line, column);
let value_target = !extra.not_value_target;
// endLine body extension: resolveBody only (TS/JS: function-valued
// class fields whose body nests in the arrow / HOF-wrapped arrow).
@@ -369,6 +399,13 @@ impl<'t> Walker<'t> {
let qualified = extra.qualified_name.unwrap_or_else(|| {
let mut parts: Vec<&str> = Vec::new();
for s in &self.stack {
// A path-hung object literal qualifies what it holds by its
// path (`window.App::init`), which carries its own scope.
if let Some(path) = self.object_path_owners.get(&s.row) {
parts.clear();
parts.push(path);
continue;
}
if s.kind != "file" {
parts.push(&s.name);
}
@@ -434,18 +471,19 @@ impl<'t> Walker<'t> {
if kind == "function" || kind == "method" {
self.defined_fn_names.insert(name.to_string());
}
self.capture_value_ref_scope(kind, name, row, node);
self.capture_value_ref_scope(kind, name, row, node, value_target);
Some(row)
}
// --- value references (captureValueRefScope / flushValueRefs) --------------
fn capture_value_ref_scope(&mut self, kind: &'static str, name: &str, row: u32, node: Node<'t>) {
fn capture_value_ref_scope(&mut self, kind: &'static str, name: &str, row: u32, node: Node<'t>, value_target: bool) {
if !self.variant.value_refs() {
return;
}
let target_kind_ok = kind == "constant" || kind == "variable";
if target_kind_ok
if value_target
&& target_kind_ok
&& util::utf16_len(name) >= 3
&& util::has_upper_or_underscore().is_match(name)
{
@@ -640,6 +678,12 @@ impl<'t> Walker<'t> {
// Function-as-value capture — independent of the dispatch ladder.
self.maybe_capture_fn_refs(node);
// `window.App = {…}` / `App.utils = {…}`: the object's functions are
// the path's members (#2300). Its whole subtree is handled there.
if kind == "assignment_expression" && self.extract_assigned_object_owner(node, true) {
return;
}
if is_function_type(kind) {
// (the isInsideClassLike + methodTypes overlap is Python/Ruby-only)
self.extract_function(node, None);
@@ -729,6 +773,16 @@ impl<'t> Walker<'t> {
let kind = node.kind();
self.maybe_capture_fn_refs(node);
// A named object literal in a body (an IIFE's `const App = {…}`) owns
// its function members as one at module scope does, and so does
// `window.App = {…}` written in here (#2300). Each handles its subtree.
if kind == "variable_declarator" && self.extract_local_object_owner(node) {
return;
}
if kind == "assignment_expression" && self.extract_assigned_object_owner(node, false) {
return;
}
if kind == "call_expression" {
self.extract_call(node);
} else if kind == "new_expression" {
+5 -1
View File
@@ -95,7 +95,11 @@ export function foldScriptResult(result: ExtractionResult, fold: ScriptFold, sin
for (const edge of result.edges) {
if (edge.kind === 'contains' && edge.source === blockFile) continue;
if (edge.line) edge.line += fold.lineOffset;
if (fold.perInstance && runsAsComponent(edge.source) && edge.kind !== 'imports') edge.source = fold.componentNodeId;
// What a top-level value DOES is the component's; what it HOLDS stays its
// own — `const api = { load() {…} }` keeps `api::load` (#2300).
if (fold.perInstance && runsAsComponent(edge.source) && edge.kind !== 'imports' && edge.kind !== 'contains') {
edge.source = fold.componentNodeId;
}
sink.edges.push(edge);
}
+220 -5
View File
@@ -18,7 +18,7 @@ import {
import { getParser, detectLanguage, isLanguageSupported, isFileLevelOnlyLanguage } from './grammars';
import { NodeIdAllocator, getNodeText, getChildByField, getPrecedingDocstring, BUILTIN_TYPE_NAMES } from './tree-sitter-helpers';
import { FN_REF_SPECS, captureFnRefCandidates, type FnRefSpec, type FnRefCandidate } from './function-ref';
import { isGeneratedFile } from './generated-detection';
import { isGeneratedFile, isMinifiedContent } from './generated-detection';
import type { LanguageExtractor, ExtractorContext } from './tree-sitter-types';
import { EXTRACTORS } from './languages';
import { stripCppTemplateArgs, isCppConstructorDeclaration } from './languages/c-cpp';
@@ -456,6 +456,25 @@ const TS_JS_CHAIN_LANGUAGES = new Set(['typescript', 'tsx', 'javascript', 'jsx']
/** Receiver node types (TS/JS grammars) that continue a member chain downward. */
const TS_JS_CHAIN_RECEIVER_TYPES = new Set(['member_expression', 'subscript_expression']);
/**
* A named object literal owns its function members (#2300): `const api = {
* load() {…}, save: () => {…} }`, the same object inside an IIFE or another
* function, or one hung on a path — `window.App = {…}`, `App.utils = {…}`.
* Each member is a `function` node qualified under its owner (`api::load`,
* `window.App::init`), and the calls written in it are its own. These are the
* values a member can hold, and the keys that name one — a computed `[expr]`
* key names nothing static, so its member's code stays with the owner.
* Mirrored in the kernel (tsjs/extractors.rs).
*/
const OBJECT_MEMBER_FUNCTION_TYPES: ReadonlySet<string> = new Set(['arrow_function', 'function_expression', 'generator_function']);
const STATIC_OBJECT_KEY_TYPES: ReadonlySet<string> = new Set(['property_identifier', 'string', 'number']);
/**
* Path roots that are the global object: `window.App = {…}` defines the global
* `App`. (`self` is the global only in a worker; in page code it is far more
* often `var self = this`, so `self.x = {…}` stays an ordinary path.)
*/
const HOST_GLOBAL_ROOTS: ReadonlySet<string> = new Set(['window', 'globalThis']);
/** The field of a `this.<field>.<method>()` receiver: public or ES private (#1496, #1987). */
const THIS_FIELD_PROPERTY_TYPES = new Set(['property_identifier', 'private_property_identifier']);
/** A Swift receiver that is a path of types, `API.PackageController.GetRoute` — two segments or more, each capitalized. */
@@ -590,6 +609,12 @@ export class TreeSitterExtractor {
private fnRefCandidates: Array<FnRefCandidate & { fromNodeId: string }> = [];
// Memoized "is this a Vue store file" verdict (per-extractor = per-file).
private vueStoreFile: boolean | null = null;
// An object literal hung on a path (`window.App = {…}`, `ns.mod = {…}`) is
// named by the path's last link but qualified by the whole path, which its
// members are then qualified under (see buildQualifiedName, #2300).
private objectPathOwners = new Map<string, string>();
// Memoized ownsObjectLiterals verdict (per-extractor = per-file).
private ownsObjects: boolean | null = null;
// Source already went through the extractor's preParse at the kernel route
// point (this instance is the wasm fallback for a kernel-deferred file) —
// don't blank it a second time.
@@ -920,7 +945,7 @@ export class TreeSitterExtractor {
* distinctive names become reference targets; function/method/const/var symbols become reader
* scopes whose bodies flushValueRefs scans.
*/
private captureValueRefScope(kind: NodeKind, name: string, id: string, node: SyntaxNode): void {
private captureValueRefScope(kind: NodeKind, name: string, id: string, node: SyntaxNode, valueTarget = true): void {
// Pascal targets `constant` only: its extractor emits function PARAMETERS
// (`Dest: TBufferWriter`) and class fields (`declField`) as `variable` at the
// enclosing scope, which would otherwise become noisy targets (a param name
@@ -929,7 +954,7 @@ export class TreeSitterExtractor {
// `var` globals are the rare cost; the parameter/field noise dominates.)
const targetKindOk =
this.language === 'pascal' ? kind === 'constant' : kind === 'constant' || kind === 'variable';
if (targetKindOk && name.length >= 3 && /[A-Z_]/.test(name)) {
if (valueTarget && targetKindOk && name.length >= 3 && /[A-Z_]/.test(name)) {
const parentId = this.nodeStack[this.nodeStack.length - 1];
// file-scope OR class/module/struct/enum-scope constants are targets.
// Class/module scope matters for languages (Ruby) that keep nearly all
@@ -1224,6 +1249,10 @@ export class TreeSitterExtractor {
// can never shadow or be shadowed by an extraction branch.
this.maybeCaptureFnRefs(node, nodeType);
// `window.App = {…}` / `App.utils = {…}`: the object's functions are the
// path's members (#2300). Its whole subtree is handled there.
if (nodeType === 'assignment_expression' && this.extractAssignedObjectOwner(node, true)) return;
// Check for function declarations
// For Python/Ruby, function_definition inside a class should be treated as method
if (this.extractor.functionTypes.includes(nodeType)) {
@@ -1573,7 +1602,10 @@ export class TreeSitterExtractor {
kind: NodeKind,
name: string,
node: SyntaxNode,
extra?: Partial<Node>
extra?: Partial<Node>,
// False for a value no other scope names by this name — a local, or an
// object hung on a dotted path (see captureValueRefScope).
valueTarget = true
): Node | null {
// Skip nodes with empty/missing names — they are not meaningful symbols
// and would cause FK violations when edges reference them (see issue #42)
@@ -1635,7 +1667,7 @@ export class TreeSitterExtractor {
}
}
if (this.valueRefsEnabled) this.captureValueRefScope(kind, name, id, node);
if (this.valueRefsEnabled) this.captureValueRefScope(kind, name, id, node, valueTarget);
return newNode;
}
@@ -1714,6 +1746,13 @@ export class TreeSitterExtractor {
// C/C++ enclosing namespaces prefix first (empty for every other language).
const parts: string[] = [...this.namespacePrefix];
for (const nodeId of this.nodeStack) {
// An object literal hung on a path qualifies what it holds by that path
// (`window.App::init`), which already carries its own scope.
const pathOwner = this.objectPathOwners.get(nodeId);
if (pathOwner !== undefined) {
parts.splice(0, parts.length, pathOwner);
continue;
}
const node = this.nodes.find((n) => n.id === nodeId);
if (node && node.kind !== 'file') {
parts.push(node.name);
@@ -2562,6 +2601,164 @@ export class TreeSitterExtractor {
return walked;
}
/**
* Whether a named object literal in this file owns its function members
* (#2300). TS/JS only; a generated or minified bundle — named so
* (`*.min.js`) or not (a vendored `bundle.js`, by its content) — keeps the
* old shape, so its single-letter objects don't become hundreds of nodes.
* Mirrored in the kernel (tsjs/extractors.rs owns_object_literals).
*/
private ownsObjectLiterals(): boolean {
this.ownsObjects ??= TS_JS_CHAIN_LANGUAGES.has(this.language) && !isGeneratedFile(this.filePath) &&
!isMinifiedContent(this.filePath, this.source);
return this.ownsObjects;
}
/**
* The function an owned literal's member becomes, named by its static key:
* `load() {…}`, `load: () => {…}`, `load: function () {…}`, `load: function* () {…}`.
* Null for every other member — a computed key, a value, a shorthand, a spread.
*/
private ownedMemberFunction(member: SyntaxNode): { fn: SyntaxNode; name: string } | null {
if (member.type === 'method_definition') {
const key = getChildByField(member, 'name');
return key && STATIC_OBJECT_KEY_TYPES.has(key.type) ? { fn: member, name: this.objectKeyName(key) } : null;
}
if (member.type !== 'pair') return null;
const key = getChildByField(member, 'key');
const value = getChildByField(member, 'value');
if (!key || !value || !STATIC_OBJECT_KEY_TYPES.has(key.type) || !OBJECT_MEMBER_FUNCTION_TYPES.has(value.type)) return null;
return { fn: value, name: this.objectKeyName(key) };
}
/** `value` when it is an object literal that owns at least one function member, else null. */
private ownedObjectValue(value: SyntaxNode | null): SyntaxNode | null {
if (!value || (value.type !== 'object' && value.type !== 'object_expression') || !this.ownsObjectLiterals()) return null;
for (let i = 0; i < value.namedChildCount; i++) {
const member = value.namedChild(i);
if (member && this.ownedMemberFunction(member)) return value;
}
return null;
}
/**
* Extract an owned literal's members: each function member becomes a node
* of its own, qualified under the owner (at file scope for one on the global
* object, `global`). Every other member — a value, a computed key, a spread —
* runs where the literal is written, so it is walked there: under the owner
* for a module-scope declaration (`valuesUnderOwner`, its initializer's calls
* are the constant's, #693), under the enclosing function for a local or an
* assignment, as they were before members had nodes. The literal's shorthand
* members (`{ load }`) go to the function-as-value capture the same way.
*/
private extractOwnedObjectMembers(obj: SyntaxNode, ownerId: string, valuesUnderOwner: boolean, global: boolean): void {
const enclosing = this.nodeStack;
const ownerStack = [...(global ? enclosing.slice(0, 1) : enclosing), ownerId];
const valueStack = valuesUnderOwner ? ownerStack : enclosing;
this.nodeStack = valueStack;
this.maybeCaptureFnRefs(obj, obj.type);
for (let i = 0; i < obj.namedChildCount; i++) {
const member = obj.namedChild(i);
if (!member) continue;
const owned = this.ownedMemberFunction(member);
this.nodeStack = owned ? ownerStack : valueStack;
if (owned) this.extractFunction(owned.fn, owned.name);
else this.visitFunctionBody(member, '');
}
this.nodeStack = enclosing;
}
/**
* `const api = { load() {…} }` written in a function body or an IIFE: the
* owner gets the node a module-scope declaration would, and its members are
* extracted under it (#2300). Returns false when the declarator holds no
* owned literal, leaving it to the walker.
*/
private extractLocalObjectOwner(declarator: SyntaxNode): boolean {
const nameNode = getChildByField(declarator, 'name');
if (nameNode?.type !== 'identifier') return false;
const obj = this.ownedObjectValue(getChildByField(declarator, 'value'));
if (!obj) return false;
const declaration = declarator.parent;
const isConst = declaration ? (this.extractor?.isConst?.(declaration) ?? false) : false;
const initValue = getNodeText(obj, this.source).slice(0, 100);
// A local: never the target of another scope's value read.
const owner = this.createNode(isConst ? 'constant' : 'variable', getNodeText(nameNode, this.source), declarator, {
docstring: this.docstringFor(declarator),
signature: `= ${initValue}${initValue.length >= 100 ? '...' : ''}`,
isExported: false,
}, false);
if (!owner) return false;
this.extractVariableTypeAnnotation(declarator, owner.id);
this.extractOwnedObjectMembers(obj, owner.id, false, false);
return true;
}
/**
* `window.App = {…}` / `App.utils = {…}` / `dw_page = {…}`: an object
* literal assigned to a name owns its function members like a declared one
* (#2300). Assigned to a plain identifier — an implicit global, or a binding
* declared elsewhere — it is qualified like a declaration. Hung on a path,
* the owner is named by the path's last link and qualified by the path as
* written — `window.App`, `App.utils` — so its members read
* `window.App::init`: a property is reached through its object, not through
* the function that happened to assign it (Lua's `M.helpers::a` reads the
* same way). A path on the global object is global wherever it is written,
* so it is also contained by the file. CommonJS export objects and
* prototypes are not namespaces and are left alone. Returns false (nothing
* extracted) for any other assignment.
*/
private extractAssignedObjectOwner(node: SyntaxNode, moduleLevel: boolean): boolean {
const obj = this.ownedObjectValue(getChildByField(node, 'right'));
if (!obj) return false;
const path = this.objectOwnerPath(getChildByField(node, 'left'));
// A plain name assigned at module level is a global (`dw_page = {…}`); in
// a function it is a local being reassigned (`e = {…}` in a bundle's IIFE).
if (!path || (path.length === 1 && !moduleLevel)) return false;
const qualifiedName = path.length > 1 ? path.join('.') : undefined;
const global = path.length > 1 && HOST_GLOBAL_ROOTS.has(path[0]!);
const saved = this.nodeStack;
if (global) this.nodeStack = saved.slice(0, 1);
const initValue = getNodeText(obj, this.source).slice(0, 100);
const statement = node.parent?.type === 'expression_statement' ? node.parent : node;
// `App.utils` is read as `App.utils`, never as a bare `utils`: only a name
// the code reads by that name is a value-read target.
const owner = this.createNode('variable', path[path.length - 1]!, node, {
docstring: this.docstringFor(statement),
signature: `= ${initValue}${initValue.length >= 100 ? '...' : ''}`,
isExported: false,
...(qualifiedName !== undefined ? { qualifiedName } : {}),
}, path.length === 1 || global);
this.nodeStack = saved;
if (!owner) return false;
if (qualifiedName !== undefined) this.objectPathOwners.set(owner.id, qualifiedName);
this.extractOwnedObjectMembers(obj, owner.id, false, global);
return true;
}
/**
* The links of an assignment target written as plain names — `dw_page` →
* `['dw_page']`, `window.App` → `['window', 'App']`, `App.utils.dom` →
* `['App', 'utils', 'dom']` — or null for anything else: `this.x`, a
* computed `a[k]`, a call, CommonJS's `module.exports` / `exports.x`, or a
* prototype.
*/
private objectOwnerPath(left: SyntaxNode | null): string[] | null {
const path: string[] = [];
let cur = left;
while (cur?.type === 'member_expression') {
const property = getChildByField(cur, 'property');
if (property?.type !== 'property_identifier') return null;
path.unshift(getNodeText(property, this.source));
cur = getChildByField(cur, 'object');
}
if (cur?.type !== 'identifier') return null;
const root = getNodeText(cur, this.source);
if (root === 'module' || root === 'exports' || path.includes('prototype')) return null;
path.unshift(root);
return path;
}
/**
* Extract function-valued properties of an object literal as named function
* nodes (named by their property key). Shared by the two object-of-functions
@@ -3054,6 +3251,17 @@ export class TreeSitterExtractor {
this.extractVariableTypeAnnotation(child, varNode.id);
}
// A named object literal owns its function members, exported or
// not (#2300): `const api = { load() {…} }` gives `api::load`, and
// the calls written in a member are the member's. The rest of the
// literal is walked under the owner. (The same literal inside a
// function or an IIFE goes through visitFunctionBody.)
const ownedObject = varNode ? this.ownedObjectValue(valueNode) : null;
if (varNode && ownedObject) {
this.extractOwnedObjectMembers(ownedObject, varNode.id, true, false);
continue;
}
// Exported const object-of-functions — extract each function-valued
// property as a function named by its key + walk its body so its
// calls are captured. Two shapes, both keyed on AST shape (not on any
@@ -6154,6 +6362,13 @@ export class TreeSitterExtractor {
// not in visitNode, so the capture hook must fire in both walkers.
this.maybeCaptureFnRefs(node, nodeType);
// A named object literal in a body — an IIFE's `const App = {…}`, a
// handler map in a function — owns its function members as one at
// module scope does, and so does `window.App = {…}` written in here
// (#2300). Each handles its whole subtree.
if (nodeType === 'variable_declarator' && this.extractLocalObjectOwner(node)) return;
if (nodeType === 'assignment_expression' && this.extractAssignedObjectOwner(node, false)) return;
// Rocket route-registration macros (`routes![…]` / `catchers![…]`): the
// handler paths live in a raw token tree the call walker can't see.
if (nodeType === 'macro_invocation') this.extractRustRouteMacro(node);
+4 -2
View File
@@ -80,8 +80,10 @@ export const svelteResolver: FrameworkResolver = {
};
}
// Pattern 2: Store auto-subscriptions ($storeName)
if (ref.referenceName.startsWith('$') && !ref.referenceName.startsWith('$$')) {
// Pattern 2: Store auto-subscriptions ($storeName) — a `.svelte`
// component's syntax only. In any other script `$n` is a name of its own
// (jQuery's `$el`, a compiler's `$n`), never the store `n`.
if (ref.language === 'svelte' && ref.referenceName.startsWith('$') && !ref.referenceName.startsWith('$$')) {
const storeName = ref.referenceName.substring(1);
const storeNode = context.getNodesByName(storeName).find(
(n) => n.kind === 'variable' || n.kind === 'constant'
+11 -6
View File
@@ -21,7 +21,7 @@ import {
isImportableKind,
CPP_DEFINE_SIGNATURE,
} from './types';
import { isPythonSelfCall, matchJsStoreBindingCall, isUnresolvedJsMemberCall, isVisibleAcrossFiles, matchReference, matchFunctionRef, matchDottedCallChain, matchScopedCallChain, matchMethodCall, sameLanguageFamily, crossesCodeBoundary, gateLanguageMatch, dumpNameMatcherProfile, clearNameMatcherMemos, isRustNameInScope, CASE_INSENSITIVE_LANGUAGES, isDartMemberRead, matchDartMemberRead } from './name-matcher';
import { isPythonSelfCall, matchJsStoreBindingCall, isUnresolvedJsMemberCall, matchObjectPathCall, thisScopeCaller, isVisibleAcrossFiles, matchReference, matchFunctionRef, matchDottedCallChain, matchScopedCallChain, matchMethodCall, sameLanguageFamily, crossesCodeBoundary, gateLanguageMatch, dumpNameMatcherProfile, clearNameMatcherMemos, isRustNameInScope, CASE_INSENSITIVE_LANGUAGES, isDartMemberRead, matchDartMemberRead } from './name-matcher';
import { isVisibleCppMacro, clearCppMacroVisibility } from './cpp-macro-visibility';
import { isCppConstructorRef, matchCppConstructor } from './cpp-constructor';
import { gateSwiftTypeTarget, clearSwiftTypeVisibility, swiftExtendedConformances } from './swift-type-visibility';
@@ -1292,7 +1292,8 @@ export class ReferenceResolver {
if (isUnresolvedJsMemberCall(ref)) {
const root = ref.referenceName.slice(0, ref.referenceName.indexOf('.'));
const namespace = this.context.getImportMappings(ref.filePath, ref.language).some((m) => m.isNamespace && m.localName === root);
if (!namespace) return null;
// `App.utils.fmt()` through a path an object literal was hung on (#2300).
if (!namespace) return this.gateLanguage(matchObjectPathCall(ref, this.context), ref);
const viaNamespace = this.gateLanguage(resolveViaImport(ref, this.context), ref);
const target = viaNamespace ? this.nodeById(viaNamespace.targetNodeId) : null;
return target && (target.kind === 'function' || target.kind === 'method' || target.kind === 'class' || target.kind === 'constant' || target.kind === 'variable')
@@ -2797,8 +2798,11 @@ export class ReferenceResolver {
private resolveThisMemberFnRef(ref: UnresolvedRef): ResolvedRef | null {
const member = ref.referenceName.slice('this.'.length);
if (!member) return null;
const fromNode = this.nodeById(ref.fromNodeId);
if (!fromNode) return null;
const written = this.nodeById(ref.fromNodeId);
if (!written) return null;
// Inside an object literal: its own method's `this` is the object, an
// arrow member's is the method around the literal (#2300).
const fromNode = thisScopeCaller(written, this.context);
// A hook declared at class-body level (Ruby `before_action :authenticate`)
// attributes to the CLASS node itself — its qualified name IS the scope.
// For members, strip the member segment.
@@ -2885,8 +2889,9 @@ export class ReferenceResolver {
for (const ref of deferred) {
await maybeYield();
const member = ref.referenceName.slice('this.'.length);
const fromNode = this.nodeById(ref.fromNodeId);
if (!fromNode || !member) continue;
const written = this.nodeById(ref.fromNodeId);
if (!written || !member) continue;
const fromNode = thisScopeCaller(written, this.context);
// Class-body-level hooks (Ruby) attribute to the CLASS node itself.
let className: string;
if (SUPERTYPE_BEARING_KINDS.has(fromNode.kind) || fromNode.kind === 'module') {
+569 -24
View File
@@ -1215,6 +1215,17 @@ export function isLexicallyReachable(
ref: UnresolvedRef,
context: ResolutionContext
): boolean {
// An object literal's member is reached through its object (#2300): a bare
// `load()` or `setTimeout(load)` never means `App.load`, whatever shares
// the name — see isObjectMemberReachableByName for the ways that do.
if (candidate.kind === 'function' && /^[A-Za-z_$][\w$]*$/.test(ref.referenceName) &&
!isObjectMemberReachableByName(candidate, ref, context)) return false;
// An object hung on a path (`App.utils = {…}`, `$.event.special.swipe =
// {…}`) is a property, not a binding: no bare name reaches it — a
// `this.swipe()` elsewhere is not that object. One hung on the global object
// (`window.App = {…}`) is the global `App`.
if (isPathHolder(candidate) && isObjectLiteralOwner(candidate) && /^[A-Za-z_$][\w$]*$/.test(ref.referenceName) &&
!HOST_GLOBAL_PREFIX.test(lastQualifiedSegment(candidate.qualifiedName))) return false;
// A `val` / `const` declared in a function body is that body's alone —
// okio's `(source as Source).buffer()` bound to a `val buffer = Buffer()`
// inside another test file's `pipe()`.
@@ -6115,9 +6126,10 @@ export function preferCallSiteFile(nodes: Node[], callSiteFile: string): Node[]
/**
* Languages whose object literals declare callable members — `export const
* api = { call() {…}, get: () => {…} }` used as a namespace (#1573).
* api = { call() {…}, get: () => {…} }` used as a namespace (#1573) — including
* the script blocks of single-file components (#2300).
*/
const OBJECT_LITERAL_LANGUAGES = new Set<string>(['typescript', 'tsx', 'javascript', 'jsx', 'arkts']);
const OBJECT_LITERAL_LANGUAGES = new Set<string>(['typescript', 'tsx', 'javascript', 'jsx', 'arkts', 'vue', 'svelte', 'astro']);
/** True when `inner`'s source range lies within `outer`'s (lines, then columns on a shared line). */
function rangeWithin(inner: Node, outer: Node): boolean {
@@ -6343,6 +6355,533 @@ export function resolveObjectLiteralBinding(
return null;
}
// ── Object literals that own their members (#2300) ──────────────────────────────
//
// A named object literal — `const App = {…}`, the same inside an IIFE or a
// function, `App = {…}`, or one hung on a path (`window.App = {…}`, `App.utils
// = {…}`) — makes each function member a node qualified under its owner
// (`App::init`, `window.App::init`). An owner is a constant or variable whose
// initializer is an object literal. A member is reached THROUGH its object:
// `App.init()`, `window.App.init()`, `App.utils.fmt()`, a sibling's
// `this.init()`, a `const { init } = App` binding — never by its name alone.
/**
* A path on the global object — `window.`, `globalThis.`. (`self` is the
* global only in a worker; in page code it is far more often `var self = this`.)
*/
const HOST_GLOBAL_PREFIX = /^(?:window|globalThis)\./;
/** The last `::` segment of a qualified name. */
function lastQualifiedSegment(qualifiedName: string): string {
const cut = qualifiedName.lastIndexOf('::');
return cut < 0 ? qualifiedName : qualifiedName.slice(cut + 2);
}
/**
* The path a constant or variable is reached by: its name, or — for an object
* hung on a path by assignment — that path, any global-object root dropped
* (`window.App = {…}` defines the global `App`; `App.utils = {…}` is `App.utils`).
*/
function holderPath(n: Node): string {
const last = lastQualifiedSegment(n.qualifiedName);
return last.includes('.') ? last.replace(HOST_GLOBAL_PREFIX, '') : n.name;
}
/** Whether a constant or variable was hung on a dotted path by assignment rather than declared. */
function isPathHolder(n: Node): boolean {
return lastQualifiedSegment(n.qualifiedName).includes('.');
}
/** Whether `n` holds an object literal: an owner its members can belong to. */
function isObjectLiteralOwner(n: Node): boolean {
return (n.kind === 'constant' || n.kind === 'variable') && JS_FAMILY.has(n.language) && /^=\s*\{/.test(n.signature ?? '');
}
const OBJECT_OWNER_OF = new WeakMap<ResolutionContext, Map<string, Node | null>>();
/**
* The object literal a function node is a member of — its qualified parent,
* an owner in its file whose extent holds it — or null for every other
* function: a declaration, a helper nested in a function, a function inside
* an initializer that is not an object literal (a module IIFE's own helpers).
*/
function objectLiteralOwnerOf(n: Node, context: ResolutionContext): Node | null {
if (n.kind !== 'function' || !JS_FAMILY.has(n.language)) return null;
const cut = n.qualifiedName.lastIndexOf('::');
if (cut <= 0) return null;
let memo = OBJECT_OWNER_OF.get(context);
if (!memo) OBJECT_OWNER_OF.set(context, (memo = new Map()));
const hit = memo.get(n.id);
if (hit !== undefined) return hit;
const parent = n.qualifiedName.slice(0, cut);
const owner = context.getNodesByQualifiedName(parent).find((o) =>
o.filePath === n.filePath && o.id !== n.id && isObjectLiteralOwner(o) && rangeWithin(n, o)) ?? null;
memo.set(n.id, owner);
return owner;
}
/** The node a qualified name's parent names, in `n`'s file and holding it. */
function qualifiedParentOf(n: Node, context: ResolutionContext): Node | null {
const cut = n.qualifiedName.lastIndexOf('::');
if (cut <= 0) return null;
return context.getNodesByQualifiedName(n.qualifiedName.slice(0, cut))
.find((p) => p.filePath === n.filePath && p.id !== n.id && rangeWithin(n, p)) ?? null;
}
/** An arrow function's text: `(a) =>`, `async (a) =>`, `a =>`, `<T>(a: T) =>` — not `name(…) {`, `function …`. */
const ARROW_HEAD = /^(?:async\s*)?(?:[(<]|[A-Za-z_$][\w$]*\s*=>)/;
/** Whether an object literal's member is an arrow function — one that has no `this` of its own. */
function isArrowMember(n: Node, context: ResolutionContext): boolean {
const line = (context.getFileLines?.(n.filePath) ?? context.readFile(n.filePath)?.split('\n'))?.[n.startLine - 1] ?? '';
return ARROW_HEAD.test(line.slice(n.startColumn));
}
const THIS_CALLERS = new WeakMap<ResolutionContext, Map<string, Node>>();
/**
* The caller whose scope `this` belongs to, for code written in `caller`
* (#2300). An object literal's own method — `load() {…}`, `load: function () {…}`
* — has the object: it is returned as is, so its qualified parent is the
* owner. An arrow member has no `this` of its own, and a value written in the
* literal runs where the literal is: both take the `this` of the code around
* the literal, a class method's in `class App { api() { return { load: () =>
* this.x() } } }`. Any other caller is its own scope.
*/
export function thisScopeCaller(caller: Node, context: ResolutionContext): Node {
if (!JS_FAMILY.has(caller.language)) return caller;
let memo = THIS_CALLERS.get(context);
if (!memo) THIS_CALLERS.set(context, (memo = new Map()));
const hit = memo.get(caller.id);
if (hit) return hit;
let cur = caller;
for (let depth = 0; depth < 8; depth++) {
const owner = isObjectLiteralOwner(cur) ? cur
: objectLiteralOwnerOf(cur, context) !== null && isArrowMember(cur, context) ? objectLiteralOwnerOf(cur, context) : null;
if (!owner) break;
const up = qualifiedParentOf(owner, context);
if (!up) break;
cur = up;
}
memo.set(caller.id, cur);
return cur;
}
/** The object literal `this` is inside `caller`, when it is one: the owner of an object's own method. */
function thisObjectOf(caller: Node, context: ResolutionContext): Node | null {
const scope = thisScopeCaller(caller, context);
const owner = objectLiteralOwnerOf(scope, context);
return owner && !isArrowMember(scope, context) ? owner : null;
}
/** Whether (line, column) falls inside `n`'s source range. */
function positionWithin(line: number, column: number, n: Node): boolean {
const end = n.endLine ?? n.startLine;
if (line < n.startLine || line > end) return false;
if (line === n.startLine && column < n.startColumn) return false;
return !(line === end && column >= n.endColumn);
}
/**
* What the object-literal lookups read from a JS/TS file (#2300), each part
* built on first use: its source with comments and string contents blanked
* (offsets kept), its `{…}` blocks, the scopes it binds a name in and the
* names it destructures off a path. References arrive file by file, so only
* the last few files' are kept: no file is read twice in a row, and a large
* project's files are never all held at once (#2334).
*/
interface JsFileScan {
source: string | null;
code?: string | null;
blocks?: JsBlockIndex | null;
bindings?: Map<string, Array<[number, number]>>;
destructured?: Map<string, Set<string>>;
}
const JS_FILE_SCANS = new WeakMap<ResolutionContext, Map<string, JsFileScan>>();
const JS_FILE_SCANS_KEEP = 32;
function jsFileScan(filePath: string, context: ResolutionContext): JsFileScan {
let files = JS_FILE_SCANS.get(context);
if (!files) JS_FILE_SCANS.set(context, (files = new Map()));
let scan = files.get(filePath);
if (scan) {
files.delete(filePath); // re-added below: the most recently read goes last
} else {
const source = context.readFile(filePath);
scan = { source: typeof source === 'string' ? source : null };
if (files.size >= JS_FILE_SCANS_KEEP) files.delete(files.keys().next().value!);
}
files.set(filePath, scan);
return scan;
}
function scannedCode(scan: JsFileScan): string | null {
if (scan.code === undefined) {
scan.code = scan.source === null ? null : blankStringContents(stripCommentsForRegex(scan.source, 'typescript'));
}
return scan.code;
}
/** A JS/TS file's source with comments and string contents blanked, offsets kept. */
function maskedJsSource(filePath: string, context: ResolutionContext): string | null {
return scannedCode(jsFileScan(filePath, context));
}
/**
* Whether a reference by NAME alone can mean an object literal's member. Only
* three ways write one: the owner's own `this.load()` (a sibling member), a
* named function expression calling itself (`load: function load() { load() }`),
* and a binding destructured off the owner (`const { load } = App`). A bare
* `load()`, a callback `setTimeout(load)`, `window.Other.load()` mean a
* function of that name — or nothing.
*/
function isObjectMemberReachableByName(n: Node, ref: UnresolvedRef, context: ResolutionContext): boolean {
const owner = objectLiteralOwnerOf(n, context);
if (!owner) return true;
if (ref.filePath === n.filePath) {
if (ref.referenceKind === 'calls' && positionWithin(ref.line, ref.column, owner) &&
bareCallReceiver(ref, context)?.receiver === 'self') {
// `this` must be that object: written in one of its own methods, not in an arrow member.
const caller = context.getNodeById?.(ref.fromNodeId);
if (caller && thisObjectOf(caller, context)?.id === owner.id) return true;
}
if (positionWithin(ref.line, ref.column, n)) {
const line = (context.getFileLines?.(n.filePath) ?? context.readFile(n.filePath)?.split('\n'))?.[n.startLine - 1] ?? '';
const self = new RegExp(`^(?:async\\s+)?function\\s*\\*?\\s*${n.name.replace(/[.*+?^${}()|[\]\\]/g, '\\$&')}(?![\\w$])`);
if (self.test(line.slice(n.startColumn))) return true;
}
}
return destructuresMember(ref.filePath, holderPath(owner), n.name, context);
}
/** `{ … } = App` / `= window.App.utils`: a pattern destructured off a plain path (the whole right-hand side). */
const DESTRUCTURED_OFF_PATH = /\{([^{}]*)\}\s*=\s*(?:(?:window|globalThis)\s*\.\s*)?([A-Za-z_$][\w$]*(?:\s*\.\s*[A-Za-z_$][\w$]*)*)(?![\w$])(?!\s*[.(\[])/g;
/**
* A `}` then `=` with only space and comments between — what any such pattern
* holds, read off the raw text. Each comment form matches one way only (a
* line comment through its newline, a block comment to its first `*\/`), so
* a `////////` banner can't make the match backtrack without end.
*/
const CLOSE_THEN_ASSIGN = /\}(?:\s|\/\*(?:[^*]|\*(?!\/))*\*\/|\/\/[^\n]*\n)*=(?!=)/;
/**
* Whether a file binds `member` by destructuring it off the object at `path`:
* `{ load }` or `{ a, load = fallback }` off `App` — not `{ x: load }`, which
* binds `load` to `App.x`. The file's patterns are read once, on first use.
*/
function destructuresMember(filePath: string, path: string, member: string, context: ResolutionContext): boolean {
const scan = jsFileScan(filePath, context);
if (!scan.destructured) {
const paths = new Map<string, Set<string>>();
const code = scan.source !== null && CLOSE_THEN_ASSIGN.test(scan.source) ? scannedCode(scan) : null;
for (const m of code?.matchAll(DESTRUCTURED_OFF_PATH) ?? []) {
const own = [...destructuredKeys(m[1]!)].filter(([name, key]) => name === key).map(([name]) => name);
if (own.length === 0) continue;
const at = m[2]!.replace(/\s+/g, '');
const names = paths.get(at);
if (names) for (const name of own) names.add(name);
else paths.set(at, new Set(own));
}
scan.destructured = paths;
}
return scan.destructured.get(path)?.has(member) ?? false;
}
/** A file's `{…}` blocks: for any offset, the innermost one open there (see jsBlockAt). */
interface JsBlockIndex {
lineStarts: number[];
/** Offsets of every `{` and `}`, in order. */
events: number[];
/** The innermost block still open just after each event (its `{` offset), -1 at the top level. */
open: number[];
close: Map<number, number>;
length: number;
}
function jsBlockIndex(filePath: string, context: ResolutionContext): JsBlockIndex | null {
const scan = jsFileScan(filePath, context);
if (scan.blocks === undefined) {
const code = scannedCode(scan);
scan.blocks = code === null ? null : readJsBlocks(code);
}
return scan.blocks;
}
function readJsBlocks(code: string): JsBlockIndex {
const lineStarts = [0];
const events: number[] = [];
const open: number[] = [];
const close = new Map<number, number>();
const stack: number[] = [];
for (let i = 0; i < code.length; i++) {
const ch = code.charCodeAt(i);
if (ch === 10) { lineStarts.push(i + 1); continue; }
if (ch === 123) stack.push(i);
else if (ch === 125) {
const at = stack.pop();
if (at !== undefined) close.set(at, i);
} else continue;
events.push(i);
open.push(stack.length > 0 ? stack[stack.length - 1]! : -1);
}
return { lineStarts, events, open, close, length: code.length };
}
/** The offset of (line, column) in a file. */
function jsOffset(index: JsBlockIndex, line: number, column: number): number {
return (index.lineStarts[line - 1] ?? index.length) + column;
}
/** The innermost `{…}` holding an offset, as [open, close] — null at the file's top level. */
function jsBlockAt(index: JsBlockIndex, offset: number): [number, number] | null {
let lo = 0;
let hi = index.events.length - 1;
let at = -1;
while (lo <= hi) {
const mid = (lo + hi) >> 1;
if (index.events[mid]! < offset) { at = mid; lo = mid + 1; } else hi = mid - 1;
}
const open = at < 0 ? -1 : index.open[at]!;
return open < 0 ? null : [open, index.close.get(open) ?? index.length];
}
/**
* The offset ranges a JS/TS file binds `name` in: a declaration's or a
* function/class name's enclosing block (the whole file at the top level), a
* parameter's function body (an expression-bodied arrow's, to the end of its
* enclosing block). The patterns are isLocallyBoundJsName's; only the scope is
* added, so one function's parameter does not shadow a global in another.
*/
function bindingScopes(name: string, filePath: string, context: ResolutionContext): Array<[number, number]> {
const scan = jsFileScan(filePath, context);
const memo = (scan.bindings ??= new Map());
const hit = memo.get(name);
if (hit) return hit;
const scopes: Array<[number, number]> = [];
const code = scannedCode(scan);
const index = jsBlockIndex(filePath, context);
if (code && index && code.includes(name)) {
const blockOf = (at: number): [number, number] => jsBlockAt(index, at) ?? [-1, code.length];
// The body a parameter list (ending in `{` or `=>`) opens.
const bodyAfter = (start: number, end: number): [number, number] => {
if (code[end - 1] === '{') return [end - 1, index.close.get(end - 1) ?? code.length];
let i = end;
while (i < code.length && /\s/.test(code[i]!)) i++;
if (code[i] === '{') return [i, index.close.get(i) ?? code.length];
return [start, blockOf(start)[1]];
};
const { decl, fn, param } = localBindingPatterns(name, 'g');
for (const m of code.matchAll(decl)) scopes.push(blockOf(m.index!));
for (const m of code.matchAll(fn)) scopes.push(blockOf(m.index!));
for (const m of code.matchAll(param)) scopes.push(bodyAfter(m.index!, m.index! + m[0].length));
const arrow = new RegExp(`(?<![\\w$.])${name.replace(/[.*+?^${}()|[\]\\]/g, '\\$&')}\\s*=>`, 'g');
for (const m of code.matchAll(arrow)) scopes.push(bodyAfter(m.index!, m.index! + m[0].length));
}
memo.set(name, scopes);
return scopes;
}
/** Whether a binding of `name` in the reference's own file encloses the reference. */
function bindsNameAt(name: string, ref: UnresolvedRef, context: ResolutionContext): boolean {
const index = jsBlockIndex(ref.filePath, context);
if (!index) return false;
const at = jsOffset(index, ref.line, ref.column);
return bindingScopes(name, ref.filePath, context).some(([start, end]) => at > start && at < end);
}
/**
* Where a same-file holder of an object can be seen from: the block it is
* declared in (an IIFE's, a function's), or the whole file for one at the top
* level — and for a path hung by assignment, which is a property of its root
* object wherever that is. Returns the depth to rank by (the block's `{`
* offset; -1 for the whole file), or null when the reference cannot see it —
* outside its block, or where the calling function binds the root name itself.
*/
function holderScopeAt(holder: Node, ref: UnresolvedRef, viaGlobal: boolean, context: ResolutionContext): number | null {
let depth = -1;
// A single-file component's source holds its template and styles too, whose
// braces are not blocks: a holder there is read as the script's top level.
if (!isPathHolder(holder) && JS_TS.has(holder.language)) {
const index = jsBlockIndex(holder.filePath, context);
const block = index ? jsBlockAt(index, jsOffset(index, holder.startLine, holder.startColumn)) : null;
if (index && block) {
const at = jsOffset(index, ref.line, ref.column);
if (at <= block[0] || at >= block[1]) return null;
depth = block[0];
}
}
const root = viaGlobal ? null : holderPath(holder).split('.')[0]!;
const local = root ? jsFunctionLocalScope(root, ref, context) : null;
if (local && !(holder.filePath === ref.filePath && holder.startLine >= local.start && holder.startLine <= local.end)) return null;
return depth;
}
const CLASSIC_SCRIPTS = new WeakMap<ResolutionContext, Map<string, boolean>>();
/**
* Whether a JS/TS file is a classic script — no `import`, `export`, CommonJS
* export or `require(…)` — whose top-level names are globals every other
* script on the page shares (#2300).
*/
function isClassicScript(filePath: string, context: ResolutionContext): boolean {
let memo = CLASSIC_SCRIPTS.get(context);
if (!memo) CLASSIC_SCRIPTS.set(context, (memo = new Map()));
const hit = memo.get(filePath);
if (hit !== undefined) return hit;
const source = context.readFile(filePath);
let classic = false;
if (typeof source === 'string' && !/\.mjs$|\.cjs$/i.test(filePath) && !HAS_CJS_EXPORT.test(source)) {
const code = maskedJsSource(filePath, context) ?? '';
classic = !HAS_IMPORT_STATEMENT.test(code) && !HAS_ESM_EXPORT.test(code) && !/(?:^|[^\w$.])require\s*\(/.test(code);
}
memo.set(filePath, classic);
return classic;
}
const GLOBAL_HOLDERS = new WeakMap<ResolutionContext, Map<string, boolean>>();
/** Whether a holder in another file is a global: on the global object, or at the top level of a classic script. */
function isGlobalHolder(n: Node, context: ResolutionContext): boolean {
let memo = GLOBAL_HOLDERS.get(context);
if (!memo) GLOBAL_HOLDERS.set(context, (memo = new Map()));
const hit = memo.get(n.id);
if (hit !== undefined) return hit;
let global: boolean;
if (HOST_GLOBAL_PREFIX.test(lastQualifiedSegment(n.qualifiedName))) global = true;
else if (!JS_TS.has(n.language) || !isClassicScript(n.filePath, context)) global = false;
else if (!isPathHolder(n)) {
const index = jsBlockIndex(n.filePath, context);
global = !!index && jsBlockAt(index, jsOffset(index, n.startLine, n.startColumn)) === null;
} else {
// `App.utils = {…}` is global when `App` is: declared at a classic script's top level, or on the global object.
const root = holderPath(n).split('.')[0]!;
global = context.getNodesByName(root).some((r) => (r.kind === 'constant' || r.kind === 'variable') &&
JS_FAMILY.has(r.language) && holderPath(r) === root && r.id !== n.id && isGlobalHolder(r, context));
}
memo.set(n.id, global);
return global;
}
/**
* Resolve `member` on the object a dotted `path` names, at `ref` (#2300):
* `App` for `App.init()` / `window.App.init()`, `App.utils` for
* `App.utils.fmt()`. A same-file holder the reference can see comes first, the
* innermost; then — when the file neither imports nor binds the path's root —
* a global one: `window.App = {…}` anywhere, or a classic script's top-level
* `App`. Several equally near holders that all have the member are no answer.
* `host` is the global object the call was written on (`window.App.init()`),
* null for a bare `App.init()`. Undefined when no holder of the path exists at
* all (so a caller can try its other strategies), null when one exists but
* none has the member.
*/
function resolveObjectPathMember(
path: string,
member: string,
ref: UnresolvedRef,
context: ResolutionContext,
host: string | null,
): ResolvedRef | null | undefined {
const viaGlobal = host !== null;
const tail = path.slice(path.lastIndexOf('.') + 1);
const named = context.getNodesByName(tail).filter((n) =>
(n.kind === 'constant' || n.kind === 'variable') && OBJECT_LITERAL_LANGUAGES.has(n.language) &&
sameLanguageFamily(n.language, ref.language) && holderPath(n) === path);
if (named.length === 0) return undefined;
const hitOn = (holder: Node): ResolvedRef | null =>
resolveObjectLiteralMember(holder, member, ref, context, 0.85, 'instance-method') ??
resolveObjectLiteralBinding(holder, member, ref, context);
const local = named
.filter((n) => n.filePath === ref.filePath)
.map((n) => ({ n, depth: holderScopeAt(n, ref, viaGlobal, context) }))
.filter((e): e is { n: Node; depth: number } => e.depth !== null)
.sort((a, b) => b.depth - a.depth);
if (local.length > 0) {
// The nearest holders only: an outer one is shadowed where an inner one exists.
const nearest = local.filter((e) => e.depth === local[0]!.depth);
for (const { n } of nearest) {
const hit = hitOn(n);
if (hit) return hit;
}
return null;
}
const hits = named
.filter((n) => n.filePath !== ref.filePath && isObjectLiteralOwner(n) && isGlobalHolder(n, context))
.map((n) => ({ n, hit: hitOn(n) }))
.filter((e): e is { n: Node; hit: ResolvedRef } => e.hit !== null);
if (hits.length === 0) return null;
// A global is what the call names only where nothing nearer binds the name:
// the file's own import of it, a parameter or local around the call — or,
// for `window.App`, a `window` of its own. (Checked once a global holder has
// the member: it reads the calling file.)
const root = path.split('.')[0]!;
if (host !== null ? bindsNameAt(host, ref, context)
: isImportBinding(root, ref, context) || bindsNameAt(root, ref, context)) return null;
const targets = new Set(hits.map((e) => e.hit.targetNodeId));
if (targets.size === 1) return { ...hits[0]!.hit, confidence: 0.8 };
// The same namespace defined twice (a page's copy and a build's): the caller's own directory decides, or nothing does.
const dir = ref.filePath.slice(0, ref.filePath.lastIndexOf('/') + 1);
const near = hits.filter((e) => e.n.filePath.startsWith(dir) && !e.n.filePath.slice(dir.length).includes('/'));
return new Set(near.map((e) => e.hit.targetNodeId)).size === 1 ? { ...near[0]!.hit, confidence: 0.75 } : null;
}
/**
* `a.b.m()` through a namespace an object literal was hung on (`App.utils =
* {…}` then `App.utils.fmt()`, #2300). Identifier-rooted chains carry no type
* (#1566), so nothing else may resolve them: this answers only when a holder
* of exactly that path exists, and is null otherwise.
*/
export function matchObjectPathCall(ref: UnresolvedRef, context: ResolutionContext): ResolvedRef | null {
if (ref.referenceKind !== 'calls' || !JS_FAMILY.has(ref.language)) return null;
const dot = ref.referenceName.lastIndexOf('.');
if (dot <= 0) return null;
let path = ref.referenceName.slice(0, dot);
const host = HOST_GLOBAL_PREFIX.test(path) ? path.slice(0, path.indexOf('.')) : null;
if (host !== null) path = path.slice(host.length + 1);
if (!path.includes('.') && host === null) return null;
return resolveObjectPathMember(path, ref.referenceName.slice(dot + 1), ref, context, host) ?? null;
}
/** `(` after optional space, at `lastIndex`. */
const OPEN_PAREN_AT = /\s*\(/y;
/** `window.App.utils` at the end of a receiver's text → host `window`, path `App.utils`. */
const HOST_GLOBAL_RECEIVER =/(?:^|[^\w$.])(window|globalThis)\s*\??\.\s*([A-Za-z_$][\w$]*(?:\s*\??\.\s*[A-Za-z_$][\w$]*)*)$/;
/**
* A call the extractor recorded by its bare name although it was written on
* an object (#2300): `window.App.init()` — the `window` chain keeps its bare
* name as the project-global escape (#1707) — and `this.render()` inside an
* object literal's member. Both name the owner of the member they call, which
* is tried first. Undefined when the call is neither, or no such owner has the
* member (the bare-name strategies then run as before).
*/
export function matchCollapsedObjectCall(ref: UnresolvedRef, context: ResolutionContext): ResolvedRef | undefined {
if (ref.referenceKind !== 'calls' || !JS_FAMILY.has(ref.language) || !/^[A-Za-z_$][\w$]*$/.test(ref.referenceName)) return undefined;
// A call written bare starts with its own name and `(`: no receiver to read.
const line = (context.getFileLines?.(ref.filePath) ?? context.readFile(ref.filePath)?.split(/\r?\n/))?.[ref.line - 1];
if (line?.startsWith(ref.referenceName, ref.column)) {
OPEN_PAREN_AT.lastIndex = ref.column + ref.referenceName.length;
if (OPEN_PAREN_AT.test(line)) return undefined;
}
const written = bareCallReceiver(ref, context);
if (!written) return undefined;
if (written.receiver === 'self') {
// The object literal `this` is — the owner of the method the call is
// written in (an arrow member's `this` is the code around the literal's) —
// when it has the member.
const caller = context.getNodeById?.(ref.fromNodeId);
const owner = caller ? thisObjectOf(caller, context) : null;
const hit = owner ? resolveObjectLiteralMember(owner, ref.referenceName, ref, context, 0.85, 'instance-method') : null;
return hit ?? undefined;
}
const host = HOST_GLOBAL_RECEIVER.exec(written.receiver);
if (!host) return undefined;
return resolveObjectPathMember(host[2]!.replace(/[\s?]/g, ''), ref.referenceName, ref, context, host[1]!) ?? undefined;
}
// Exported for the precedence unit tests (#1079): they assert the
// preferredFqn → same-file → matches[0] ordering directly.
export function resolveMethodOnType(
@@ -7078,6 +7617,11 @@ export function clearNameMatcherMemos(context: ResolutionContext): void {
SCALA_SUPERS.delete(context);
SCALA_IMPORTS.delete(context);
ESM_EXPORT_LISTS.delete(context);
JS_FILE_SCANS.delete(context);
OBJECT_OWNER_OF.delete(context);
THIS_CALLERS.delete(context);
GLOBAL_HOLDERS.delete(context);
CLASSIC_SCRIPTS.delete(context);
LUA_LOCALS.delete(context);
LUA_MEMBERS.delete(context);
JVM_PACKAGES.delete(context);
@@ -8342,26 +8886,17 @@ export function matchMethodCall(
}
// Object-literal namespace receiver (#1573): `api.call()` where `api` is a
// same-file `const api = { call() {…}, get: () => {…} }`. Its members are
// plain functions with bare names inside the constant's extent — no
// `Container::member` qualified name — so none of the class-shaped
// strategies below can see them (Strategy 3 only considers `method`
// kinds) and the call resolved to nothing at all. Same file only: a
// cross-file use reaches the same helper through the import path.
// `const api = { call() {…}, get: () => {…} }`. Its members are functions
// inside the constant's extent, found by containment — none of the
// class-shaped strategies below can see them (Strategy 3 only considers
// `method` kinds). The holder is the one the call can see: in its own file,
// the nearest enclosing declaration (an IIFE's `const App` before the file's);
// otherwise a global — `window.App = {…}`, or a classic script's top-level
// `App` — when the file doesn't import or bind the name itself (#2300). An
// imported holder reaches the same member through the import path.
if (dotMatch && !objectOrClass!.includes('.') && OBJECT_LITERAL_LANGUAGES.has(ref.language)) {
const literalMatch = nmTimedT('mc-literal', ref, (): ResolvedRef | null => {
// Same-file holders only, so the call-site-first ordering is moot.
const holders = context.getNodesByName(objectOrClass!).filter(
(n) => (n.kind === 'constant' || n.kind === 'variable') && n.filePath === ref.filePath
);
for (const holder of holders) {
const hit =
resolveObjectLiteralMember(holder, methodName!, ref, context, 0.85, 'instance-method') ??
resolveObjectLiteralBinding(holder, methodName!, ref, context);
if (hit) return hit;
}
return null;
});
const literalMatch = nmTimedT('mc-literal', ref, (): ResolvedRef | null =>
resolveObjectPathMember(objectOrClass!, methodName!, ref, context, null) ?? null);
if (literalMatch) return literalMatch;
}
@@ -9258,8 +9793,11 @@ function matchTsThisFieldCall(
context: ResolutionContext,
): ResolvedRef | null {
if (!field || field.includes('.')) return null;
const caller = context.getNodeById?.(ref.fromNodeId);
if (!caller) return null;
const written = context.getNodeById?.(ref.fromNodeId);
if (!written) return null;
// An arrow member of an object literal written in a method has that
// method's `this` (#2300).
const caller = thisScopeCaller(written, context);
const sep = caller.qualifiedName.lastIndexOf('::');
if (sep <= 0) return null; // not inside a class
const owner = caller.qualifiedName.slice(0, sep).split('::').pop();
@@ -10568,7 +11106,14 @@ function matchReferenceInner(
}
}
if (isUnresolvedJsMemberCall(ref)) return null;
// An identifier-rooted chain has no type to resolve through (#1566) — unless
// its receiver is a path an object literal was hung on (#2300).
if (isUnresolvedJsMemberCall(ref)) return matchObjectPathCall(ref, context);
// `window.App.init()` / a sibling's `this.init()`, recorded by the bare
// name: the object they are written on comes first (#2300).
const collapsed = matchCollapsedObjectCall(ref, context);
if (collapsed) return collapsed;
// A Swift call through a type path (`API.PackageController.GetRoute.query`)
// resolves on the type the path names, or not at all: the strategies below