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 @@ -167,6 +167,7 @@ and adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
- In JavaScript and TypeScript, a name imported from a package your `package.json` lists no longer links to a project symbol that only shares its name when `tsconfig.json` or `jsconfig.json` has a catch-all path alias, like `"*": ["./typings/*"]` or `"*": ["src/*", "node_modules/*"]`, or an alias that points the package at a file in `node_modules`, like `"lit/decorators": ["./node_modules/lit/decorators.js"]`. Such an alias made every package look like part of the project, so `import { Typography } from '@mui/material'` was linked to the project's own `Typography` and every lit `@property()` decorator to an unrelated class's `property` field, and `codegraph callers`, impact and `codegraph affected` listed code that never used them. An import the alias does map to a file of your project, like a `.d.ts` you keep for an untyped package or `components/Button` through `"*": ["src/*"]`, links as before. Re-index affected projects after upgrading.
- In JavaScript and TypeScript, a function the file imports from a package and passes along as a value, like react-router's `Link` in `styled(Link)`, lodash's `noop` in `createContext({ onClose: noop })` or chart.js's `Legend` in `Chart.register(Legend)`, no longer links to a project function that only shares its name. Before, whenever the project had exactly one function of that name, every such value linked to it: outline's breadcrumbs, menus and document lists showed up among the callers of an email template's `Link`, and a test's own `noop` stub in cord-field among the code the app's contexts use, so `codegraph callers`, impact and `codegraph_explore` followed references that never happen. A function the file declares itself, including one that hides the import inside a function, and one imported from a file of your project link as before. Re-index JavaScript and TypeScript projects after upgrading.
- In JavaScript and TypeScript, a value the file imports from your own project and passes along now links only to what it imports: a constant like `isCloudHosted`, a class like a `Star` model, a whole module (`import * as Slack from '../slack'`), or a stylesheet, image or GraphQL document (`import TCO21 from './themes/TCO21.scss'`) no longer links to a function elsewhere that only shares its name. Before, when the project had exactly one function of that name, such a value linked to it: outline's settings screens showed up among the callers of the server's `isCloudHosted()` check, and its stores among those of the `<Star>` icon, so `codegraph callers`, impact and `codegraph_explore` followed references that never happen. A component that wraps the function declared beside it, like `export default styled(Disclosure)`, and a page loaded lazily, like `lazy(() => import('../pages/Home'))`, still lead to that function. Re-index JavaScript and TypeScript projects after upgrading.
- In JavaScript, an import of a TypeScript module written without an extension, like `import AudioModal from './audio_modal'` in a `.jsx` file next to `audio_modal.tsx`, now links to that module, as it already did from a TypeScript file. Before, a `.js` or `.jsx` file's imports of TypeScript modules were never followed, so in a codebase moving to TypeScript they were matched by name instead: an import under another name linked nothing, and a name that repeats could link to the wrong file, so mastodon's `<IconButton>` and `<Status>` tags rendered other components of those names than the ones each file imports. A JavaScript module beside a TypeScript one of the same name still comes first. Re-index JavaScript projects after upgrading.
- `codegraph_explore`'s Flow now goes through the implementation your query names when an interface method has several that lead to the same place. Before, it took whichever one came first in the index: asking about prometheus's `Engine.execEvalStmt Queryable.Querier fanout.Querier NewMergeQuerier` traced the call through the TSDB's `DB.Querier` and left the `fanout.Querier` you named off the Flow. Any two routes of the same length are now settled this way, in favor of the one that passes through more of the symbols you named.
- In C and C++, a struct, class, union or enum defined in a variable declaration, like `struct Foo { … } foo;`, is now indexed with its methods and enum values, next to the variable. Before, at most the variable was, so the type was missing from search and `codegraph_explore`, a class deriving from it had no base, and calls to its methods linked to nothing: in redis, the Sentinel's `sentinelState` and `redis-cli`'s `config` struct were missing. A struct, union or enum with no name of its own now takes the name of the variable it declares, so redis's `static struct { … } SPT;` is found as `SPT`, and one declared inside a function is named after its variable instead of `<anonymous>`. The comment above the declaration becomes the type's description. Re-index C and C++ projects after upgrading.
- In TypeScript React code, a styled-components or emotion component declared with a type argument, like `const Wrapper = styled.div<WrapperProps>` followed by its CSS, or `styled(Base)<Props>`, is now indexed as a component, as one written without a type argument already was. Before, it was indexed as a plain constant, so `codegraph callers` and impact found none of the code that renders it, and its tags could link to another file's component of the same name instead: in outline/outline, `NudeButton` and `HStack` had no callers, and `<Text>` tags linked to a different `Text` than the one each file imports. Re-index TypeScript React projects after upgrading.
Expand Down
137 changes: 137 additions & 0 deletions __tests__/js-imports-ts-modules.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,137 @@
/**
* A JavaScript file importing a TypeScript module without an extension —
* `import AudioModal from './audio_modal'` in mastodon's `modal_root.jsx`,
* with `audio_modal.tsx` beside it — names that module, as it does for the
* bundler and for TypeScript with `allowJs`. The import resolver tried only
* JavaScript extensions for a `.js` or `.jsx` importer, so a codebase halfway
* through moving to TypeScript had none of those imports followed: they were
* matched by name, which links nothing when the local name differs from the
* export and picks a namesake when the name repeats.
*
* A JavaScript module still comes first: next to `helpers.js`, a `helpers.ts`
* doesn't take its importers.
*/
import { describe, it, expect, afterAll } from 'vitest';
import * as fs from 'fs';
import * as os from 'os';
import * as path from 'path';
import { CodeGraph } from '../src';

