diff --git a/.changeset/seven-hornets-jump.md b/.changeset/seven-hornets-jump.md
new file mode 100644
index 000000000..9e0ca0c20
--- /dev/null
+++ b/.changeset/seven-hornets-jump.md
@@ -0,0 +1,6 @@
+---
+"@flint.fyi/vue-language": minor
+"@flint.fyi/vue": minor
+---
+
+Introduce Vue language.
diff --git a/cspell.json b/cspell.json
index fcf9e48e3..bf70eaf4d 100644
--- a/cspell.json
+++ b/cspell.json
@@ -43,6 +43,7 @@
"bday",
"BDFL",
"bradzacher",
+ "codegen",
"codemod",
"contentinfo",
"deoptimizations",
diff --git a/knip.json b/knip.json
index 52292d8c1..ef5122ff9 100644
--- a/knip.json
+++ b/knip.json
@@ -19,6 +19,9 @@
"sharp",
"zod"
]
+ },
+ "packages/vue": {
+ "ignoreDependencies": ["vue"]
}
}
}
diff --git a/packages/typescript-language/package.json b/packages/typescript-language/package.json
index e418b5fec..1e14231fe 100644
--- a/packages/typescript-language/package.json
+++ b/packages/typescript-language/package.json
@@ -13,7 +13,7 @@
"name": "JoshuaKGoldberg",
"email": "npm@joshuakgoldberg.com"
},
- "sideEffects": false,
+ "sideEffects": true,
"type": "module",
"exports": {
".": "./src/index.ts"
diff --git a/packages/typescript-language/src/convertTypeScriptDiagnosticToLanguageFileDiagnostic.ts b/packages/typescript-language/src/convertTypeScriptDiagnosticToLanguageFileDiagnostic.ts
index fb9d7c243..a29d66467 100644
--- a/packages/typescript-language/src/convertTypeScriptDiagnosticToLanguageFileDiagnostic.ts
+++ b/packages/typescript-language/src/convertTypeScriptDiagnosticToLanguageFileDiagnostic.ts
@@ -94,6 +94,7 @@ function displayFilename(name: string) {
if (name.startsWith("./")) {
return name.slice(2);
}
+ // TODO: use LinterHost.getCurrentDirectory()
return name.slice(process.cwd().length + 1);
}
diff --git a/packages/volar-language/package.json b/packages/volar-language/package.json
index 35b10e7e9..c4291d82a 100644
--- a/packages/volar-language/package.json
+++ b/packages/volar-language/package.json
@@ -13,7 +13,7 @@
"name": "JoshuaKGoldberg",
"email": "npm@joshuakgoldberg.com"
},
- "sideEffects": false,
+ "sideEffects": true,
"type": "module",
"exports": {
".": "./src/index.ts"
diff --git a/packages/vue-language/README.md b/packages/vue-language/README.md
new file mode 100644
index 000000000..dd9cc8227
--- /dev/null
+++ b/packages/vue-language/README.md
@@ -0,0 +1,9 @@
+
@flint.fyi/vue-language
+
+
+ [Experimental] Vue.js language for Flint.
+ ❤️🔥
+
+
+This is an internal package for Flint.
+Documentation will eventually be added.
diff --git a/packages/vue-language/package.json b/packages/vue-language/package.json
new file mode 100644
index 000000000..4c93d036c
--- /dev/null
+++ b/packages/vue-language/package.json
@@ -0,0 +1,52 @@
+{
+ "name": "@flint.fyi/vue-language",
+ "version": "0.0.1",
+ "description": "[Experimental] Vue.js language for Flint.",
+ "homepage": "https://flint.fyi",
+ "repository": {
+ "type": "git",
+ "url": "git+https://github.com/flint-fyi/flint.git",
+ "directory": "packages/vue-language"
+ },
+ "license": "MIT",
+ "author": {
+ "name": "JoshuaKGoldberg",
+ "email": "npm@joshuakgoldberg.com"
+ },
+ "sideEffects": true,
+ "type": "module",
+ "exports": {
+ ".": "./src/index.ts"
+ },
+ "files": [
+ "lib/",
+ "!lib/**/*.map"
+ ],
+ "scripts": {
+ "test": "vitest --project vue-language"
+ },
+ "dependencies": {
+ "@flint.fyi/ts-patch": "workspace:^",
+ "@flint.fyi/typescript-language": "workspace:^",
+ "@flint.fyi/utils": "workspace:^",
+ "@flint.fyi/volar-language": "workspace:^",
+ "@volar/language-core": "2.4.28",
+ "@vue/compiler-dom": "^3.5.0",
+ "@vue/language-core": "3.2.1",
+ "typescript": "^5.9.0 || ^6.0.0"
+ },
+ "devDependencies": {
+ "@flint.fyi/core": "workspace:^",
+ "tsdown": "catalog:dev",
+ "vitest": "catalog:dev"
+ },
+ "engines": {
+ "node": ">=24.0.0"
+ },
+ "publishConfig": {
+ "access": "public",
+ "exports": {
+ ".": "./lib/index.js"
+ }
+ }
+}
diff --git a/packages/vue-language/src/extractTemplateDirectives.ts b/packages/vue-language/src/extractTemplateDirectives.ts
new file mode 100644
index 000000000..082d74f5d
--- /dev/null
+++ b/packages/vue-language/src/extractTemplateDirectives.ts
@@ -0,0 +1,56 @@
+import type { ExtractedDirective } from "@flint.fyi/typescript-language";
+import { nullThrows } from "@flint.fyi/utils";
+import {
+ NodeTypes,
+ type RootNode,
+ type TemplateChildNode,
+} from "@vue/compiler-dom";
+
+export function extractTemplateDirectives(ast: RootNode) {
+ const directives: ExtractedDirective[] = [];
+
+ function visitTemplate(elem: TemplateChildNode) {
+ if (elem.type === NodeTypes.ELEMENT) {
+ for (const child of elem.children) {
+ visitTemplate(child);
+ }
+ return;
+ }
+ if (elem.type !== NodeTypes.COMMENT) {
+ return;
+ }
+ const match = /\s*flint-(\S+)(?:\s+(.+))?/.exec(elem.content);
+ if (match == null) {
+ return;
+ }
+ const [, type, selection] = match;
+ directives.push({
+ range: {
+ begin: {
+ column: elem.loc.start.column - 1,
+ line: elem.loc.start.line - 1,
+ raw: elem.loc.start.offset,
+ },
+ end: {
+ column: elem.loc.end.column - 1,
+ line: elem.loc.end.line - 1,
+ raw: elem.loc.end.offset,
+ },
+ },
+ selection: nullThrows(
+ selection,
+ "Expected RegExp to provide second capturing group",
+ ),
+ type: nullThrows(
+ type,
+ "Expected RegExp to provide first capturing group",
+ ),
+ });
+ }
+
+ for (const child of ast.children) {
+ visitTemplate(child);
+ }
+
+ return directives;
+}
diff --git a/packages/vue-language/src/index.ts b/packages/vue-language/src/index.ts
new file mode 100644
index 000000000..f7ff6e353
--- /dev/null
+++ b/packages/vue-language/src/index.ts
@@ -0,0 +1 @@
+export { vueLanguage } from "./language.ts";
diff --git a/packages/vue-language/src/language.ts b/packages/vue-language/src/language.ts
new file mode 100644
index 000000000..4dbdcef3e
--- /dev/null
+++ b/packages/vue-language/src/language.ts
@@ -0,0 +1,128 @@
+import { setTSExtraSupportedExtensions } from "@flint.fyi/ts-patch";
+import { assert, nullThrows } from "@flint.fyi/utils";
+import { createVolarBasedLanguage } from "@flint.fyi/volar-language";
+import type { Mapper as VolarMapper } from "@volar/language-core";
+import { NodeTypes, type RootNode, parse as vueParse } from "@vue/compiler-dom";
+import {
+ createVueLanguagePlugin,
+ createParsedCommandLine as createVueParsedCommandLine,
+ createParsedCommandLineByJson as createVueParsedCommandLineByJson,
+ tsCodegen,
+ VueVirtualCode,
+} from "@vue/language-core";
+
+import { extractTemplateDirectives } from "./extractTemplateDirectives.ts";
+import { vueParsingErrorsToLanguageDiagnostics } from "./vueParsingErrorsToLanguageDiagnostics.ts";
+
+setTSExtraSupportedExtensions([".vue"]);
+
+export interface VueServices {
+ vue: {
+ codegen: VueCodegen;
+ map: VolarMapper;
+ sfc: RootNode;
+ virtualCode: VueVirtualCode;
+ };
+}
+
+type VueCodegen =
+ typeof tsCodegen extends WeakMap ? V : never;
+
+export const vueLanguage = createVolarBasedLanguage(
+ (ts, options) => {
+ const { configFilePath } = options.options;
+ const host = options.host
+ ? {
+ ...options.host,
+ useCaseSensitiveFileNames: options.host.useCaseSensitiveFileNames(),
+ }
+ : ts.sys;
+ const vueCompilerOptions = (
+ typeof configFilePath === "string"
+ ? createVueParsedCommandLine(
+ ts,
+ host,
+ configFilePath.replaceAll("\\", "/"),
+ )
+ : createVueParsedCommandLineByJson(
+ ts,
+ host,
+ host.getCurrentDirectory(),
+ {},
+ )
+ ).vueOptions;
+
+ return {
+ createFile({
+ data,
+ serviceScript,
+ sourceFile,
+ sourceScript,
+ volarLanguage,
+ }) {
+ const sourceText = sourceScript.snapshot.getText(
+ 0,
+ sourceScript.snapshot.getLength(),
+ );
+ const virtualCode = sourceScript.generated.root;
+ assert(
+ virtualCode instanceof VueVirtualCode,
+ "Expected sourceScript.generated.root to be VueServiceCode",
+ );
+
+ const codegen = nullThrows(
+ tsCodegen.get(virtualCode.sfc),
+ `tsCodegen for ${data.filePathAbsolute} is undefined`,
+ );
+
+ const map = volarLanguage.maps.get(serviceScript.code, sourceScript);
+
+ const sfcAst = vueParse(sourceText, {
+ comments: true,
+ expressionPlugins: ["typescript"],
+ onError: () => {
+ // We ignore errors because virtual code already provides them,
+ // and it also provides them with sourceText-based locations,
+ // so we don't have to remap them. Oh, and it also contains errors from
+ // other blocks rather than only as well.
+ // If we don't provide this callback, @vue/compiler-core will throw.
+ },
+ parseMode: "html",
+ });
+
+ return {
+ // TODO: extract directives from other blocks too
+ directives: extractTemplateDirectives(sfcAst),
+ extraContext: {
+ vue: {
+ codegen,
+ map,
+ sfc: sfcAst,
+ virtualCode,
+ },
+ },
+ firstStatementPosition:
+ sfcAst.children.find((c) => c.type !== NodeTypes.COMMENT)?.loc.start
+ .offset ?? sourceText.length,
+ getDiagnostics() {
+ return vueParsingErrorsToLanguageDiagnostics(
+ sourceFile.fileName.startsWith("./")
+ ? sourceFile.fileName.slice(2)
+ : // TODO: use LinterHost.getCurrentDirectory()
+ sourceFile.fileName.slice(process.cwd().length + 1),
+ virtualCode.vueSfc?.errors ?? [],
+ );
+ },
+ };
+ },
+ languagePlugins: [
+ createVueLanguagePlugin(
+ ts,
+ options.options,
+ vueCompilerOptions,
+ (id) => id,
+ ),
+ ],
+ };
+ },
+);
diff --git a/packages/vue-language/src/vueParsingErrorsToLanguageDiagnostics.ts b/packages/vue-language/src/vueParsingErrorsToLanguageDiagnostics.ts
new file mode 100644
index 000000000..5df5d538d
--- /dev/null
+++ b/packages/vue-language/src/vueParsingErrorsToLanguageDiagnostics.ts
@@ -0,0 +1,23 @@
+import type { LanguageDiagnostics } from "@flint.fyi/core";
+import type { CompilerError } from "@vue/compiler-dom";
+
+export function vueParsingErrorsToLanguageDiagnostics(
+ fileName: string,
+ errors: (CompilerError | SyntaxError)[],
+): LanguageDiagnostics {
+ return errors.map((error) => {
+ let code = "VUE";
+ let loc = "";
+ if ("code" in error) {
+ code += error.code.toString();
+ loc =
+ error.loc != null
+ ? `:${error.loc.start.line}:${error.loc.start.column}`
+ : "";
+ }
+ return {
+ code,
+ text: `${fileName}${loc} - ${code}: ${error.name} - ${error.message}`,
+ };
+ });
+}
diff --git a/packages/vue-language/tsconfig.json b/packages/vue-language/tsconfig.json
new file mode 100644
index 000000000..c37e7bdb5
--- /dev/null
+++ b/packages/vue-language/tsconfig.json
@@ -0,0 +1,8 @@
+{
+ "extends": "../../tsconfig.base.json",
+ "include": [],
+ "references": [
+ { "path": "./tsconfig.src.json" },
+ { "path": "./tsconfig.test.json" }
+ ]
+}
diff --git a/packages/vue-language/tsconfig.src.json b/packages/vue-language/tsconfig.src.json
new file mode 100644
index 000000000..d2881f67d
--- /dev/null
+++ b/packages/vue-language/tsconfig.src.json
@@ -0,0 +1,18 @@
+{
+ "compilerOptions": {
+ "tsBuildInfoFile": "node_modules/.cache/tsbuild/info.src.json",
+ "rootDir": "src/",
+ "outDir": "lib/",
+ "types": ["node"]
+ },
+ "extends": "../../tsconfig.base.json",
+ "include": ["src"],
+ "exclude": ["src/**/*.test.ts"],
+ "references": [
+ { "path": "../core" },
+ { "path": "../ts-patch" },
+ { "path": "../typescript-language" },
+ { "path": "../utils" },
+ { "path": "../volar-language" }
+ ]
+}
diff --git a/packages/vue-language/tsconfig.test.json b/packages/vue-language/tsconfig.test.json
new file mode 100644
index 000000000..bed5231ef
--- /dev/null
+++ b/packages/vue-language/tsconfig.test.json
@@ -0,0 +1,12 @@
+{
+ "compilerOptions": {
+ "tsBuildInfoFile": "node_modules/.cache/tsbuild/info.test.json",
+ "rootDir": "src/",
+ "outDir": "node_modules/.cache/tsbuild/test",
+ "types": ["node"],
+ "erasableSyntaxOnly": false
+ },
+ "extends": "../../tsconfig.base.json",
+ "include": ["src/**/*.test.ts"],
+ "references": [{ "path": "./tsconfig.src.json" }]
+}
diff --git a/packages/vue-language/tsdown.config.ts b/packages/vue-language/tsdown.config.ts
new file mode 100644
index 000000000..4edb2fb87
--- /dev/null
+++ b/packages/vue-language/tsdown.config.ts
@@ -0,0 +1,19 @@
+import { defineConfig } from "tsdown";
+
+export default defineConfig({
+ attw: {
+ enabled: "ci-only",
+ profile: "esm-only",
+ },
+ clean: ["./node_modules/.cache/tsbuild/"],
+ dts: { build: true, incremental: true },
+ entry: ["src/index.ts"],
+ exports: {
+ devExports: true,
+ packageJson: false,
+ },
+ failOnWarn: true,
+ fixedExtension: false,
+ outDir: "lib",
+ unbundle: true,
+});
diff --git a/packages/vue/README.md b/packages/vue/README.md
new file mode 100644
index 000000000..f65074904
--- /dev/null
+++ b/packages/vue/README.md
@@ -0,0 +1,9 @@
+@flint.fyi/vue
+
+
+ [Experimental] Vue.js language plugin for Flint.
+ ❤️🔥
+
+
+This is an internal package for Flint.
+Documentation will eventually be added.
diff --git a/packages/vue/package.json b/packages/vue/package.json
new file mode 100644
index 000000000..aaec94b50
--- /dev/null
+++ b/packages/vue/package.json
@@ -0,0 +1,53 @@
+{
+ "name": "@flint.fyi/vue",
+ "version": "0.0.1",
+ "description": "[Experimental] Vue.js language plugin for Flint.",
+ "homepage": "https://flint.fyi",
+ "repository": {
+ "type": "git",
+ "url": "git+https://github.com/flint-fyi/flint.git",
+ "directory": "packages/vue"
+ },
+ "license": "MIT",
+ "author": {
+ "name": "JoshuaKGoldberg",
+ "email": "npm@joshuakgoldberg.com"
+ },
+ "sideEffects": false,
+ "type": "module",
+ "exports": {
+ ".": "./src/index.ts"
+ },
+ "files": [
+ "lib/",
+ "!lib/**/*.map"
+ ],
+ "scripts": {
+ "test": "vitest --project vue"
+ },
+ "dependencies": {
+ "@flint.fyi/core": "workspace:^",
+ "@flint.fyi/ts": "workspace:^",
+ "@flint.fyi/typescript-language": "workspace:^",
+ "@flint.fyi/utils": "workspace:^",
+ "@flint.fyi/volar-language": "workspace:^",
+ "@flint.fyi/vue-language": "workspace:^",
+ "@vue/compiler-dom": "^3.5.0",
+ "typescript": "^5.9.0 || ^6.0.0"
+ },
+ "devDependencies": {
+ "@flint.fyi/rule-tester": "workspace:^",
+ "tsdown": "catalog:dev",
+ "vitest": "catalog:dev",
+ "vue": "~3.5.27"
+ },
+ "engines": {
+ "node": ">=24.0.0"
+ },
+ "publishConfig": {
+ "access": "public",
+ "exports": {
+ ".": "./lib/index.js"
+ }
+ }
+}
diff --git a/packages/vue/src/index.ts b/packages/vue/src/index.ts
new file mode 100644
index 000000000..971ee2103
--- /dev/null
+++ b/packages/vue/src/index.ts
@@ -0,0 +1 @@
+export { vue } from "./plugin.ts";
diff --git a/packages/vue/src/plugin.ts b/packages/vue/src/plugin.ts
new file mode 100644
index 000000000..cfaf52320
--- /dev/null
+++ b/packages/vue/src/plugin.ts
@@ -0,0 +1,12 @@
+import { createPlugin } from "@flint.fyi/core";
+import { ts } from "@flint.fyi/ts";
+
+import vForKeys from "./rules/vForKeys.ts";
+
+export const vue = createPlugin({
+ files: {
+ all: [ts.files.all, "**/*.vue"],
+ },
+ name: "Vue.js",
+ rules: [vForKeys],
+});
diff --git a/packages/vue/src/rules/ruleCreator.ts b/packages/vue/src/rules/ruleCreator.ts
new file mode 100644
index 000000000..91d68c73b
--- /dev/null
+++ b/packages/vue/src/rules/ruleCreator.ts
@@ -0,0 +1,7 @@
+import { RuleCreator } from "@flint.fyi/core";
+
+export const ruleCreator = new RuleCreator({
+ docs: (ruleId) => `https://flint.fyi/rules/vue/${ruleId.toLowerCase()}`,
+ pluginId: "vue",
+ presets: ["logical", "logicalStrict", "stylistic", "stylisticStrict"],
+});
diff --git a/packages/vue/src/rules/ruleTester.ts b/packages/vue/src/rules/ruleTester.ts
new file mode 100644
index 000000000..e5aa9bd61
--- /dev/null
+++ b/packages/vue/src/rules/ruleTester.ts
@@ -0,0 +1,13 @@
+import { RuleTester } from "@flint.fyi/rule-tester";
+import { createRuleTesterTSConfig } from "@flint.fyi/typescript-language";
+import { describe, it } from "vitest";
+
+export const ruleTester = new RuleTester({
+ defaults: {
+ fileName: "file.vue",
+ files: createRuleTesterTSConfig(),
+ },
+ describe,
+ diskBackedFSRoot: import.meta.dirname,
+ it,
+});
diff --git a/packages/vue/src/rules/tsAnyReturns.test.ts b/packages/vue/src/rules/tsAnyReturns.test.ts
new file mode 100644
index 000000000..41f32b33d
--- /dev/null
+++ b/packages/vue/src/rules/tsAnyReturns.test.ts
@@ -0,0 +1,120 @@
+import "@flint.fyi/vue-language";
+
+import rule from "../../../ts/src/rules/anyReturns.ts";
+import { ruleTester } from "./ruleTester.ts";
+
+const myComponentFixture = {
+ "MyComponent.vue": `
+
+
+Hello!
+ `,
+};
+
+ruleTester.describe(rule, {
+ invalid: [
+ {
+ code: `
+
+
+`,
+ snapshot: `
+
+
+`,
+ },
+ {
+ code: `
+
+
+
+
+
+
+`,
+ files: myComponentFixture,
+ snapshot: `
+
+
+
+
+ ~~~
+ Unsafe return of a value of type \`any\`.
+
+
+`,
+ },
+ {
+ code: `
+
+
+
+
+
+
+`,
+ files: myComponentFixture,
+ snapshot: `
+
+
+
+
+ ~~~~~~~~
+
+
+`,
+ },
+ ],
+ valid: [
+ {
+ code: `
+
+ `,
+ files: myComponentFixture,
+ },
+ ],
+});
diff --git a/packages/vue/src/rules/vForKeys.test.ts b/packages/vue/src/rules/vForKeys.test.ts
new file mode 100644
index 000000000..def0adbb1
--- /dev/null
+++ b/packages/vue/src/rules/vForKeys.test.ts
@@ -0,0 +1,496 @@
+import { ruleTester } from "./ruleTester.ts";
+import rule from "./vForKeys.ts";
+
+ruleTester.describe(rule, {
+ invalid: [
+ {
+ code: `
+
+
+
+
+`,
+ snapshot: `
+
+
+ ~~~~~
+ Elements using v-for must include a unique :key to ensure correct reactivity and DOM stability.
+
+
+`,
+ },
+ {
+ code: `
+
+
+
+
+`,
+ snapshot: `
+
+
+ ~~~~~
+ Elements using v-for must include a unique :key to ensure correct reactivity and DOM stability.
+
+
+`,
+ },
+ {
+ code: `
+
+
+
+
+`,
+ snapshot: `
+
+
+ ~~~~~
+ Elements using v-for must include a unique :key to ensure correct reactivity and DOM stability.
+
+
+
+
+`,
+ },
+ {
+ code: `
+
+
+
+
+
+
+`,
+ snapshot: `
+
+
+ ~~~~~
+ Elements using v-for must include a unique :key to ensure correct reactivity and DOM stability.
+
+
+
+
+`,
+ },
+ {
+ code: `
+
+
+
![]()
+
+
+
+`,
+ snapshot: `
+
+
+
![]()
+ ~~~~~
+ Elements using v-for must include a unique :key to ensure correct reactivity and DOM stability.
+
+
+
+`,
+ },
+ {
+ code: `
+
+
+
![]()
+
+
+
+`,
+ snapshot: `
+
+
+
![]()
+ ~~~~~
+ Elements using v-for must include a unique :key to ensure correct reactivity and DOM stability.
+
+
+
+`,
+ },
+ {
+ code: `
+
+
+
+
![]()
+
+
+
+`,
+ snapshot: `
+
+
+
+
![]()
+ ~~~~~
+ Elements using v-for must include a unique :key to ensure correct reactivity and DOM stability.
+
+
+
+`,
+ },
+ {
+ code: `
+
+
+
![]()
+
+
+
+`,
+ snapshot: `
+
+
+
![]()
+ ~~~~~
+ Elements using v-for must include a unique :key to ensure correct reactivity and DOM stability.
+
+
+
+`,
+ },
+ {
+ code: `
+
+
+
+
+
+ },
+
+`,
+ snapshot: `
+
+
+
+
+
+ },
+
+`,
+ },
+ {
+ code: `
+
+
+
+ },
+
+`,
+ snapshot: `
+
+
+
+ },
+
+`,
+ },
+ {
+ code: `
+
+
+
+
+`,
+ snapshot: `
+
+
+
+
+`,
+ },
+ {
+ code: `
+
+
+
+
+`,
+ snapshot: `
+
+
+
+
+`,
+ },
+ {
+ code: `
+
+
+
+
+`,
+ snapshot: `
+
+
+
+
+`,
+ },
+ {
+ code: `
+
+
+
+
+`,
+ snapshot: `
+
+
+
+
+`,
+ },
+ {
+ code: `
+
+
+
+
+
+
+`,
+ snapshot: `
+
+
+
+
+
+
+`,
+ },
+ {
+ code: `
+
+
+
+
+
+
+`,
+ snapshot: `
+
+
+
+
+
+
+`,
+ },
+ ],
+ valid: [
+ `
+
+
+
+
+ `,
+ `
+
+
+
+
+ `,
+ `
+
+
+
+ `,
+ `
+
+
+
+
+
+ `,
+ `
+
+
+
+ `,
+ `
+
+
+
+ `,
+ `
+
+
+
+ `,
+ `
+
+
+
+ `,
+ `
+
+
+
+ `,
+ `
+
+
+
+ `,
+ `
+
+
+
+ `,
+ `
+
+
+
+ `,
+ `
+
+
+
+
+
+ `,
+ `
+
+
+
+
+
+ `,
+ ],
+});
diff --git a/packages/vue/src/rules/vForKeys.ts b/packages/vue/src/rules/vForKeys.ts
new file mode 100644
index 000000000..8a2860fbb
--- /dev/null
+++ b/packages/vue/src/rules/vForKeys.ts
@@ -0,0 +1,272 @@
+import type { CharacterReportRange } from "@flint.fyi/core";
+import { nullThrows } from "@flint.fyi/utils";
+import { reportSourceCode } from "@flint.fyi/volar-language";
+import { vueLanguage } from "@flint.fyi/vue-language";
+import * as vue from "@vue/compiler-dom";
+import ts from "typescript";
+
+import { ruleCreator } from "./ruleCreator.ts";
+
+export default ruleCreator.createRule(vueLanguage, {
+ about: {
+ description: "Reports v-for directives without a valid key binding.",
+ id: "vForKeys",
+ preset: "logical",
+ },
+ messages: {
+ // TODO: support import("@flint.fyi/volar-language").reportSourceCode in flint/unusedMessageIds
+ // flint-disable-next-line flint/unusedMessageIds
+ invalidKey: {
+ primary:
+ "The :key on this v-for element does not reference the iteration variable.",
+ secondary: [
+ "Keys must uniquely identify each item in the v-for loop to maintain object constancy.",
+ "Using values unrelated to the loop can still lead to rendering issues during reordering.",
+ ],
+ suggestions: [
+ "Bind the :key to something derived from the v-for item, like item.id or the index if no unique identifier exists.",
+ ],
+ },
+ // flint-disable-next-line flint/unusedMessageIds
+ missingKey: {
+ primary:
+ "Elements using v-for must include a unique :key to ensure correct reactivity and DOM stability.",
+ secondary: [
+ "A missing :key can cause unpredictable updates during rendering optimizations.",
+ "Without a key, Vue may reuse or reorder elements incorrectly, which breaks expected behavior in transitions and stateful components.",
+ ],
+ suggestions: [
+ "Always provide a unique :key based on the v-for item, such as an id.",
+ ],
+ },
+ // flint-disable-next-line flint/unusedMessageIds
+ staticKey: {
+ primary:
+ "Static key values prevent Vue from tracking changes in v-for lists.",
+ secondary: [
+ 'Using key="literal" means every item in the v-for shares the same key, which prevents Vue from tracking list updates correctly.',
+ "This blocks proper reactivity, leading to stale DOM content and skipped updates.",
+ ],
+ suggestions: [
+ "Replace the static key with a dynamic and unique :key derived from the v-for item, such as item.id.",
+ ],
+ },
+ },
+ setup(context) {
+ return {
+ visitors: {
+ SourceFile(node, services) {
+ if (services.vue == null) {
+ return;
+ }
+ const { map, sfc } = services.vue;
+
+ const toGeneratedLocation = (sourceLocation: number) => {
+ for (const [loc] of map.toGeneratedLocation(sourceLocation)) {
+ return loc;
+ }
+ return undefined;
+ };
+
+ const toGeneratedLocationOrThrow = (sourceLocation: number) => {
+ return nullThrows(
+ toGeneratedLocation(sourceLocation),
+ "Unable to map source location to generated location",
+ );
+ };
+
+ const templateBlock = sfc.children.find(
+ (c): c is vue.ElementNode =>
+ c.type === vue.NodeTypes.ELEMENT && c.tag === "template",
+ );
+ if (templateBlock == null) {
+ return {};
+ }
+
+ const propValueRange = (propValue: vue.TextNode) => {
+ const strip = propValue.loc.source === propValue.content ? 0 : 1;
+ return {
+ begin: propValue.loc.start.offset + strip,
+ end: propValue.loc.end.offset - strip,
+ };
+ };
+
+ const checkFor = (
+ forDirective: vue.DirectiveNode,
+ forParseResult: vue.ForParseResult,
+ keyProp: null | vue.AttributeNode | vue.DirectiveNode,
+ ) => {
+ if (keyProp == null) {
+ reportSourceCode(context, {
+ message: "missingKey",
+ range: {
+ begin: forDirective.loc.start.offset,
+ end: forDirective.loc.start.offset + "v-for".length,
+ },
+ });
+ return;
+ }
+ if (keyProp.type === vue.NodeTypes.ATTRIBUTE) {
+ if (keyProp.value == null) {
+ return; // TS error
+ }
+ reportSourceCode(context, {
+ message: "staticKey",
+ range: propValueRange(keyProp.value),
+ });
+ return;
+ }
+
+ let reportRange: CharacterReportRange;
+ let valueRange: CharacterReportRange;
+
+ if (keyProp.exp == null) {
+ // :key
+ reportRange = {
+ begin: keyProp.loc.start.offset,
+ end: keyProp.loc.end.offset,
+ };
+ const generatedLocations = Array.from(
+ map.toGeneratedLocation(
+ nullThrows(keyProp.arg, "Expected keyProp.arg to be non-null")
+ .loc.start.offset,
+ ),
+ ).filter(
+ ([, m]) =>
+ nullThrows(
+ m.lengths[0],
+ "Expected mapping to have at least one range",
+ ) > 0,
+ );
+
+ // |key|: |key|
+ // ^^^^^
+ // |key|: __VLS_ctx.|key|
+ // ^^^^^
+ const valueMapping = nullThrows(
+ generatedLocations[1],
+ "Expected :key two have two mappings",
+ )[1];
+
+ const generatedBegin = nullThrows(
+ valueMapping.generatedOffsets[0],
+ "Expected mapping to have at least one range",
+ );
+ valueRange = {
+ begin: generatedBegin,
+ end:
+ generatedBegin +
+ nullThrows(
+ valueMapping.lengths[0],
+ "Expected mapping to have at least one range",
+ ),
+ };
+ } else {
+ reportRange = {
+ begin: keyProp.exp.loc.start.offset,
+ end: keyProp.exp.loc.end.offset,
+ };
+
+ valueRange = {
+ begin: toGeneratedLocationOrThrow(keyProp.exp.loc.start.offset),
+ end: toGeneratedLocationOrThrow(keyProp.exp.loc.end.offset),
+ };
+ }
+
+ const loopVariableRanges = [
+ forParseResult.value,
+ forParseResult.key,
+ forParseResult.index,
+ ]
+ .filter((v) => v != null)
+ .map((v) => ({
+ begin: toGeneratedLocationOrThrow(v.loc.start.offset),
+ end: toGeneratedLocationOrThrow(v.loc.end.offset),
+ }));
+
+ // TODO(perf): use ScopeManager instead
+ // https://github.com/flint-fyi/flint/issues/400
+ const find = (current: ts.Node) => {
+ const currentBegin = current.getStart(node);
+ const currentEnd = current.getEnd();
+ if (
+ currentBegin > valueRange.end ||
+ currentEnd <= valueRange.begin
+ ) {
+ return false;
+ }
+ if (
+ currentBegin >= valueRange.begin &&
+ currentEnd <= valueRange.end &&
+ ts.isIdentifier(current)
+ ) {
+ const symbol =
+ services.typeChecker.getSymbolAtLocation(current);
+ if (symbol?.valueDeclaration == null) {
+ return false;
+ }
+ const declStart = symbol.valueDeclaration.getStart(node);
+ const declEnd = symbol.valueDeclaration.getEnd();
+
+ return loopVariableRanges.some(
+ ({ begin, end }) => declStart >= begin && declEnd <= end,
+ );
+ }
+
+ return current.getChildren(node).some(find);
+ };
+
+ if (!find(node)) {
+ reportSourceCode(context, {
+ message: "invalidKey",
+ range: reportRange,
+ });
+ }
+ };
+
+ // TODO: add vue: listeners to the language
+ function visitTag(node: vue.TemplateChildNode) {
+ if (node.type === vue.NodeTypes.ELEMENT) {
+ let forDirective: null | vue.DirectiveNode = null;
+ let forParseResult: null | vue.ForParseResult = null;
+ let keyProp: null | vue.AttributeNode | vue.DirectiveNode = null;
+
+ for (const prop of node.props) {
+ if (
+ prop.type === vue.NodeTypes.DIRECTIVE &&
+ prop.name === "for" &&
+ prop.forParseResult != null
+ ) {
+ forDirective = prop;
+ forParseResult = prop.forParseResult;
+ } else if (
+ prop.type === vue.NodeTypes.DIRECTIVE &&
+ prop.name === "bind" &&
+ vue.isStaticArgOf(prop.arg, "key")
+ ) {
+ keyProp = prop;
+ } else if (
+ prop.type === vue.NodeTypes.ATTRIBUTE &&
+ prop.name === "key"
+ ) {
+ keyProp = prop;
+ }
+ }
+
+ if (forDirective != null && forParseResult != null) {
+ checkFor(forDirective, forParseResult, keyProp);
+ }
+
+ for (const child of node.children) {
+ visitTag(child);
+ }
+ }
+ }
+ for (const child of templateBlock.children) {
+ visitTag(child);
+ }
+ },
+ },
+ };
+ },
+});
diff --git a/packages/vue/tsconfig.json b/packages/vue/tsconfig.json
new file mode 100644
index 000000000..c37e7bdb5
--- /dev/null
+++ b/packages/vue/tsconfig.json
@@ -0,0 +1,8 @@
+{
+ "extends": "../../tsconfig.base.json",
+ "include": [],
+ "references": [
+ { "path": "./tsconfig.src.json" },
+ { "path": "./tsconfig.test.json" }
+ ]
+}
diff --git a/packages/vue/tsconfig.src.json b/packages/vue/tsconfig.src.json
new file mode 100644
index 000000000..1fcac6d72
--- /dev/null
+++ b/packages/vue/tsconfig.src.json
@@ -0,0 +1,19 @@
+{
+ "compilerOptions": {
+ "tsBuildInfoFile": "node_modules/.cache/tsbuild/info.src.json",
+ "rootDir": "src/",
+ "outDir": "lib/",
+ "types": ["node"]
+ },
+ "extends": "../../tsconfig.base.json",
+ "include": ["src"],
+ "exclude": ["src/**/*.test.ts", "src/rules/ruleTester.ts"],
+ "references": [
+ { "path": "../core" },
+ { "path": "../ts" },
+ { "path": "../typescript-language" },
+ { "path": "../utils" },
+ { "path": "../volar-language" },
+ { "path": "../vue-language" }
+ ]
+}
diff --git a/packages/vue/tsconfig.test.json b/packages/vue/tsconfig.test.json
new file mode 100644
index 000000000..5025f87b5
--- /dev/null
+++ b/packages/vue/tsconfig.test.json
@@ -0,0 +1,16 @@
+{
+ "compilerOptions": {
+ "tsBuildInfoFile": "node_modules/.cache/tsbuild/info.test.json",
+ "rootDir": "src/",
+ "outDir": "node_modules/.cache/tsbuild/test",
+ "types": ["node"],
+ "erasableSyntaxOnly": false
+ },
+ "extends": "../../tsconfig.base.json",
+ "include": ["src/**/*.test.ts", "src/rules/ruleTester.ts"],
+ "references": [
+ { "path": "../rule-tester" },
+ { "path": "../ts" },
+ { "path": "./tsconfig.src.json" }
+ ]
+}
diff --git a/packages/vue/tsdown.config.ts b/packages/vue/tsdown.config.ts
new file mode 100644
index 000000000..4edb2fb87
--- /dev/null
+++ b/packages/vue/tsdown.config.ts
@@ -0,0 +1,19 @@
+import { defineConfig } from "tsdown";
+
+export default defineConfig({
+ attw: {
+ enabled: "ci-only",
+ profile: "esm-only",
+ },
+ clean: ["./node_modules/.cache/tsbuild/"],
+ dts: { build: true, incremental: true },
+ entry: ["src/index.ts"],
+ exports: {
+ devExports: true,
+ packageJson: false,
+ },
+ failOnWarn: true,
+ fixedExtension: false,
+ outDir: "lib",
+ unbundle: true,
+});
diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml
index 1544d1189..70c90a07d 100644
--- a/pnpm-lock.yaml
+++ b/pnpm-lock.yaml
@@ -1060,6 +1060,83 @@ importers:
specifier: catalog:dev
version: 4.1.0(@types/node@24.12.0)(esbuild@0.27.4)(jiti@2.6.1)(yaml@2.8.2)
+ packages/vue:
+ dependencies:
+ '@flint.fyi/core':
+ specifier: workspace:^
+ version: link:../core
+ '@flint.fyi/ts':
+ specifier: workspace:^
+ version: link:../ts
+ '@flint.fyi/typescript-language':
+ specifier: workspace:^
+ version: link:../typescript-language
+ '@flint.fyi/utils':
+ specifier: workspace:^
+ version: link:../utils
+ '@flint.fyi/volar-language':
+ specifier: workspace:^
+ version: link:../volar-language
+ '@flint.fyi/vue-language':
+ specifier: workspace:^
+ version: link:../vue-language
+ '@vue/compiler-dom':
+ specifier: ^3.5.0
+ version: 3.5.30
+ typescript:
+ specifier: ^5.9.0 || ^6.0.0
+ version: 5.9.3
+ devDependencies:
+ '@flint.fyi/rule-tester':
+ specifier: workspace:^
+ version: link:../rule-tester
+ tsdown:
+ specifier: catalog:dev
+ version: 0.21.0(@arethetypeswrong/core@0.18.2)(oxc-resolver@11.19.1)(synckit@0.11.12)(typescript@5.9.3)
+ vitest:
+ specifier: catalog:dev
+ version: 4.1.0(@types/node@24.12.0)(esbuild@0.27.4)(jiti@2.6.1)(yaml@2.8.2)
+ vue:
+ specifier: ~3.5.27
+ version: 3.5.30(typescript@5.9.3)
+
+ packages/vue-language:
+ dependencies:
+ '@flint.fyi/ts-patch':
+ specifier: workspace:^
+ version: link:../ts-patch
+ '@flint.fyi/typescript-language':
+ specifier: workspace:^
+ version: link:../typescript-language
+ '@flint.fyi/utils':
+ specifier: workspace:^
+ version: link:../utils
+ '@flint.fyi/volar-language':
+ specifier: workspace:^
+ version: link:../volar-language
+ '@volar/language-core':
+ specifier: 2.4.28
+ version: 2.4.28
+ '@vue/compiler-dom':
+ specifier: ^3.5.0
+ version: 3.5.30
+ '@vue/language-core':
+ specifier: 3.2.1
+ version: 3.2.1
+ typescript:
+ specifier: ^5.9.0 || ^6.0.0
+ version: 5.9.3
+ devDependencies:
+ '@flint.fyi/core':
+ specifier: workspace:^
+ version: link:../core
+ tsdown:
+ specifier: catalog:dev
+ version: 0.21.0(@arethetypeswrong/core@0.18.2)(oxc-resolver@11.19.1)(synckit@0.11.12)(typescript@5.9.3)
+ vitest:
+ specifier: catalog:dev
+ version: 4.1.0(@types/node@24.12.0)(esbuild@0.27.4)(jiti@2.6.1)(yaml@2.8.2)
+
packages/yaml:
dependencies:
'@flint.fyi/core':
@@ -3053,6 +3130,9 @@ packages:
peerDependencies:
typescript: '*'
+ '@volar/language-core@2.4.27':
+ resolution: {integrity: sha512-DjmjBWZ4tJKxfNC1F6HyYERNHPYS7L7OPFyCrestykNdUZMFYzI9WTyvwPcaNaHlrEUwESHYsfEw3isInncZxQ==}
+
'@volar/language-core@2.4.28':
resolution: {integrity: sha512-w4qhIJ8ZSitgLAkVay6AbcnC7gP3glYM3fYwKV3srj8m494E3xtrCv6E+bWviiK/8hs6e6t1ij1s2Endql7vzQ==}
@@ -3062,6 +3142,9 @@ packages:
'@volar/language-service@2.4.28':
resolution: {integrity: sha512-Rh/wYCZJrI5vCwMk9xyw/Z+MsWxlJY1rmMZPsxUoJKfzIRjS/NF1NmnuEcrMbEVGja00aVpCsInJfixQTMdvLw==}
+ '@volar/source-map@2.4.27':
+ resolution: {integrity: sha512-ynlcBReMgOZj2i6po+qVswtDUeeBRCTgDurjMGShbm8WYZgJ0PA4RmtebBJ0BCYol1qPv3GQF6jK7C9qoVc7lg==}
+
'@volar/source-map@2.4.28':
resolution: {integrity: sha512-yX2BDBqJkRXfKw8my8VarTyjv48QwxdJtvRgUpNE5erCsgEUdI2DsLbpa+rOQVAJYshY99szEcRDmyHbF10ggQ==}
@@ -3080,9 +3163,32 @@ packages:
'@vue/compiler-dom@3.5.30':
resolution: {integrity: sha512-eCFYESUEVYHhiMuK4SQTldO3RYxyMR/UQL4KdGD1Yrkfdx4m/HYuZ9jSfPdA+nWJY34VWndiYdW/wZXyiPEB9g==}
+ '@vue/compiler-sfc@3.5.30':
+ resolution: {integrity: sha512-LqmFPDn89dtU9vI3wHJnwaV6GfTRD87AjWpTWpyrdVOObVtjIuSeZr181z5C4PmVx/V3j2p+0f7edFKGRMpQ5A==}
+
+ '@vue/compiler-ssr@3.5.30':
+ resolution: {integrity: sha512-NsYK6OMTnx109PSL2IAyf62JP6EUdk4Dmj6AkWcJGBvN0dQoMYtVekAmdqgTtWQgEJo+Okstbf/1p7qZr5H+bA==}
+
+ '@vue/language-core@3.2.1':
+ resolution: {integrity: sha512-g6oSenpnGMtpxHGAwKuu7HJJkNZpemK/zg3vZzZbJ6cnnXq1ssxuNrXSsAHYM3NvH8p4IkTw+NLmuxyeYz4r8A==}
+
'@vue/language-core@3.2.5':
resolution: {integrity: sha512-d3OIxN/+KRedeM5wQ6H6NIpwS3P5gC9nmyaHgBk+rO6dIsjY+tOh4UlPpiZbAh3YtLdCGEX4M16RmsBqPmJV+g==}
+ '@vue/reactivity@3.5.30':
+ resolution: {integrity: sha512-179YNgKATuwj9gB+66snskRDOitDiuOZqkYia7mHKJaidOMo/WJxHKF8DuGc4V4XbYTJANlfEKb0yxTQotnx4Q==}
+
+ '@vue/runtime-core@3.5.30':
+ resolution: {integrity: sha512-e0Z+8PQsUTdwV8TtEsLzUM7SzC7lQwYKePydb7K2ZnmS6jjND+WJXkmmfh/swYzRyfP1EY3fpdesyYoymCzYfg==}
+
+ '@vue/runtime-dom@3.5.30':
+ resolution: {integrity: sha512-2UIGakjU4WSQ0T4iwDEW0W7vQj6n7AFn7taqZ9Cvm0Q/RA2FFOziLESrDL4GmtI1wV3jXg5nMoJSYO66egDUBw==}
+
+ '@vue/server-renderer@3.5.30':
+ resolution: {integrity: sha512-v+R34icapydRwbZRD0sXwtHqrQJv38JuMB4JxbOxd8NEpGLny7cncMp53W9UH/zo4j8eDHjQ1dEJXwzFQknjtQ==}
+ peerDependencies:
+ vue: 3.5.30
+
'@vue/shared@3.5.30':
resolution: {integrity: sha512-YXgQ7JjaO18NeK2K9VTbDHaFy62WrObMa6XERNfNOkAhD1F1oDSf3ZJ7K6GqabZ0BvSDHajp8qfS5Sa2I9n8uQ==}
@@ -6031,6 +6137,14 @@ packages:
vscode-uri@3.1.0:
resolution: {integrity: sha512-/BpdSx+yCQGnCvecbyXdxHDkuk55/G3xwnC0GqY4gmQ3j+A+g8kzzgB4Nk/SINjqn6+waqw3EgbVF2QKExkRxQ==}
+ vue@3.5.30:
+ resolution: {integrity: sha512-hTHLc6VNZyzzEH/l7PFGjpcTvUgiaPK5mdLkbjrTeWSRcEfxFrv56g/XckIYlE9ckuobsdwqd5mk2g1sBkMewg==}
+ peerDependencies:
+ typescript: '*'
+ peerDependenciesMeta:
+ typescript:
+ optional: true
+
walk-up-path@4.0.0:
resolution: {integrity: sha512-3hu+tD8YzSLGuFYtPRb48vdhKMi0KQV5sn+uWr8+7dMEq/2G/dtLrdDinkLjqq5TIbIBjYJ4Ax/n3YiaW7QM8A==}
engines: {node: 20 || >=22}
@@ -8045,6 +8159,10 @@ snapshots:
vscode-languageserver-textdocument: 1.0.12
vscode-uri: 3.1.0
+ '@volar/language-core@2.4.27':
+ dependencies:
+ '@volar/source-map': 2.4.27
+
'@volar/language-core@2.4.28':
dependencies:
'@volar/source-map': 2.4.28
@@ -8068,6 +8186,8 @@ snapshots:
vscode-languageserver-textdocument: 1.0.12
vscode-uri: 3.1.0
+ '@volar/source-map@2.4.27': {}
+
'@volar/source-map@2.4.28': {}
'@volar/typescript@2.4.28':
@@ -8099,6 +8219,33 @@ snapshots:
'@vue/compiler-core': 3.5.30
'@vue/shared': 3.5.30
+ '@vue/compiler-sfc@3.5.30':
+ dependencies:
+ '@babel/parser': 7.29.0
+ '@vue/compiler-core': 3.5.30
+ '@vue/compiler-dom': 3.5.30
+ '@vue/compiler-ssr': 3.5.30
+ '@vue/shared': 3.5.30
+ estree-walker: 2.0.2
+ magic-string: 0.30.21
+ postcss: 8.5.8
+ source-map-js: 1.2.1
+
+ '@vue/compiler-ssr@3.5.30':
+ dependencies:
+ '@vue/compiler-dom': 3.5.30
+ '@vue/shared': 3.5.30
+
+ '@vue/language-core@3.2.1':
+ dependencies:
+ '@volar/language-core': 2.4.27
+ '@vue/compiler-dom': 3.5.30
+ '@vue/shared': 3.5.30
+ alien-signals: 3.1.2
+ muggle-string: 0.4.1
+ path-browserify: 1.0.1
+ picomatch: 4.0.3
+
'@vue/language-core@3.2.5':
dependencies:
'@volar/language-core': 2.4.28
@@ -8109,6 +8256,28 @@ snapshots:
path-browserify: 1.0.1
picomatch: 4.0.3
+ '@vue/reactivity@3.5.30':
+ dependencies:
+ '@vue/shared': 3.5.30
+
+ '@vue/runtime-core@3.5.30':
+ dependencies:
+ '@vue/reactivity': 3.5.30
+ '@vue/shared': 3.5.30
+
+ '@vue/runtime-dom@3.5.30':
+ dependencies:
+ '@vue/reactivity': 3.5.30
+ '@vue/runtime-core': 3.5.30
+ '@vue/shared': 3.5.30
+ csstype: 3.2.3
+
+ '@vue/server-renderer@3.5.30(vue@3.5.30(typescript@5.9.3))':
+ dependencies:
+ '@vue/compiler-ssr': 3.5.30
+ '@vue/shared': 3.5.30
+ vue: 3.5.30(typescript@5.9.3)
+
'@vue/shared@3.5.30': {}
'@webgpu/types@0.1.21': {}
@@ -11668,6 +11837,16 @@ snapshots:
vscode-uri@3.1.0: {}
+ vue@3.5.30(typescript@5.9.3):
+ dependencies:
+ '@vue/compiler-dom': 3.5.30
+ '@vue/compiler-sfc': 3.5.30
+ '@vue/runtime-dom': 3.5.30
+ '@vue/server-renderer': 3.5.30(vue@3.5.30(typescript@5.9.3))
+ '@vue/shared': 3.5.30
+ optionalDependencies:
+ typescript: 5.9.3
+
walk-up-path@4.0.0: {}
web-namespaces@2.0.1: {}
diff --git a/tsconfig.json b/tsconfig.json
index c5a42c1b4..214af73c8 100644
--- a/tsconfig.json
+++ b/tsconfig.json
@@ -37,6 +37,10 @@
{ "path": "./packages/ts-patch" },
{ "path": "./packages/ts" },
{ "path": "./packages/typescript-language" },
+ { "path": "./packages/utils" },
+ { "path": "./packages/volar-language" },
+ { "path": "./packages/vue" },
+ { "path": "./packages/vue-language" },
{ "path": "./packages/yaml-language" },
{ "path": "./packages/yaml" }
]