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:
Colby Mchenry
2026-10-01 01:25:35 +00:00
committed by GitHub
parent 83dac15cff
commit 3e6252f38b
3 changed files with 140 additions and 0 deletions
+1
View File
@@ -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');
});
});
+45
View File
@@ -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;