Skip to content
Merged
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 @@ -90,6 +90,7 @@ and adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
- React Router routes kept in a table of their own are now indexed: the ASP.NET Core React template's `AppRoutes` array that `App.js` maps into `<Route {...rest}>`, a `routes` array imported into `useRoutes(routes)` or `createBrowserRouter(routes)` or returned by a function as in `useRoutes(routes(isLoggedIn))`, route objects kept one per file and listed in `createBrowserRouter([MainRoutes, LoginRoutes])`, and routes written in place in `useRoutes([…])`. Before, route objects were read only from a file that itself creates a data router, so these apps had no routes, and their `navigate('/login')` calls and `<Link to="/counter">` links led nowhere. Each route links to the page its `element` renders, past a guard like `<ProtectedRoute>` or `<AdminWrapper>`, and through `lazy(() => import(…))` to the page it loads rather than a same-named page elsewhere in the repository. An `index: true` route is the page at its parent's address, and a parent route around others counts as their layout. A `{ path, element }` list that nothing hands to the router, such as a menu, still makes no routes. Re-index React projects after upgrading.
- In Go, a call or type written through an import whose path ends in a version, or in something other than the package's name, is now known to go through that import: `yaml.Unmarshal(…)` after `import "go.yaml.in/yaml/v3"` or `"gopkg.in/yaml.v3"`, `sqlite3.Error` after `import "github.com/mattn/go-sqlite3"`, `klog.V(2)` after `import "k8s.io/klog/v2"`. Before, only the last part of the path named such an import (`v3`, `yaml.v3`, `go-sqlite3`), so the name was matched on its own and could link to any project function, method or type that shared it: kubernetes' `klog.V(…)` calls were linked to a logging wrapper's `V` method, and etcd's `semver.Version` parameters to an unrelated `Version` function. A name from another module now links to nothing, and one through a package of your own project, like `kit.New()` after `import "example.com/kit/v2"`, links to that package's symbol. A comment in an import block is also no longer taken for the name of the import after it. Re-index Go projects after upgrading. Thanks @danusha2345 for the comment fix. (#2374)
- React Router routes written as JSX now read `<Route index>` the way route objects do: inside `<Route path="/" element={<Layout />}>`, `<Route index element={<Home />} />` is the page at `/`, and `Layout` counts as the layout of every route nested in it, as does a guard with no path of its own like `<Route element={<RequireAuth />}>`. Before, the index route was never read, so `/` led to the layout itself and a `<Route path="/dashboard">` holding only an index route led to nothing. A route's `element` written on lines of its own, the way Prettier formats a long one, or wrapped in `<Suspense fallback={<Loader />}>`, now links to the page it shows rather than to nothing or to the fallback, and a path written in braces, like `path={"agents"}`, is read like any other. Re-index React projects after upgrading.
- In JavaScript and TypeScript, a module loaded with `require('./x').default` or `const { default: X } = require('./x')` now links to its default export when it is written as an ES module: bitwarden's desktop app loads `export default class OsBiometricsServiceMac` this way before calling `new OsBiometricsServiceMac(…)`, and React Native's own libraries load many of their modules like this. Before, `.default` was looked up only as an export named `default`, which `export default` is not, so the `new` and the calls made through it linked to nothing, to the local variable holding the module, or to a method of the same name somewhere else. A Svelte component loaded this way and created with `new App({ target })` now links to the component. A CommonJS module that sets `exports.default` itself, or both `module.exports` and `module.exports.default` as fastify does, still links to what it sets, and one that sets no `default` still links to nothing. Re-index JavaScript and TypeScript projects after upgrading.
- In JavaScript and TypeScript, an import written `import { default as AppRoot } from './routes/app/root'`, as in bulletproof-react's router, now counts as the module's default import, the same as `import AppRoot from './routes/app/root'`. Before, it was read as an import of an export named `default`, which no module has, so a call, a route or a JSX attribute that used `AppRoot` was matched by its name alone: it could link to another file's `AppRoot`, or to nothing when the default export has a name of its own. Svelte, Vue and Astro script blocks are read the same way.
- In React, a component's JSX no longer links it to a class or component that only shares a name with one of its type arguments or with a variable of its own. A type in angle brackets, like `Document` in `<PaginatedList<Document> items={…} />`, `User` in `useState<User>()` or `Entry` in a generic `<Entry extends BaseEntity>(…) =>` component, is no longer read as a tag. A tag naming a variable or parameter the component sets itself, like `<Content>` after `const Content = isDropdown ? DropdownMenu.SubContent : ContextMenu.SubContent` or `<Widget>` in `widgets.map((Widget) => <Widget />)`, now links to nothing, unless the component declares a component of that name inside itself. Before, these linked to an unrelated class or component elsewhere in the repository: outline's document lists showed up among the callers of its `Document` model class, and its menus among the callers of the command bar's `Content`, so `codegraph_explore`, callers and impact followed renders that never happen. Re-index React projects after upgrading.