const roots: string[] = [];
afterAll(() => {
for (const r of roots.splice(0)) fs.rmSync(r, { recursive: true, force: true });
});

async function indexProject(files: Record<string, string>): Promise<CodeGraph> {
const root = fs.mkdtempSync(path.join(os.tmpdir(), 'cg-js-imports-ts-'));
roots.push(root);
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);
}
return CodeGraph.init(root, { index: true });
}

/** `<kind> <target file>:<target qualified name> by <resolver>` for the import and call edges leaving `file`. */
function edgesFrom(cg: CodeGraph, file: string): string[] {
const ids = cg.getNodesInFile(file).map((n) => n.id);
return [...new Set(cg
.getOutgoingEdgesFrom(ids)
.filter((e) => e.kind === 'imports' || e.kind === 'calls')
.map((e) => {
const target = cg.getNode(e.target);
return `${e.kind} ${target?.filePath}:${target?.qualifiedName} by ${String(e.metadata?.resolvedBy ?? e.metadata?.synthesizedBy)}`;
}))]
.filter((e) => !e.includes(`${file}:`))
.sort();
}

describe('a JavaScript file importing a TypeScript module', () => {
it('links the module the import names, without an extension', async () => {
const cg = await indexProject({
'package.json': JSON.stringify({ name: 'social', dependencies: { react: '^18.2.0' } }),
// The TypeScript modules the JavaScript files import.
'app/features/ui/components/audio_modal.tsx': `export default function AudioModal(props: { src: string }) {
return <audio src={props.src} />;
}
`,
'app/utils/time.ts': `export function formatTime(seconds: number): string {
return String(seconds);
}
`,
'app/store/index.ts': `export function configureStore(): object {
return {};
}
`,
// A namesake that name matching would have to choose between.
'app/features/video/time.ts': `export function formatTime(seconds: number): string {
return seconds.toFixed(1);
}
`,
'app/features/ui/components/modal_root.jsx': `import Modal from './audio_modal';
import { formatTime } from '../../../utils/time';

export function ModalRoot(props) {
return <Modal src={formatTime(props.seconds)} />;
}
`,
'app/main.js': `import { configureStore } from './store';

export function start() {
return configureStore();
}
`,
});
try {
expect({
modalRoot: edgesFrom(cg, 'app/features/ui/components/modal_root.jsx'),
main: edgesFrom(cg, 'app/main.js'),
}).toEqual({
// The default import links its module, and the tag renders what it exports.
modalRoot: [
'calls app/features/ui/components/audio_modal.tsx:AudioModal by jsx-render',
'calls app/utils/time.ts:formatTime by import',
'imports app/features/ui/components/audio_modal.tsx:app/features/ui/components/audio_modal.tsx by import',
'imports app/utils/time.ts:app/utils/time.ts by import',
'imports app/utils/time.ts:formatTime by import',
],
main: [
'calls app/store/index.ts:configureStore by import',
'imports app/store/index.ts:app/store/index.ts by import',
'imports app/store/index.ts:configureStore by import',
],
});
} finally {
cg.close();
}
}, 60_000);

it('still links the JavaScript module when both exist', async () => {
const cg = await indexProject({
'package.json': JSON.stringify({ name: 'tools' }),
'lib/helpers.js': `export function slugify(text) {
return text.toLowerCase();
}
`,
'lib/helpers.ts': `export function slugify(text: string): string {
return text.toLowerCase();
}
`,
'lib/index.js': `import { slugify } from './helpers';

export function title(text) {
return slugify(text);
}
`,
});
try {
expect(edgesFrom(cg, 'lib/index.js')).toEqual([
'calls lib/helpers.js:slugify by import',
'imports lib/helpers.js:lib/helpers.js by import',
'imports lib/helpers.js:slugify by import',
]);
} finally {
cg.close();
}
}, 60_000);
});
9 changes: 7 additions & 2 deletions src/resolution/import-resolver.ts
Original file line number Diff line number Diff line change
Expand Up @@ -42,9 +42,14 @@ const EXTENSION_RESOLUTION: Record<string, string[]> = {
// module-entry convention, hit when a bare workspace import ("data") is
// rewritten to the member's directory; lowercase variants for safety.
arkts: ['.ets', '.ts', '.d.ts', '.js', '/Index.ets', '/index.ets', '/index.ts', '/index.js'],
javascript: ['.js', '.jsx', '.mjs', '.cjs', '.xsjs', '.xsjslib', '/index.js', '/index.jsx'],
// A JavaScript file imports TypeScript modules too, as the bundler and
// tsc's `allowJs` read it: mastodon's `modal_root.jsx` imports
// `./audio_modal`, which is `audio_modal.tsx`. JavaScript comes first, so a
// `helpers.ts` beside `helpers.js` doesn't take its importers. Never `.d.ts`:
// a declaration file is not the module a JavaScript file runs.
javascript: ['.js', '.jsx', '.mjs', '.cjs', '.xsjs', '.xsjslib', '.ts', '.tsx', '/index.js', '/index.jsx', '/index.ts', '/index.tsx'],
tsx: ['.tsx', '.ts', '.d.ts', '.js', '.jsx', '/index.tsx', '/index.ts', '/index.js', '/index.jsx'],
jsx: ['.jsx', '.js', '/index.jsx', '/index.js'],
jsx: ['.jsx', '.js', '.tsx', '.ts', '/index.jsx', '/index.js', '/index.tsx', '/index.ts'],
// SFC consumers import plain TS/JS, sibling components, and barrels
// (`./lib` → `./lib/index.ts`). Without a list, relative imports from a
// `.svelte`/`.vue` file resolve to nothing, so barrel callers vanish (#629).
Expand Down