mirror of
https://github.com/colbymchenry/codegraph.git
synced 2026-10-17 05:07:37 +08:00
fix(svelte,vue): a component's instance script declarations are its own (#2227)
isVisibleAcrossFiles rejects a declaration in a .svelte instance <script> or a Vue <script setup> from another file; a Svelte <script module>, a plain Vue <script>, and a type a <script setup> exports stay reachable. Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
@@ -14,6 +14,7 @@ and adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
|
||||
|
||||
### Fixes
|
||||
|
||||
- What a Svelte component's instance `<script>` or a Vue component's `<script setup>` declares is now private to that component. Only a Svelte `<script module>`, a plain Vue `<script>`, or a type a `<script setup>` exports can be reached from another file. Before, shadcn-svelte's `<Item.Root>` linked to a `type Item` that one example component declares for itself, and halo's `isActive` imported from its editor core linked to a tab component's local value.
|
||||
- In JavaScript and TypeScript, a name the calling function binds itself, as a parameter or a plain `var`/`let`/`const` above the reference, now means that local instead of a same-named function elsewhere in the file. Before, every lodash helper's `object` parameter linked to a `function object() {}` declared inside `runInContext`, and zod's `new Class(…)` linked to an unrelated `Class` when `Class` was the function's parameter. Destructured hook results like `const { t } = useI18n()` still link to the function they come from.
|
||||
- Framework name conventions for Django, FastAPI, Flask, ASP.NET, Gin, SwiftUI and Vapor now only pick a class the reference can see. Examples of these conventions are "a `…View` is a view", "a `…Service` is a service" and "a capitalized name is a model". The pick starts from the reference's own file and then its package, and never takes a class declared inside some function or nested in another file's class. Before, Django REST framework's tests linked each file's own `MockView` and `Serializer` to another file's, and netbox's tests linked `TestForm()` to a form declared inside a different test function.
|
||||
- In Rust projects, a struct, handler or service name no longer resolves to an item declared inside some function body, and a file's own item comes before any other crate's. Before, axum's examples' `Uri` linked to a `struct Uri` declared inside a routing test, tokio's runtime `Handle` linked to `tokio-test`'s, and ripgrep's `Glob { … }` in globset linked to the CLI flags' `Glob`.
|
||||
|
||||
@@ -0,0 +1,94 @@
|
||||
/**
|
||||
* What a Svelte component's instance `<script>` or a Vue SFC's `<script setup>`
|
||||
* declares is the component's own — no other file reaches it by name. Only a
|
||||
* Svelte `<script module>`, a Vue `<script>` that is not `setup`, or a type a
|
||||
* `<script setup>` exports can be imported. shadcn-svelte's `<Item.Root>` (a
|
||||
* namespace import) went to a `type Item` one example component declares,
|
||||
* and halo's `isActive` from `@/tiptap/core` to a tab's local `computed`.
|
||||
*/
|
||||
import { describe, it, expect, afterAll, beforeAll } from 'vitest';
|
||||
import * as fs from 'fs';
|
||||
import * as os from 'os';
|
||||
import * as path from 'path';
|
||||
import { CodeGraph } from '../src';
|
||||
|
||||
let root = '';
|
||||
let cg: CodeGraph;
|
||||
|
||||
beforeAll(async () => {
|
||||
root = fs.mkdtempSync(path.join(os.tmpdir(), 'cg-sfc-private-'));
|
||||
const files: Record<string, string> = {
|
||||
'package.json': JSON.stringify({ name: 'docs', private: true }),
|
||||
'src/lib/examples/attachment-group.svelte': `<script lang="ts">
|
||||
type Item = {
|
||||
name: string;
|
||||
};
|
||||
const items: Item[] = [];
|
||||
</script>
|
||||
|
||||
<div>{items.length}</div>
|
||||
`,
|
||||
'src/lib/examples/item-demo.svelte': `<script lang="ts">
|
||||
import * as Item from "$lib/ui/item/index.js";
|
||||
let label: Item = null;
|
||||
</script>
|
||||
|
||||
<Item.Root>{label}</Item.Root>
|
||||
`,
|
||||
'src/components/TabItem.vue': `<script lang="ts" setup>
|
||||
import { computed } from "vue";
|
||||
const isActive = computed(() => true);
|
||||
</script>
|
||||
|
||||
<template><div v-if="isActive" /></template>
|
||||
`,
|
||||
'src/extensions/code-block.ts': `import { isActive } from "@/tiptap/core";
|
||||
|
||||
export function active(state: unknown) {
|
||||
return isActive(state);
|
||||
}
|
||||
`,
|
||||
'src/components/CrudTable.vue': `<script setup lang="ts">
|
||||
export interface TableConfig {
|
||||
hideColumns: boolean;
|
||||
}
|
||||
defineProps<{ config: TableConfig }>();
|
||||
</script>
|
||||
|
||||
<template><table /></template>
|
||||
`,
|
||||
'src/pages/GroupDataPage.vue': `<script setup lang="ts">
|
||||
import type { TableConfig } from "~/components/CrudTable.vue";
|
||||
const config: TableConfig = { hideColumns: false };
|
||||
</script>
|
||||
|
||||
<template><div>{{ config }}</div></template>
|
||||
`,
|
||||
};
|
||||
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);
|
||||
}
|
||||
cg = await CodeGraph.init(root, { index: true });
|
||||
});
|
||||
|
||||
afterAll(() => {
|
||||
cg?.close();
|
||||
if (root) fs.rmSync(root, { recursive: true, force: true });
|
||||
});
|
||||
|
||||
const targetsFrom = (file: string) => {
|
||||
const ids = cg.getNodesInFile(file).map((n) => n.id);
|
||||
return cg.getOutgoingEdgesFrom(ids).filter((e) => e.kind !== 'contains').map((e) => cg.getNode(e.target)!).map((t) => `${t.filePath}:${t.name}`);
|
||||
};
|
||||
|
||||
describe('single-file component declarations', () => {
|
||||
it('are private to the component', () => {
|
||||
expect(targetsFrom('src/lib/examples/item-demo.svelte')).not.toContain('src/lib/examples/attachment-group.svelte:Item');
|
||||
expect(targetsFrom('src/extensions/code-block.ts')).not.toContain('src/components/TabItem.vue:isActive');
|
||||
});
|
||||
|
||||
it('except a type a Vue <script setup> exports', () => {
|
||||
expect(targetsFrom('src/pages/GroupDataPage.vue')).toContain('src/components/CrudTable.vue:TableConfig');
|
||||
});
|
||||
});
|
||||
@@ -1300,6 +1300,47 @@ function rustModuleDir(filePath: string): string {
|
||||
return path.posix.join(dir, base.replace(/\.rs$/, ''));
|
||||
}
|
||||
|
||||
const SFC_SCRIPT_RANGES = new WeakMap<ResolutionContext, Map<string, Array<{ start: number; end: number; exported: boolean }>>>();
|
||||
|
||||
/**
|
||||
* Whether a declaration in a `.svelte` / `.vue` file is the component's own:
|
||||
* anything but the component itself, unless it sits in a block that can export
|
||||
* — Svelte's `<script module>` (`context="module"`), or a Vue `<script>` that
|
||||
* is not `setup` — or is a type a Vue `<script setup>` exports.
|
||||
*/
|
||||
function isSfcPrivate(n: Node, context: ResolutionContext): boolean {
|
||||
const svelte = n.filePath.endsWith('.svelte');
|
||||
if ((!svelte && !n.filePath.endsWith('.vue')) || n.kind === 'component' || n.kind === 'file') return false;
|
||||
let memo = SFC_SCRIPT_RANGES.get(context);
|
||||
if (!memo) SFC_SCRIPT_RANGES.set(context, (memo = new Map()));
|
||||
let ranges = memo.get(n.filePath);
|
||||
if (!ranges) {
|
||||
ranges = [];
|
||||
const lines = context.getFileLines?.(n.filePath) ?? context.readFile(n.filePath)?.split(/\r?\n/) ?? [];
|
||||
let open: { start: number; exported: boolean } | null = null;
|
||||
lines.forEach((text, i) => {
|
||||
const tag = /<script\b([^>]*)>/i.exec(text);
|
||||
if (tag && !open) {
|
||||
const attrs = tag[1] ?? '';
|
||||
open = { start: i + 1, exported: svelte ? /\bmodule\b|context\s*=\s*["']module["']/.test(attrs) : !/\bsetup\b/.test(attrs) };
|
||||
}
|
||||
if (open && /<\/script\s*>/i.test(text)) {
|
||||
ranges!.push({ ...open, end: i + 1 });
|
||||
open = null;
|
||||
}
|
||||
});
|
||||
memo.set(n.filePath, ranges);
|
||||
}
|
||||
const block = ranges.find((r) => n.startLine >= r.start && n.startLine <= r.end);
|
||||
if (block?.exported) return false;
|
||||
// Vue hoists the types `<script setup>` exports: mealie imports CrudTable.vue's `TableConfig`.
|
||||
if (!svelte && block) {
|
||||
const line = context.getFileLines?.(n.filePath)?.[n.startLine - 1] ?? context.readFile(n.filePath)?.split(/\r?\n/)[n.startLine - 1] ?? '';
|
||||
if (/^\s*export\s+(?:declare\s+)?(?:interface|type|enum)\b/.test(line)) return false;
|
||||
}
|
||||
return true;
|
||||
}
|
||||
|
||||
/**
|
||||
* Whether `candidate` can be NAMED from a reference in `ref`'s file at all,
|
||||
* given what its language says about the definition's visibility. A
|
||||
@@ -1344,6 +1385,10 @@ export function isVisibleAcrossFiles(candidate: Node, ref: UnresolvedRef, contex
|
||||
// A test suite is not linked into the program: typeorm's `Record<K, V>` is
|
||||
// not a test entity `Record`, tokio's `Output` not a `runtime/tests` type.
|
||||
if (isTestSuitePath(candidate.filePath) && !isTestPath(ref.filePath)) return false;
|
||||
// A Svelte component's instance script, or a Vue SFC's `<script setup>`, is
|
||||
// private to the component: shadcn-svelte's 838 `<Item.Root>` (a namespace
|
||||
// import) went to a `type Item` one example component declares for itself.
|
||||
if (isSfcPrivate(candidate, context)) return false;
|
||||
if (candidate.language === 'csharp' && ref.language === 'csharp' && CSHARP_TYPE_KINDS.has(candidate.kind) &&
|
||||
/^[A-Za-z_]\w*$/.test(ref.referenceName) &&
|
||||
(!isCsharpTypeVisible(candidate, ref, context) || !isCsharpNestedTypeInScope(candidate, ref, context))) return false;
|
||||
|
||||
Reference in New Issue
Block a user