fix(js,ts): an import from the app's root maps through the app's own tsconfig paths (#2740)

bigbluebutton's client imports nearly everything from its root,
`import Icon from '/imports/ui/components/common/icon/component'`, which its
tsconfig maps with `"paths": { "/*": ["*"] }`. tsc maps a rooted name through
`paths` (only `./` and `../` count as relative there) and never reads it
from `baseUrl`. ownConfigCandidates refused every specifier starting with
`/`, so all 2,375 of these imports stayed unresolved and every name they
import linked by bare-name matching, often to the wrong one of several
same-named components.

A rooted specifier now goes through the nearest config's `paths` patterns
only; `baseUrl` is still never applied to it, and a protocol-relative
`//host/x` stays out. On bigbluebutton the unresolved refs fall from 26,189
to 22,565 and its rooted imports from 2,375 unresolved to 1 (a module that
isn't in the repository); 13 control repos are byte-identical.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
Colby Mchenry
2026-10-21 10:08:27 +00:00
committed by GitHub
co-authored by Claude Opus 5.5
parent ada1445458
commit aa01692620
3 changed files with 135 additions and 3 deletions
+1
View File
@@ -267,6 +267,7 @@ and adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
- In TypeScript React files, a type annotation no longer links to a component of the same name. When a file declares a type next to a component or value of the same name, like `type ModalRegistration` beside a `ModalRegistration` component, its annotations now link to the type while its calls still link to the component, and a type the file declares on its own, like `type Option`, or a generic's type parameter, like `<Item>`, no longer links to an `Option` or `Item` component elsewhere in the project. Before, `codegraph callers` and impact listed those annotations as uses of the component. Re-index TypeScript React projects after upgrading.
- In React, a component declared through a wrapper with no render function of its own, like styled-components' `styled(ActionButton)` (with `.attrs(…)`, `.withConfig(…)` or type arguments too), emotion's and MUI's `styled(Box)(…)`, `memo(Button)` or `forwardRef(Render)`, now links to the component it wraps, so a render flow in `codegraph_explore`, a component's callers and its impact go on through the wrapper instead of stopping at it. The calls and values in a wrapper's styles, like `${(props) => theme.color(props)}` or a shared `css` block, now belong to the wrapper, as they do to any other constant, so a theme helper used only in styles has callers. A component that wraps its render function in `forwardRef(…)` or `memo(…)` now shows in the impact of the top-level constants it reads, as a function component does. A wrapper around a component that comes from a package, like react-router's `Link` or antd's `Typography.Text`, links to nothing, and the file that declares a wrapper no longer shows as referencing the component it wraps. Re-index React projects after upgrading.
- In TypeScript, a type written with a qualifier, like `React.ReactNode`, `Y.Doc`, `TChat.ChatMessage` or `EQueryType.CLICKHOUSE`, now links to the member of what the qualifier names: a type from a package, like React's, no longer links to a class or component of your own that shares its name, and a member of an enum or a namespace your project declares links to that member instead of to another enum's or a same-named component. Re-index TypeScript projects after upgrading.
- In JavaScript and TypeScript, an import written from the app's root with a leading slash, like bigbluebutton's `import Icon from '/imports/ui/components/common/icon/component'`, now links to the file it names when the app's `tsconfig.json` or `jsconfig.json` maps it, as `"paths": { "/*": ["*"] }` does. Before, such an import was taken for a package, so the module looked unused and every name it imports linked by name instead, often to the wrong one of several components of the same name, or to nothing. Re-index projects that import this way after upgrading.
## [1.6.2] - 2026-10-03
+123
View File
@@ -143,6 +143,26 @@ describe('resolving a bare specifier through the app’s own config', () => {
expect(resolveImportPath('components/Button', 'src/App.jsx', 'jsx', ctx)).toBeNull();
expect(resolveImportPath('@/components/Button', 'src/App.jsx', 'jsx', ctx)).toBe('src/components/Button.jsx');
});
it('maps a rooted specifier through paths, as tsc does (bigbluebutton’s "/*": ["*"])', () => {
const rooted: AliasMap = {
baseUrl: path.join(ROOT, 'bigbluebutton-html5'),
declaresBaseUrl: true,
patterns: [{ prefix: '/', suffix: '', hasWildcard: true, replacements: ['*'] }],
};
const from = 'bigbluebutton-html5/imports/ui/components/button/styles.js';
const ctx = stubContext([from, 'bigbluebutton-html5/imports/ui/components/common/icon/component.jsx'], {
nearest: (f) => (f.startsWith('bigbluebutton-html5/') ? rooted : null),
});
expect(resolveImportPath('/imports/ui/components/common/icon/component', from, 'javascript', ctx))
.toBe('bigbluebutton-html5/imports/ui/components/common/icon/component.jsx');
});
it('never reads a rooted specifier from baseUrl, and leaves a protocol-relative one alone', () => {
const ctx = stubContext(['app/main.js', 'app/imports/ui/icon.js', 'app/cdn.example.com/lib.js'], { nearest: () => baseUrlOnly('app') });
expect(resolveImportPath('/imports/ui/icon', 'app/main.js', 'javascript', ctx)).toBeNull();
expect(resolveImportPath('//cdn.example.com/lib', 'app/main.js', 'javascript', ctx)).toBeNull();
});
});
function writeTree(root: string, files: Record<string, string>): void {
@@ -386,6 +406,17 @@ const SYNC_MODULES: Record<string, string> = {
'src/state/store.js': 'export const store = { get(k) { return k; } };\n',
'src/config.js': 'const config = { port: 3000 };\nexport default config;\n',
};
// The same, written from the app's root (bigbluebutton's `"/*": ["*"]`).
const ROOTED_IMPORTERS: Record<string, string> = {
'package.json': JSON.stringify({ name: 'late-rooted-app', dependencies: { react: '^18' } }),
'html5/tsconfig.json': JSON.stringify({ compilerOptions: { baseUrl: '.', paths: { '/*': ['*'] } } }),
'html5/imports/call.js': "import runIt from '/imports/handlers/run';\nexport function go() { return runIt(); }\n",
'html5/imports/draw.js': "import { Widget as W } from '/imports/widgets/Widget';\nexport function paint() { return new W(); }\n",
};
const ROOTED_MODULES: Record<string, string> = {
'html5/imports/handlers/run.js': 'export default function handle() { return 1; }\n',
'html5/imports/widgets/Widget.js': 'export class Widget {}\n',
};
describe('a sync links an import read from baseUrl once its module appears', () => {
const roots: string[] = [];
@@ -433,6 +464,19 @@ describe('a sync links an import read from baseUrl once its module appears', ()
expect(edgesOf(cg)).toEqual(freshEdges);
}, 120_000);
it('matches a fresh index for rooted imports the app maps through paths', async () => {
const { root, cg } = await indexed(ROOTED_IMPORTERS);
writeTree(root, ROOTED_MODULES);
const result = await cg.sync();
expect(result.filesAdded).toBe(Object.keys(ROOTED_MODULES).length);
const fresh = await indexed({ ...ROOTED_IMPORTERS, ...ROOTED_MODULES });
const freshEdges = edgesOf(fresh.cg);
expect(freshEdges.some((e) => e.startsWith('calls function html5/imports/call.js:go') && e.includes('-> function html5/imports/handlers/run.js:handle'))).toBe(true);
expect(freshEdges.some((e) => e.startsWith('instantiates function html5/imports/draw.js:paint') && e.includes('-> class html5/imports/widgets/Widget.js:Widget'))).toBe(true);
expect(edgesOf(cg)).toEqual(freshEdges);
}, 120_000);
it('matches a fresh index when an edit gives the module what its importers import', async () => {
const stubs = Object.fromEntries(Object.keys(SYNC_MODULES).map((file) => [file, 'export const placeholder = 1;\n']));
const { root, cg } = await indexed({ ...SYNC_IMPORTERS, ...stubs });
@@ -445,3 +489,82 @@ describe('a sync links an import read from baseUrl once its module appears', ()
expect(edgesOf(cg)).toEqual(edgesOf(fresh.cg));
}, 120_000);
});
describe('rooted imports an app maps through its own tsconfig paths (bigbluebutton shape)', () => {
let root = '';
let cg: CodeGraph;
beforeAll(async () => {
root = fs.mkdtempSync(path.join(os.tmpdir(), 'cg-rooted-paths-'));
writeTree(root, {
'package.json': JSON.stringify({ name: 'bigbluebutton', private: true }),
'bigbluebutton-html5/package.json': JSON.stringify({ name: 'bbb-html5', dependencies: { react: '^18', 'styled-components': '^5' } }),
// JSONC, a block comment beside a "/*" pattern, as bigbluebutton writes it.
'bigbluebutton-html5/tsconfig.json': `{
"compilerOptions": {
/* Module Resolution Options */
"baseUrl": ".",
"paths": {
/* Support absolute /imports/* with a leading '/' */
"/*": ["*"]
}
}
}
`,
'bigbluebutton-html5/imports/ui/components/common/icon/component.jsx': `const Icon = ({ iconName }) => <i className={iconName} />;
export default Icon;
`,
'bigbluebutton-html5/imports/utils/format.js': `export function formatTime(time) {
return String(time);
}
`,
// A namesake that a name match takes.
'bigbluebutton-html5/imports/ui/components/audio/styles.js': `import styled from 'styled-components';
const Icon = styled.i\`\`;
export default { Icon };
`,
'bigbluebutton-html5/imports/ui/components/timer/component.jsx': `import Icon from '/imports/ui/components/common/icon/component';
import { formatTime } from '/imports/utils/format';
export default function Timer({ time }) {
return <Icon iconName={formatTime(time)} />;
}
`,
'bigbluebutton-html5/imports/ui/components/button/styles.js': `import styled from 'styled-components';
import Icon from '/imports/ui/components/common/icon/component';
const ButtonIcon = styled(Icon)\`\`;
export default { ButtonIcon };
`,
});
cg = await CodeGraph.init(root, { index: true });
});
afterAll(() => {
cg?.close();
if (root) fs.rmSync(root, { recursive: true, force: true, maxRetries: 5 });
});
const ICON = 'bigbluebutton-html5/imports/ui/components/common/icon/component.jsx';
const TIMER = 'bigbluebutton-html5/imports/ui/components/timer/component.jsx';
/** `kind` edges out of `file`'s nodes, as `name -> name@path`. */
const edgesOut = (file: string, kind: string): string[] =>
cg.getOutgoingEdgesFrom(cg.getNodesInFile(file).map((n) => n.id))
.filter((e) => e.kind === kind)
.map((e) => `${cg.getNode(e.source)!.name} -> ${cg.getNode(e.target)!.name}@${cg.getNode(e.target)!.filePath}`);
it('links the module a rooted import names', () => {
expect(edgesOut(TIMER, 'imports')).toContain(`component.jsx -> component.jsx@${ICON}`);
expect(edgesOut(TIMER, 'imports')).toContain('component.jsx -> format.js@bigbluebutton-html5/imports/utils/format.js');
});
it('renders and calls what the rooted import binds, not a namesake', () => {
const calls = edgesOut(TIMER, 'calls');
expect(calls).toContain(`Timer -> Icon@${ICON}`);
expect(calls).not.toContain('Timer -> Icon@bigbluebutton-html5/imports/ui/components/audio/styles.js');
expect(calls).toContain('Timer -> formatTime@bigbluebutton-html5/imports/utils/format.js');
});
it('links a styled wrapper of a rooted import to the component it wraps', () => {
expect(edgesOut('bigbluebutton-html5/imports/ui/components/button/styles.js', 'calls')).toContain(`ButtonIcon -> Icon@${ICON}`);
});
});
+11 -3
View File
@@ -1381,17 +1381,25 @@ function resolveFromOwnConfig(
return null;
}
/** Where the importing app's own config reads a bare specifier from (see resolveFromOwnConfig), or null when it reads none. */
/**
* Where the importing app's own config reads a bare specifier from (see
* resolveFromOwnConfig), or null when it reads none. A rooted one,
* `/imports/ui/components/icon/component`, is a path: tsc maps it through
* `paths` (bigbluebutton's `"/*": ["*"]`, kept from its Meteor days) and
* never reads it from `baseUrl`.
*/
function ownConfigCandidates(
importPath: string,
fromFile: string,
language: Language,
context: ResolutionContext
): string[] | null {
if (!ESM_IMPORT_LANGUAGES.has(language) || !importPath || importPath.startsWith('/') || /^[A-Za-z][\w+.-]*:/.test(importPath)) return null;
if (!ESM_IMPORT_LANGUAGES.has(language) || !importPath || importPath.startsWith('//') || /^[A-Za-z][\w+.-]*:/.test(importPath)) return null;
const config = context.getNearestAliases?.(fromFile) ?? context.getProjectAliases?.();
if (!config) return null;
const candidates = aliasAndBaseUrlCandidates(importPath, config, context.getProjectRoot());
const candidates = importPath.startsWith('/')
? applyAliases(importPath, config, context.getProjectRoot())
: aliasAndBaseUrlCandidates(importPath, config, context.getProjectRoot());
return candidates.length > 0 ? candidates : null;
}