From aa01692620e156dd56d16607c938ea023ddb6673 Mon Sep 17 00:00:00 2001 From: Colby Mchenry Date: Wed, 21 Oct 2026 10:08:27 +0000 Subject: [PATCH] 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 --- CHANGELOG.md | 1 + __tests__/tsconfig-baseurl-imports.test.ts | 123 +++++++++++++++++++++ src/resolution/import-resolver.ts | 14 ++- 3 files changed, 135 insertions(+), 3 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index cd475615..560dacf1 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -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 ``, 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 diff --git a/__tests__/tsconfig-baseurl-imports.test.ts b/__tests__/tsconfig-baseurl-imports.test.ts index 47e2ebad..6962df77 100644 --- a/__tests__/tsconfig-baseurl-imports.test.ts +++ b/__tests__/tsconfig-baseurl-imports.test.ts @@ -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): void { @@ -386,6 +406,17 @@ const SYNC_MODULES: Record = { '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 = { + '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 = { + '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 }) => ; +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 ; +} +`, + '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}`); + }); +}); diff --git a/src/resolution/import-resolver.ts b/src/resolution/import-resolver.ts index 9f968c44..78bfea84 100644 --- a/src/resolution/import-resolver.ts +++ b/src/resolution/import-resolver.ts @@ -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; }