mirror of
https://github.com/colbymchenry/codegraph.git
synced 2026-11-15 04:21:31 +08:00
The React, Svelte, Astro and Vue component resolvers each picked among a name's definitions by preferring one whose path started with the referencing file's folder, compared as text with no separator: `src/ui/Card.tsx`'s `Widget` took `src/ui-kit/Widget.tsx`, since `src/ui-kit/...` starts with `src/ui`. A file at the project root has the folder `''`, which every path starts with, so its pick was whichever definition came first, skipping the rule that a name with several definitions and nothing to choose by links nothing (#764). One helper, inReferencingFolder (frameworks/component-folder.ts), now serves all four copies: a candidate counts when its path starts with the folder plus `/`, so a folder below the referencing file's still counts, and a root file has no folder to prefer. Validation: new component-same-folder tests (7) fail 6 of 7 on main, one per resolver plus the root and subfolder cases; the helper's unit test runs on the new module in both. A/B against main: outline, mastodon (app/javascript), excalidraw, vben, shadcn-svelte (docs/src) and gin are byte-identical, since real code imports its components and the framework pick runs only for a name the file doesn't import. Reimplements PR #683 by @Mubashirrrr. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
134 lines
5.8 KiB
TypeScript
134 lines
5.8 KiB
TypeScript
/**
|
|
* The folder preference of the React, Svelte, Astro and Vue component
|
|
* resolvers, which pick among several definitions of a component name.
|
|
*
|
|
* They preferred a definition whose path started with the referencing file's
|
|
* folder, compared as text: `src/ui` also starts `src/ui-kit/Widget.tsx`, so
|
|
* `src/ui/Card.tsx`'s `Widget` could land in the sibling folder. A file at the
|
|
* project root has the folder `''`, which every path starts with, so its pick
|
|
* was whatever definition came first, skipping the rule that a name with
|
|
* several definitions and nothing to tell them apart links nothing (#764).
|
|
*
|
|
* The folder is now compared whole, a folder below it still counts, and a root
|
|
* file has no folder to prefer.
|
|
*/
|
|
import { describe, it, expect, afterEach } from 'vitest';
|
|
import * as fs from 'fs';
|
|
import * as os from 'os';
|
|
import * as path from 'path';
|
|
import { CodeGraph } from '../src';
|
|
import { inReferencingFolder } from '../src/resolution/frameworks/component-folder';
|
|
|
|
let dirs: string[] = [];
|
|
let graphs: CodeGraph[] = [];
|
|
afterEach(() => {
|
|
for (const graph of graphs) graph.close();
|
|
for (const dir of dirs) fs.rmSync(dir, { recursive: true, force: true });
|
|
graphs = [];
|
|
dirs = [];
|
|
});
|
|
|
|
async function index(files: Record<string, string>): Promise<CodeGraph> {
|
|
const dir = fs.mkdtempSync(path.join(os.tmpdir(), 'cg-component-folder-'));
|
|
dirs.push(dir);
|
|
for (const [rel, content] of Object.entries(files)) {
|
|
fs.mkdirSync(path.dirname(path.join(dir, rel)), { recursive: true });
|
|
fs.writeFileSync(path.join(dir, rel), content);
|
|
}
|
|
const cg = await CodeGraph.init(dir, { index: true });
|
|
graphs.push(cg);
|
|
return cg;
|
|
}
|
|
|
|
/** Every edge the framework resolvers made into a node named `name`, as `file` of the target. */
|
|
function frameworkPicks(cg: CodeGraph, name: string): string[] {
|
|
return cg
|
|
.getNodesByName(name)
|
|
.flatMap((target) =>
|
|
cg
|
|
.getIncomingEdges(target.id)
|
|
.filter((e) => (e.metadata as { resolvedBy?: string } | undefined)?.resolvedBy === 'framework')
|
|
.map(() => target.filePath)
|
|
)
|
|
.sort();
|
|
}
|
|
|
|
const REACT = JSON.stringify({ name: 'app', dependencies: { react: '^18.0.0' } });
|
|
const SVELTE = JSON.stringify({ name: 'app', devDependencies: { svelte: '^4.0.0' } });
|
|
const ASTRO = JSON.stringify({ name: 'app', dependencies: { astro: '^4.0.0' } });
|
|
const VUE = JSON.stringify({ name: 'app', dependencies: { vue: '^3.0.0' } });
|
|
const reactWidget = 'export function Widget(props: {}) { return null; }\n';
|
|
|
|
describe('the folder a component resolver prefers', () => {
|
|
it('compares folders whole, keeps folders below, and gives a root file none', () => {
|
|
const files = ['src/ui-kit/Widget.tsx', 'src/ui/forms/Widget.tsx', 'src/ui/Widget.tsx', 'Widget.tsx'];
|
|
expect(inReferencingFolder('src/ui/Card.tsx', files, (f) => f)).toEqual(['src/ui/forms/Widget.tsx', 'src/ui/Widget.tsx']);
|
|
expect(inReferencingFolder('App.tsx', files, (f) => f)).toEqual([]);
|
|
});
|
|
|
|
it('React: a sibling folder that only starts with the same name is not the folder', async () => {
|
|
const cg = await index({
|
|
'package.json': REACT,
|
|
'src/ui/Card.tsx': 'export function Card() {\n return Widget({});\n}\n',
|
|
'src/ui-kit/Widget.tsx': reactWidget,
|
|
'lib/Widget.tsx': reactWidget,
|
|
});
|
|
// Two definitions, neither in `src/ui/`: the framework has nothing to choose by.
|
|
expect(frameworkPicks(cg, 'Widget')).toEqual([]);
|
|
});
|
|
|
|
it('React: the folder itself, and a folder below it, still come first', async () => {
|
|
const cg = await index({
|
|
'package.json': REACT,
|
|
'src/ui/Card.tsx': 'export function Card() {\n return Widget({});\n}\n',
|
|
'src/ui/Banner.tsx': 'export function Banner() {\n return Field({});\n}\n',
|
|
'src/ui-kit/Widget.tsx': reactWidget,
|
|
'src/ui/Widget.tsx': reactWidget,
|
|
'src/ui/forms/Field.tsx': 'export function Field(props: {}) { return null; }\n',
|
|
'lib/Field.tsx': 'export function Field(props: {}) { return null; }\n',
|
|
});
|
|
expect(frameworkPicks(cg, 'Widget')).toEqual(['src/ui/Widget.tsx']);
|
|
expect(frameworkPicks(cg, 'Field')).toEqual(['src/ui/forms/Field.tsx']);
|
|
});
|
|
|
|
it('React: a file at the project root does not take whichever definition comes first', async () => {
|
|
const cg = await index({
|
|
'package.json': REACT,
|
|
'App.tsx': 'export function App() {\n return Panel({});\n}\n',
|
|
'src/a/Panel.tsx': 'export function Panel(props: {}) { return null; }\n',
|
|
'src/b/Panel.tsx': 'export function Panel(props: {}) { return null; }\n',
|
|
});
|
|
expect(frameworkPicks(cg, 'Panel')).toEqual([]);
|
|
});
|
|
|
|
it('Svelte: a sibling folder that only starts with the same name is not the folder', async () => {
|
|
const cg = await index({
|
|
'package.json': SVELTE,
|
|
'src/ui/mount.ts': 'export function mount() {\n return Widget({});\n}\n',
|
|
'src/ui-kit/Widget.svelte': '<span>kit</span>\n',
|
|
'lib/Widget.svelte': '<span>lib</span>\n',
|
|
});
|
|
expect(frameworkPicks(cg, 'Widget')).toEqual([]);
|
|
});
|
|
|
|
it('Astro: a sibling folder that only starts with the same name is not the folder', async () => {
|
|
const cg = await index({
|
|
'package.json': ASTRO,
|
|
'src/ui/Card.astro': '---\n---\n<div>\n <Widget />\n</div>\n',
|
|
'src/ui-kit/Widget.astro': '---\n---\n<span>kit</span>\n',
|
|
'lib/Widget.astro': '---\n---\n<span>lib</span>\n',
|
|
});
|
|
expect(frameworkPicks(cg, 'Widget')).toEqual([]);
|
|
});
|
|
|
|
it('Vue: a sibling folder that only starts with the same name is not the folder', async () => {
|
|
const cg = await index({
|
|
'package.json': VUE,
|
|
'src/ui/mount.ts': 'export function mount() {\n return Widget({});\n}\n',
|
|
'src/ui-kit/Widget.vue': '<template><span>kit</span></template>\n',
|
|
'lib/Widget.vue': '<template><span>lib</span></template>\n',
|
|
});
|
|
expect(frameworkPicks(cg, 'Widget')).toEqual([]);
|
|
});
|
|
});
|