Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -53,6 +53,7 @@ and adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).

### Fixes

- JavaScript and TypeScript calls through CommonJS or default imports of modules without a value default no longer link missing members to unrelated class methods; re-index after upgrading.
- TypeScript generic calls and typed tags using `typeof abstract` now retain their callers, while runtime `typeof` comparisons and optional chains keep their runtime meaning; re-index TypeScript projects after upgrading.
- TypeScript classes with members named `abstract` or `accessor`, and auto-accessors with `static`, `override` or `abstract` modifiers, now index correctly; re-index TypeScript projects after upgrading.
- A TSX file with a broken JSX attribute such as `<a {{b:>c:d(e f)` no longer stalls indexing for about 90 seconds and then drops out of the index; it is indexed like any other file with a syntax error.
Expand Down
71 changes: 71 additions & 0 deletions __tests__/commonjs-export-members.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,71 @@
import { describe, expect, it } from 'vitest';
import { readCommonJsExportMembers } from '../src/resolution/commonjs-export-members';

describe('CommonJS export member discovery', () => {
it.each([
'exports.load = Store.prototype.load;',
'module.exports.load = Store.prototype.load;',
"exports['load'] = Store.prototype.load;",
'module.exports["load"] = Store.prototype.load;',
'module["exports"]["load"] = Store.prototype.load;',
'exports /* target */ . /* key */ load = Store.prototype.load;',
'const text = `${exports.load = Store.prototype.load}`;',
'const text = `outer ${`inner ${exports.load = Store.prototype.load}`}`;',
'function generic<T>() { exports.load = Store.prototype.load; }',
])('finds executable assignment: %s', (source) => {
const result = readCommonJsExportMembers(source);
expect([...result.members]).toEqual(['load']);
expect(result.uncertain).toBe(false);
});

it.each([
'// exports.missing = fake;',
'/* exports.missing = fake; */',
'const text = "exports.missing = fake;";',
'const text = `exports.missing = fake;`;',
'const pattern = /exports.missing = fake/;',
'if (true) /exports.missing = fake/.test("x");',
'function setup(holder) { holder.exports.missing = 42; }',
'function setup(holder) { holder . exports . missing = 42; }',
'function setup(holder) { holder.module.exports.missing = 42; }',
'holder?.exports.missing;',
'$exports.missing = fake;',
'αexports.missing = fake;',
'exports.missing === fake;',
'const text = "module exports";',
'let value: typeof exports.missing = undefined;',
'type Loader = typeof require.cache;',
'class Evaluator { eval(code) {} }',
])('ignores non-export text: %s', (source) => {
const result = readCommonJsExportMembers(source);
expect([...result.members]).toEqual([]);
expect(result.uncertain).toBe(false);
});

it.each([
'module.exports = new Store();',
'take(exports);',
'Object.setPrototypeOf(exports, Store.prototype);',
'module.exports.__proto__ = Store.prototype;',
'exports[key] = Store.prototype.load;',
'exports["lo\\u0061d"] = Store.prototype.load;',
"eval('exports.load = Store.prototype.load;');",
'eval(`exports.load = Store.prototype.load;`);',
'eval(`exports.load = ${Store.prototype.load};`);',
"(eval)('exports.load = Store.prototype.load;');",
"((eval))(('exports.load = Store.prototype.load;'));",
'require.cache[__filename].exports = new Store();',
"const Module = require('node:module');",
"import { Module } from 'node:module';",
"const Module = await import('node:module');",
])('preserves uncertain object APIs: %s', (source) => {
expect(readCommonJsExportMembers(source).uncertain).toBe(true);
});

it('ignores JSX text but keeps assignments in JSX expressions', () => {
const source = '<div title="exports.missing = fake">exports.missing = fake{exports.load = Store.prototype.load}</div>';
const result = readCommonJsExportMembers(source, true);
expect([...result.members]).toEqual(['load']);
expect(result.uncertain).toBe(false);
});
});
184 changes: 184 additions & 0 deletions __tests__/js-default-binding-members.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -106,6 +106,92 @@ describe('a member read through a default binding', () => {
expect(callsOf(cg, 'run')).toEqual([]);
});