Expand Down
243 changes: 243 additions & 0 deletions __tests__/require-default.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,243 @@
/**
* `require('./x').default` reads the `default` PROPERTY of the module's
* exports, and so does `const { default: X } = require('./x')`. The import
* mapping records it as a named import of `default`, which is right for a
* CommonJS module that sets the property by name (`exports.default = fn`, or
* the dual `module.exports = X; module.exports.default = X`). A module
* written as an ES module and compiled to CommonJS sets it to its default
* export, `export default class Foo`, which is no named export: bitwarden's
* desktop app loads its macOS biometrics service with
* `require("./os-biometrics-mac.service").default` and `new`s it, and that
* `instantiates` reference stayed unresolved. The named property is still
* looked up first; the ES module's default export answers only when there is
* no such property, and only in the module asked for.
*/
import { describe, it, expect, beforeAll, afterAll } from 'vitest';
import * as fs from 'fs';
import * as os from 'os';
import * as path from 'path';
import { CodeGraph } from '../src';
import type { Edge } from '../src/types';

let root = '';
let cg: CodeGraph;

beforeAll(async () => {
root = fs.mkdtempSync(path.join(os.tmpdir(), 'cg-require-default-'));
const files: Record<string, string> = {
'package.json': JSON.stringify({ name: 'desktop', devDependencies: { svelte: '^4' } }),
// bitwarden's shape: an ES module with an unexported helper above its
// default-exported class, loaded with `require(...).default` on one
// platform only.
'src/biometrics/os-biometrics.service.ts': `export interface OsBiometricService {
enrollPersistent(userId: string): Promise<string>;
}
`,
'src/biometrics/os-biometrics-mac.service.ts': `import { OsBiometricService } from './os-biometrics.service';

function getLookupKeyForUser(userId: string): string {
return \`\${userId}_user_biometric\`;
}

export default class OsBiometricsServiceMac implements OsBiometricService {
async enrollPersistent(userId: string): Promise<string> {
return getLookupKeyForUser(userId);
}
}
`,
'src/biometrics/main-biometrics.service.ts': `import { OsBiometricService } from './os-biometrics.service';

export class MainBiometricsService {
private osBiometricsService: OsBiometricService | undefined;

constructor(platform: string) {
if (platform === 'darwin') {
// eslint-disable-next-line
const OsBiometricsServiceMac = require('./os-biometrics-mac.service').default;
this.osBiometricsService = new OsBiometricsServiceMac();
}
}
}
`,
'src/biometrics/os-biometrics-mac.service.spec.ts': `describe('OsBiometricsServiceMac', () => {
it('enrolls in an isolated module registry', async () => {
const { default: ServiceCtor } = require('./os-biometrics-mac.service');
const isolated = new ServiceCtor();
await isolated.enrollPersistent('user');
});
});
`,
// A single-file component is its module's default export.
'src/App.svelte': `<script>
export let name = 'world';
</script>

<h1>Hello {name}!</h1>
`,
'src/main.js': `const App = require('./App.svelte').default;

const app = new App({ target: document.body });

module.exports = app;
`,
// CommonJS modules that set the property by name.
'lib/format.js': `'use strict';

exports.pad = function (value) {
return String(value).padStart(2, '0');
};

function formatDate(date) {
return exports.pad(date.getDate());
}

exports.default = formatDate;
`,
'lib/server.js': `'use strict';

function createServer(options) {
return { options };
}

module.exports = createServer;
module.exports.default = createServer;
module.exports.createServer = createServer;
`,
// A CommonJS module without the property: its exports are no default.
'lib/plain.js': `'use strict';

exports.first = function () {
return 1;
};

exports.second = () => 2;
`,
// `export * from` forwards every export but the default.
'src/widgets/Dial.ts': `export const DIAL_SIZE = 3;

export default class Dial {
size = DIAL_SIZE;
}
`,
'src/widgets/index.ts': `export * from './Dial';
`,
'src/dashboard.ts': `export function mountDashboard(): unknown {
const Widget = require('./widgets').default;
return new Widget();
}
`,
'lib/app.js': `'use strict';

const format = require('./format').default;
const build = require('./server').default;
const { default: fastify } = require('./server');
const plain = require('./plain').default;

function start() {
const server = build({ port: 3000 });
fastify({ logger: true });
plain();
return format(new Date()) + server.options.port;
}

module.exports = start;
`,
// Python has no default export: `default` is an attribute's name.
'py/__init__.py': '',
'py/mod.py': `def first():
return 1
`,
'py/use.py': `from .mod import default


def run():
return default()
`,
};
for (const [rel, content] of Object.entries(files)) {
fs.mkdirSync(path.dirname(path.join(root, rel)), { recursive: true });
fs.writeFileSync(path.join(root, rel), content);
}
cg = await CodeGraph.init(root, { index: true });
});

