mirror of
https://github.com/colbymchenry/codegraph.git
synced 2026-10-24 19:30:10 +08:00
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:
co-authored by
Claude Opus 5.5
parent
ada1445458
commit
aa01692620
@@ -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
|
||||
|
||||
|
||||
@@ -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}`);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
|
||||
Reference in New Issue
Block a user