it.each([
["const named = require('./m');", 'Decoy'],
["import named from './m.js';", 'Decoy'],
["const named = require('./m');", 'named'],
])('does not borrow an unimported method after %s, even from class %s', async (binding, className) => {
const cg = await indexProject({
'm.js': 'exports.known = function known() {};\n',
'decoy.js': `export class ${className} { static missing() {} }\n`,
'use.js': CALLER(binding, ['missing']),
});
expect(callsOf(cg, 'run')).toEqual([]);
});

it('keeps real exports beside an unimported method and through a forwarding module', async () => {
const cg = await indexProject({
'm.js': 'exports.second = function second() {};\n',
'index.js': "module.exports = require('./m');\n",
'decoy.js': 'export class Decoy { second() {} missing() {} }\n',
'use.js': CALLER("const named = require('./index');", ['second', 'missing']),
});
expect(callsOf(cg, 'run')).toEqual(['3 m.js:second']);
});

it('keeps CommonJS stated class, object, instance and callable exports', async () => {
const cg = await indexProject({
'class.js': 'class Widget { static make() {} }\nmodule.exports = Widget;\n',
'object.js': 'const api = { read() {} };\nmodule.exports = api;\n',
'instance.js': 'class Store { load() {} }\nmodule.exports = new Store();\n',
'callable.js': 'function create() {}\nmodule.exports = create;\nmodule.exports.extra = function extra() {};\n',
'decoy.js': 'export class Decoy { make() {} read() {} load() {} extra() {} }\n',
'use.js': "const Widget = require('./class');\nconst object = require('./object');\nconst store = require('./instance');\nconst create = require('./callable');\nfunction run() {\n Widget.make();\n object.read();\n store.load();\n create();\n create.extra();\n}\nmodule.exports = run;\n",
});
expect(callsOf(cg, 'run')).toEqual([
'10 callable.js:extra', '6 class.js:Widget::make', '7 object.js:api::read',
'8 instance.js:Store::load', '9 callable.js:create',
]);
});

it('keeps typed parameters and local objects that shadow a default binding', async () => {
const cg = await indexProject({
'm.ts': 'export function known() {}\n',
'use.ts': "import named from './m';\nclass Local { missing() {} }\nexport function typed(named: Local) {\n named.missing();\n}\nexport function literal() {\n const named = { present() {} };\n named.present();\n}\n",
'decoy.ts': 'export class Decoy { missing() {} present() {} }\n',
});
expect(callsOf(cg, 'typed')).toEqual(['4 use.ts:Local::missing']);
expect(callsOf(cg, 'literal')).toEqual(['8 use.ts:literal::named::present']);
});

it.each([
"import named from './m';",
"import * as named from './m';",
])('keeps a lexical class shadow after %s', async (binding) => {
const cg = await indexProject({
'm.ts': 'export function known() {}\n',
'use.ts': `${binding}\nexport function outer() {\n class named { static missing() {} }\n function run() { named.missing(); }\n return run;\n}\n`,
});
expect(callsOf(cg, 'run')).toEqual(['4 use.ts:outer::named::missing']);
});

it.each([
'module.exports = new Store();',
'export default new Store();',
])('keeps the instance stated by %s', async (statement) => {
const cg = await indexProject({
'm.js': `class Store { load() {} }\n${statement}\n`,
'decoy.js': 'export class Decoy { missing() {} }\n',
'use.js': CALLER("const named = require('./m');", ['load']),
});
expect(callsOf(cg, 'run')).toEqual(['3 m.js:Store::load']);
});

it('keeps a missing member unresolved when a decoy is added by sync', async () => {
const cg = await indexProject({
'm.js': 'exports.known = function known() {};\n',
'use.js': CALLER("const named = require('./m');", ['missing']),
});
expect(callsOf(cg, 'run')).toEqual([]);
const decoy = path.join(roots[roots.length - 1]!, 'decoy.js');
fs.writeFileSync(decoy, 'export class Decoy { missing() {} }\n');
await cg.sync();
expect(callsOf(cg, 'run')).toEqual([]);
fs.unlinkSync(decoy);
await cg.sync();
expect(callsOf(cg, 'run')).toEqual([]);
});