afterAll(() => {
cg?.close();
if (root) fs.rmSync(root, { recursive: true, force: true });
});

const node = (file: string, name: string) => {
const found = cg.getNodesInFile(file).find((n) => n.name === name && n.kind !== 'import');
expect(found, `${file}: ${name}`).toBeDefined();
return found!;
};

/** The edges into `target`, as `kind source-file resolvedBy`. */
const into = (target: { id: string }, kinds: Edge['kind'][]) =>
cg
.getIncomingEdges(target.id)
.filter((e) => kinds.includes(e.kind))
.map((e) => `${e.kind} ${cg.getNode(e.source)!.filePath} ${(e.metadata as { resolvedBy?: string }).resolvedBy}`)
.sort();

/** The call and instantiation edges out of a file's nodes, as `kind target-file:target-name`. */
const outOf = (file: string) =>
cg
.getOutgoingEdgesFrom(cg.getNodesInFile(file).map((n) => n.id))
.filter((e) => e.kind === 'calls' || e.kind === 'instantiates')
.map((e) => {
const target = cg.getNode(e.target)!;
return `${e.kind} ${target.filePath}:${target.name}`;
})
.sort();

describe('`require(…).default` of an ES module', () => {
it('is the module’s default export, as `.default` and as `{ default: X }`', () => {
expect(into(node('src/biometrics/os-biometrics-mac.service.ts', 'OsBiometricsServiceMac'), ['instantiates'])).toEqual([
'instantiates src/biometrics/main-biometrics.service.ts import',
'instantiates src/biometrics/os-biometrics-mac.service.spec.ts import',
]);
});

it('is the component a single-file component file is', () => {
expect(into(node('src/App.svelte', 'App'), ['instantiates'])).toEqual(['instantiates src/main.js import']);
});
});

describe('`require(…).default` of a CommonJS module', () => {
it('is what `exports.default` names, not the module’s first export', () => {
expect(outOf('lib/app.js')).toContain('calls lib/format.js:formatDate');
expect(into(node('lib/format.js', 'pad'), ['calls'])).toEqual([]);
});

it('is X in the dual export `module.exports = X; module.exports.default = X`', () => {
expect(into(node('lib/server.js', 'createServer'), ['calls'])).toEqual([
'calls lib/app.js import',
'calls lib/app.js import',
]);
});

it('is nothing when the module sets no `default`: its exports are no default', () => {
expect(into(node('lib/plain.js', 'first'), ['calls'])).toEqual([]);
expect(into(node('lib/plain.js', 'second'), ['calls'])).toEqual([]);
});
});