it('takes what `module.exports =` states over a neighbour assigned to it', async () => {
const cg = await indexProject({
'm.js': 'function main() {}\nmodule.exports = main;\nmodule.exports.extra = function () {};\n',
Expand All @@ -114,6 +200,104 @@ describe('a member read through a default binding', () => {
expect(callsOf(cg, 'run')).toEqual(['3 m.js:main', '4 m.js:extra']);
});

it.each([
['./m', 'named'], ['./index', 'named'],
['./m', 'Store'], ['./index', 'Store'],
])('rechecks calls through %s as %s when it changes between a value and a namespace', async (specifier, alias) => {
const value = 'class Store { load() {} }\nmodule.exports = new Store();\n';
const namespace = 'class Store { load() {} }\nexports.known = function known() {};\n';
const files = {
'm.js': value,
'index.js': "module.exports = require('./m');\n",
'use.js': CALLER(`const named = require('${specifier}');`, ['load']).replace(/\bnamed\b/g, alias),
};
const cg = await indexProject(files);
const moduleFile = path.join(roots[roots.length - 1]!, 'm.js');
expect(callsOf(cg, 'run')).toEqual(['3 m.js:Store::load']);
for (const source of [namespace, value, namespace]) {
fs.writeFileSync(moduleFile, source);
await cg.sync();
const fresh = await indexProject({ ...files, 'm.js': source });
expect(callsOf(cg, 'run')).toEqual(callsOf(fresh, 'run'));
expect(callsOf(cg, 'run')).toEqual(source === value ? ['3 m.js:Store::load'] : []);
}
});

it.each(['exports.load', 'module.exports.load', "exports['load']", "module.exports['load']"])(
'keeps the callable alias in %s without borrowing an absent member', async (assignment) => {
const cg = await indexProject({
'm.js': `class Store { load() {} }\n${assignment} = Store.prototype.load;\n`,
'decoy.js': 'export class Decoy { missing() {} }\n',
'use.js': CALLER("const named = require('./m');", ['load', 'missing']),
});
expect(callsOf(cg, 'run')).toEqual(['3 m.js:Store::load']);
}
);

it('drops an exported callable alias when sync removes only the assignment', async () => {
const cg = await indexProject({
'm.js': 'class Store { load() {} }\nexports.load = Store.prototype.load;\n',
'use.js': CALLER("const named = require('./m');", ['load']),
});
const moduleFile = path.join(roots[roots.length - 1]!, 'm.js');
expect(callsOf(cg, 'run')).toEqual(['3 m.js:Store::load']);
fs.writeFileSync(moduleFile, 'class Store { load() {} }\nexports.known = function known() {};\n');
await cg.sync();
expect(callsOf(cg, 'run')).toEqual([]);
});

it.each([
"const named = require('./m');",
"import named from './m';",
"import * as named from './m';",
])('sees a block-local class only inside its block after %s', async (binding) => {
const cg = await indexProject({
'm.ts': 'export function known() {}\n',
'use.ts': `${binding}\nexport function run() {\n if (true) {\n class named { static missing() {} }\n function inside() { named.missing(); }\n }\n named.missing();\n}\n`,
});
expect(callsOf(cg, 'run')).toEqual([]);
expect(callsOf(cg, 'inside')).toEqual(['5 use.ts:run::named::missing']);
});

it('does not mistake a class inside a TypeScript namespace for an import shadow', async () => {
const cg = await indexProject({
'm.ts': 'export function known() {}\n',
'use.ts': "import named from './m';\nnamespace Parent {\n export class named { static missing() {} }\n}\nexport function run() { named.missing(); }\n",
});
expect(callsOf(cg, 'run')).toEqual([]);
});

it.each([
'// exports.missing = fake;',
'/* exports.missing = fake; */',
'const text = "exports.missing = fake;";',
'const text = `exports.missing = fake;`;',
'const pattern = /exports.missing = fake/;',
'function setup(holder) { holder.exports.missing = 42; }',
'function setup(holder) { holder . exports . missing = 42; }',
'function setup(holder) { holder.module.exports.missing = 42; }',
])('does not treat non-export text as a member: %s', async (extra) => {
const cg = await indexProject({
'm.js': `exports.known = function known() {};\n${extra}\n`,
'decoy.js': 'export class Decoy { missing() {} }\n',
'use.js': CALLER("const named = require('./m');", ['missing']),
});
expect(callsOf(cg, 'run')).toEqual([]);
});

it.each([
"eval('exports.load = Store.prototype.load;');",
'eval(`exports.load = Store.prototype.load;`);',
"(eval)('exports.load = Store.prototype.load;');",
'require.cache[__filename].exports = new Store();',
])('keeps uncertain runtime export APIs: %s', async (mutation) => {
const cg = await indexProject({
'm.js': `class Store { load() {} }\n${mutation}\n`,
'use.js': CALLER("const named = require('./m');", ['load']),
});
expect(callsOf(cg, 'run')).toEqual(['3 m.js:Store::load']);
});

it('links the member a sync brings, and drops the one it takes away', async () => {
const cg = await indexProject({
'm.js': 'exports.first = function () {};\n',
Expand Down
7 changes: 7 additions & 0 deletions package-lock.json

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

1 change: 1 addition & 0 deletions package.json
Original file line number Diff line number Diff line change
Expand Up @@ -54,6 +54,7 @@
"fast-string-width": "^3.0.2",
"fast-wrap-ansi": "^0.2.0",
"ignore": "^7.0.14",
"js-tokens": "9.0.1",
"jsonc-parser": "^3.3.1",
"picomatch": "^4.0.3",
"sisteransi": "^1.0.5",
Expand Down
11 changes: 5 additions & 6 deletions src/extraction/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -3653,16 +3653,15 @@ export class ExtractionOrchestrator {

/**
* Re-open resolution edges whose answer was READ from a file this sync
* touched, although neither end of the edge changed: a python attribute typed
* through a factory or a base class in another file records those files as
* `metadata.typeFrom`. The definition delta cannot see these — editing what a
* factory returns, or what a base `__init__` assigns, adds or removes no
* definition — so without this a synced index keeps the old type forever.
* touched, although neither end of the edge changed: a Python attribute type
* or a JS/TS default import's namespace ownership records those files as
* `metadata.typeFrom`. The definition delta cannot see a factory body or an
* export assignment changing, so a synced index would keep the old answer.
* The edges go back to pending refs; returns the files they come from, whose
* pending rows the caller resolves.
*/
resurrectTypeDependentEdges(touchedPaths: string[], changedFilePaths: string[]): string[] {
const touched = new Set(touchedPaths.filter((p) => /\.pyi?$/.test(p)));
const touched = new Set(touchedPaths.filter((p) => /\.(?:pyi?|[cm]?[jt]sx?)$/.test(p)));
if (touched.size === 0) return [];
const alreadyFresh = new Set(changedFilePaths);

Expand Down
16 changes: 8 additions & 8 deletions src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -1767,22 +1767,22 @@ export class CodeGraph {
result.pendingRefsUnresolved = recovery.stats.unresolved;
}

// Python attribute types read from OTHER files — a factory's return, a
// base class's `__init__` — change with a body-only edit the definition
// delta above cannot see. Re-open the edges whose answer was read from a
// touched file, and retry the failed attribute calls in files that
// import one; both resolve right here. After the orphan sweep, so the
// Python attribute types and JS/TS imported-receiver ownership read
// other files whose body/export changes the definition delta cannot
// see. Re-open those edges and retry failed Python attribute calls in
// files that import one; both resolve here. After the orphan sweep, so the
// import edges this sync created (a caller whose `from f import make`
// only now resolves) are there to find the importers by.
if (process.env.CODEGRAPH_NO_REBIND !== '1') {
const touched = [...(result.changedFilePaths ?? []), ...(result.removedFilePaths ?? [])];
const touchedPy = touched.filter((p) => /\.pyi?$/.test(p));
if (touchedPy.length > 0) {
const touchedTypes = touched.filter((p) => /\.(?:pyi?|[cm]?[jt]sx?)$/.test(p));
if (touchedTypes.length > 0) {
const tTypes = Date.now();
const reopenedFiles = this.orchestrator.resurrectTypeDependentEdges(touchedPy, result.changedFilePaths ?? []);
const reopenedFiles = this.orchestrator.resurrectTypeDependentEdges(touchedTypes, result.changedFilePaths ?? []);
// CODEGRAPH_PY_ATTR_RETRY_CEILING overrides the candidate ceiling (tests).
const ceiling = Number(process.env.CODEGRAPH_PY_ATTR_RETRY_CEILING ?? '') || 2000;
const failed = this.resolver.pythonAttrRetryCandidates(touchedPy, ceiling);
const failed = touchedPy.length ? this.resolver.pythonAttrRetryCandidates(touchedPy, ceiling) : [];
const retry = [...this.queries.getUnresolvedReferencesByFiles(reopenedFiles), ...(failed ?? [])];
if (retry.length > 0) await this.resolver.resolveAndPersistListYielding(retry);
if (process.env.CODEGRAPH_SYNTH_TIMINGS) {
Expand Down
Loading
Loading