describe('what the fallback leaves alone', () => {
it('a default behind `export * from`, which forwards none', () => {
expect(into(node('src/widgets/Dial.ts', 'Dial'), ['instantiates', 'calls'])).toEqual([]);
expect(outOf('src/dashboard.ts')).toEqual([]);
});

it('every other require binding, which reaches only what it names', () => {
expect(outOf('lib/app.js').filter((e) => !e.includes(' lib/app.js:'))).toEqual([
'calls lib/format.js:formatDate',
'calls lib/server.js:createServer',
'calls lib/server.js:createServer',
]);
});

it('Python’s `from .mod import default`, an attribute of that name', () => {
expect(into(node('py/mod.py', 'first'), ['calls'])).toEqual([]);
});
});
43 changes: 41 additions & 2 deletions src/resolution/import-resolver.ts
Original file line number Diff line number Diff line change
Expand Up @@ -111,6 +111,8 @@ interface FileExportIndex {

const DEFAULT_BINDING_KINDS = new Set<string>(['function', 'class', 'component', 'constant', 'variable']);
const DEFAULT_EXPORT_BINDING_RE = /^[ \t]*export\s+default\s+([A-Za-z_$][\w$]*)\s*;?[ \t]*$/m;
/** Any `export default …` statement: the module is an ES module with a default export. */
const ESM_DEFAULT_EXPORT_RE = /^[ \t]*export\s+default\b/m;
const JS_FAMILY_FILE = /\.(?:[cm]?[jt]sx?)$/;

/** The identifier `export default NAME` names in a JS-family file, or null. */
Expand Down Expand Up @@ -179,6 +181,28 @@ function defaultExportBindingNode(filePath: string, idx: FileExportIndex, contex
return idx.defaultBinding ?? undefined;
}

/**
* The declaration a module default-exports: the component a single-file
* component file is, else what an `export default NAME` statement names, else
* the first exported function or class (`export default class Foo`).
*/
function esmDefaultExport(filePath: string, idx: FileExportIndex, context: ResolutionContext): Node | undefined {
return idx.defaultComponent ?? defaultExportBindingNode(filePath, idx, context) ?? idx.defaultFnClass;
}

/**
* Whether the module has an ESM default export at all: it is a single-file
* component, or a JS-family file with an `export default` statement. A
* CommonJS module has none, though its `exports.x = function` declarations are
* exported and would feed the first-exported-function guess.
*/
function hasEsmDefaultExport(filePath: string, idx: FileExportIndex, context: ResolutionContext): boolean {
if (idx.defaultComponent) return true;
if (!JS_FAMILY_FILE.test(filePath)) return false;
const source = context.readFile(filePath);
return !!source && source.includes('default') && ESM_DEFAULT_EXPORT_RE.test(source);
}

/** What this file exports as `name`: an exported declaration, else a local export clause's binding. */
function exportedByName(filePath: string, idx: FileExportIndex, name: string, context: ResolutionContext): Node | undefined {
const direct = idx.byName.get(name);
Expand Down Expand Up @@ -2683,8 +2707,7 @@ function findExportedSymbolWalk(
// resolves and the component shows a false 0 callers (#629).
// A component file IS its default export; otherwise the statement that
// names the binding beats the first-exported-function guess.
const direct =
exportIndex.defaultComponent ?? defaultExportBindingNode(filePath, exportIndex, context) ?? exportIndex.defaultFnClass;
const direct = esmDefaultExport(filePath, exportIndex, context);
if (direct) return direct;
// CommonJS: `module.exports = createApplication`, or `= require('./lib/express')`.
const commonJs = commonJsDefaultExport(filePath, context);
Expand All @@ -2703,6 +2726,22 @@ function findExportedSymbolWalk(
} else {
const direct = exportedByName(filePath, exportIndex, want.exportedName, context);
if (direct) return direct;
// `require('./x').default` and `const { default: X } = require('./x')` read
// the `default` property of module.exports. A CommonJS module sets it by
// name (`exports.default = fn`, the dual `module.exports.default = X`),
// found just above. A module written as an ES module sets it to its
// default export, `export default class Foo`, which is no named export —
// the answer when the module has no such property. Only in the module
// asked for: `export * from` never forwards a default.
if (
depth === 0 &&
want.exportedName === 'default' &&
ESM_IMPORT_LANGUAGES.has(language) &&
hasEsmDefaultExport(filePath, exportIndex, context)
) {
const esmDefault = esmDefaultExport(filePath, exportIndex, context);
if (esmDefault) return esmDefault;
}
}

// 2. Re-export hit: the file forwards the symbol to another module.
Expand Down