@geoql/doctor-core 1.2.1 → 1.3.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/dist/index.js CHANGED
@@ -1,6 +1,6 @@
1
1
  import { dirname, extname, isAbsolute, join, relative, resolve } from "node:path";
2
2
  import { performance } from "node:perf_hooks";
3
- import { access, mkdir, mkdtemp, readFile, readdir, rm, writeFile } from "node:fs/promises";
3
+ import { access, mkdir, mkdtemp, readFile, readdir, rm, stat, writeFile } from "node:fs/promises";
4
4
  import { createRequire } from "node:module";
5
5
  import { execFile, spawn } from "node:child_process";
6
6
  import { parseSync } from "oxc-parser";
@@ -227,7 +227,84 @@ async function checkBuildQuality(projectInfo) {
227
227
  //#endregion
228
228
  //#region src/dead-code/build-knip-config.ts
229
229
  const DEMO_WORKSPACES = ["example", "playground"];
230
- function buildKnipConfig(projectInfo, doctorConfig) {
230
+ async function findPnpmWorkspaceYaml(startDir) {
231
+ let dir = startDir;
232
+ for (let i = 0; i < 5; i++) {
233
+ const candidate = join(dir, "pnpm-workspace.yaml");
234
+ try {
235
+ if ((await stat(candidate)).isFile()) return candidate;
236
+ } catch {}
237
+ const parent = dirname(dir);
238
+ if (parent === dir) break;
239
+ dir = parent;
240
+ }
241
+ return null;
242
+ }
243
+ function collectCatalogNames(packageJson) {
244
+ const names = /* @__PURE__ */ new Set();
245
+ const fields = [
246
+ packageJson.dependencies,
247
+ packageJson.devDependencies,
248
+ packageJson.peerDependencies,
249
+ packageJson.optionalDependencies
250
+ ];
251
+ for (const field of fields) {
252
+ if (!field) continue;
253
+ for (const version of Object.values(field)) if (typeof version === "string" && version.startsWith("catalog:")) names.add(version.slice(8));
254
+ }
255
+ return names;
256
+ }
257
+ function extractCatalogDependencies(workspaceYaml, catalogName) {
258
+ const lines = workspaceYaml.split(/\r?\n/);
259
+ const deps = [];
260
+ let inCatalogs = false;
261
+ let inTargetBlock = false;
262
+ let blockIndent = -1;
263
+ const escapedCatalogName = catalogName.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
264
+ const catalogPattern = new RegExp(`^${escapedCatalogName}\\s*:\\s*$`);
265
+ for (const line of lines) {
266
+ const trimmed = line.trim();
267
+ if (trimmed === "" || trimmed.startsWith("#")) continue;
268
+ if (!inCatalogs) {
269
+ if (/^catalogs\s*:/.test(trimmed)) inCatalogs = true;
270
+ continue;
271
+ }
272
+ const indent = line.length - line.trimStart().length;
273
+ if (inTargetBlock) {
274
+ if (indent <= blockIndent) break;
275
+ const match = line.match(/^[ \t]*["']?([@a-zA-Z0-9_./-]+)["']?[ \t]*:/);
276
+ if (match) deps.push(match[1]);
277
+ continue;
278
+ }
279
+ if (catalogPattern.test(trimmed)) {
280
+ inTargetBlock = true;
281
+ blockIndent = indent;
282
+ } else if (indent === 0 && /^\w+/.test(trimmed) && !/^catalogs\b/.test(trimmed)) break;
283
+ }
284
+ return deps;
285
+ }
286
+ async function resolveCatalogDependencies(rootDirectory) {
287
+ const workspacePath = await findPnpmWorkspaceYaml(rootDirectory);
288
+ if (!workspacePath) return [];
289
+ let packageJson;
290
+ try {
291
+ packageJson = JSON.parse(await readFile(join(rootDirectory, "package.json"), "utf8"));
292
+ } catch {
293
+ return [];
294
+ }
295
+ const catalogNames = collectCatalogNames(packageJson);
296
+ if (catalogNames.size === 0) return [];
297
+ let workspaceYaml;
298
+ try {
299
+ workspaceYaml = await readFile(workspacePath, "utf8");
300
+ } catch {
301
+ return [];
302
+ }
303
+ const resolved = /* @__PURE__ */ new Set();
304
+ for (const catalogName of catalogNames) for (const dep of extractCatalogDependencies(workspaceYaml, catalogName)) resolved.add(dep);
305
+ return [...resolved];
306
+ }
307
+ async function buildKnipConfig(projectInfo, doctorConfig) {
231
308
  const isNuxt = projectInfo.framework === "nuxt";
232
309
  const usesConventionResolution = projectInfo.hasAutoImports || projectInfo.hasComponentsAutoImport || projectInfo.hasVueRouter;
233
310
  const entry = isNuxt ? [
@@ -248,6 +325,7 @@ function buildKnipConfig(projectInfo, doctorConfig) {
248
325
  "src/stores/**/*.ts"
249
326
  ] : []
250
327
  ];
328
+ const catalogDependencies = await resolveCatalogDependencies(projectInfo.rootDirectory);
251
329
  const config = {
252
330
  cwd: projectInfo.rootDirectory,
253
331
  entry,
@@ -259,11 +337,12 @@ function buildKnipConfig(projectInfo, doctorConfig) {
259
337
  "!**/knip.config.mjs"
260
338
  ],
261
339
  ignoreFiles: [...doctorConfig.exclude, "knip.config.mjs"],
262
- ignoreDependencies: [
340
+ ignoreDependencies: [...new Set([
263
341
  "vite-plus",
264
342
  "@geoql/vue-doctor",
265
- "@geoql/nuxt-doctor"
266
- ],
343
+ "@geoql/nuxt-doctor",
344
+ ...catalogDependencies
345
+ ])],
267
346
  ignoreWorkspaces: [...DEMO_WORKSPACES]
268
347
  };
269
348
  if (isNuxt) config.compilers = { nuxt: true };
@@ -421,7 +500,7 @@ async function writeKnipConfig(rootDir, config) {
421
500
  }
422
501
  async function checkDeadCode(options) {
423
502
  if (!options.enabled) return [];
424
- const config = buildKnipConfig(options.projectInfo, options.doctorConfig);
503
+ const config = await buildKnipConfig(options.projectInfo, options.doctorConfig);
425
504
  const timeoutMs = options.timeoutMs ?? 3e4;
426
505
  let createOptions;
427
506
  let run;
@@ -1545,9 +1624,14 @@ const VUE_OXLINT_RULE_IDS = new Set([
1545
1624
  "vue-doctor/reactivity/watch-without-cleanup",
1546
1625
  "vue-doctor/reactivity/prefer-shallowRef-for-large-data",
1547
1626
  "vue-doctor/reactivity/prefer-readonly-for-injected",
1627
+ "vue-doctor/reactivity/no-fresh-deps-in-watch",
1548
1628
  "vue-doctor/composition/prefer-script-setup-for-new-files",
1549
1629
  "vue-doctor/composition/defineProps-typed",
1630
+ "vue-doctor/composition/no-pinia-store-in-setup",
1550
1631
  "vue-doctor/performance/prefer-defineAsyncComponent-on-route",
1632
+ "vue-doctor/performance/prefer-module-scope-static-value",
1633
+ "vue-doctor/performance/prefer-module-scope-pure-function",
1634
+ "vue-doctor/performance/prefer-stable-empty-fallback",
1551
1635
  "vue-doctor/security/no-inner-html",
1552
1636
  "vue-doctor/security/no-eval-like",
1553
1637
  "vue-doctor/security/no-auth-token-in-web-storage",
@@ -1606,6 +1690,7 @@ async function generateOxlintConfig(input) {
1606
1690
  ...userConfig ? { extends: [userConfig] } : {},
1607
1691
  plugins: ["vue"],
1608
1692
  jsPlugins: input.pluginPaths,
1693
+ ...input.exclude && input.exclude.length > 0 ? { ignorePatterns: input.exclude } : {},
1609
1694
  rules
1610
1695
  };
1611
1696
  const configPath = join(dir, ".oxlintrc.json");
@@ -1811,7 +1896,8 @@ async function runScriptPass(opts) {
1811
1896
  pluginPaths,
1812
1897
  ruleOverrides: opts.ruleOverrides,
1813
1898
  rootDir: opts.rootDir,
1814
- framework: opts.framework
1899
+ framework: opts.framework,
1900
+ exclude: opts.exclude
1815
1901
  });
1816
1902
  try {
1817
1903
  const raw = await runOxlint({
@@ -1832,2226 +1918,2460 @@ async function runScriptPass(opts) {
1832
1918
  }
1833
1919
  }
1834
1920
  //#endregion
1835
- //#region src/score.ts
1836
- const SEVERITY_WEIGHTS = {
1837
- error: 5,
1838
- warn: 2,
1839
- info: .5
1840
- };
1841
- function scoreDiagnostics(diagnostics, config) {
1842
- const threshold = config?.threshold ?? 0;
1843
- let errorCount = 0;
1844
- let warnCount = 0;
1845
- let infoCount = 0;
1846
- const byRule = /* @__PURE__ */ new Map();
1847
- for (const d of diagnostics) {
1848
- if (d.severity === "error") errorCount += 1;
1849
- else if (d.severity === "warn") warnCount += 1;
1850
- else infoCount += 1;
1851
- const list = byRule.get(d.ruleId);
1852
- if (list) list.push(d);
1853
- else byRule.set(d.ruleId, [d]);
1854
- }
1855
- const sortedRuleIds = [...byRule.keys()].sort();
1856
- const breakdown = [];
1857
- let penalty = 0;
1858
- for (const ruleId of sortedRuleIds) {
1859
- const list = byRule.get(ruleId);
1860
- const weight = config?.rules?.[ruleId]?.weight ?? SEVERITY_WEIGHTS[list[0].severity];
1861
- let rulePenalty = 0;
1862
- for (let i = 0; i < list.length; i++) rulePenalty += weight * (i === 0 ? 1 : 1 / Math.sqrt(i + 1));
1863
- penalty += rulePenalty;
1864
- breakdown.push({
1865
- ruleId,
1866
- occurrences: list.length,
1867
- weightPerOccurrence: weight,
1868
- penalty: rulePenalty
1869
- });
1870
- }
1871
- breakdown.sort((a, b) => b.penalty - a.penalty);
1872
- const score = Math.max(0, Math.round(100 - penalty));
1873
- return {
1874
- score,
1875
- passed: score >= threshold,
1876
- threshold,
1877
- totalFindings: diagnostics.length,
1878
- errorCount,
1879
- warnCount,
1880
- infoCount,
1881
- breakdown
1882
- };
1883
- }
1884
- //#endregion
1885
- //#region src/sfc/parse-sfc-descriptor.ts
1886
- const cache$1 = /* @__PURE__ */ new Map();
1887
- async function parseSfcDescriptor(absPath) {
1888
- if (cache$1.has(absPath)) return cache$1.get(absPath) ?? null;
1889
- let source;
1890
- try {
1891
- source = await readFile(absPath, "utf8");
1892
- } catch {
1893
- cache$1.set(absPath, null);
1894
- return null;
1895
- }
1896
- const { descriptor, errors } = parse(source, { filename: absPath });
1897
- if (errors.length > 0) {
1898
- cache$1.set(absPath, null);
1899
- return null;
1900
- }
1901
- cache$1.set(absPath, descriptor);
1902
- return descriptor;
1903
- }
1904
- //#endregion
1905
- //#region src/sfc/rules/no-mixed-options-and-composition-api.ts
1906
- const RULE_ID$5 = "vue-doctor/sfc/no-mixed-options-and-composition-api";
1907
- const MESSAGE$5 = "Mixed Options API in a <script setup> SFC. Move data/methods/computed/watch/lifecycle into <script setup> Composition API; keep <script> only for options like name/inheritAttrs. See https://vuejs.org/api/sfc-script-setup.html#usage-alongside-normal-script";
1908
- const RECOMMENDATION$5 = "Move this option into the <script setup> block using the Composition API, or keep <script> only for options-only config such as name or inheritAttrs.";
1909
- const DISALLOWED = new Set([
1910
- "data",
1911
- "methods",
1912
- "computed",
1913
- "watch",
1914
- "props",
1915
- "emits",
1916
- "provide",
1917
- "inject",
1918
- "beforeCreate",
1919
- "created",
1920
- "beforeMount",
1921
- "mounted",
1922
- "beforeUpdate",
1923
- "updated",
1924
- "beforeUnmount",
1925
- "unmounted",
1926
- "activated",
1927
- "deactivated",
1928
- "errorCaptured",
1929
- "serverPrefetch"
1930
- ]);
1931
- function resolveDefineComponentCall(call) {
1932
- if (call.callee.type !== "Identifier") return null;
1933
- if (call.callee.name !== "defineComponent") return null;
1934
- const first = call.arguments[0];
1935
- if (!first || first.type !== "ObjectExpression") return null;
1936
- return first;
1937
- }
1938
- function findBindingInit(body, name) {
1939
- for (const stmt of body) {
1940
- if (stmt.type !== "VariableDeclaration") continue;
1941
- for (const declarator of stmt.declarations) {
1942
- if (declarator.id.type !== "Identifier") continue;
1943
- if (declarator.id.name !== name) continue;
1944
- return declarator.init;
1945
- }
1946
- }
1947
- return null;
1948
- }
1949
- function resolveOptionsObject(program) {
1950
- const exported = program.body.find((node) => node.type === "ExportDefaultDeclaration");
1951
- if (!exported) return null;
1952
- const declaration = exported.declaration;
1953
- if (declaration.type === "ObjectExpression") return declaration;
1954
- if (declaration.type === "CallExpression") return resolveDefineComponentCall(declaration);
1955
- if (declaration.type === "Identifier") {
1956
- const init = findBindingInit(program.body, declaration.name);
1957
- if (init && init.type === "CallExpression") return resolveDefineComponentCall(init);
1958
- return null;
1959
- }
1960
- return null;
1961
- }
1962
- function locate(content, offset, startLine, startColumn) {
1963
- let line = startLine;
1964
- let lastNewline = -1;
1965
- for (let i = 0; i < offset; i += 1) if (content.charCodeAt(i) === 10) {
1966
- line += 1;
1967
- lastNewline = i;
1968
- }
1969
- const column = lastNewline === -1 ? startColumn + offset : offset - lastNewline;
1970
- return {
1971
- line,
1972
- column
1973
- };
1974
- }
1975
- function check$19(ctx) {
1976
- const { script, scriptSetup } = ctx.descriptor;
1977
- if (!script || !scriptSetup) return { diagnostics: [] };
1978
- const lang = script.lang === "ts" ? "ts" : "js";
1979
- const { program } = parseSync(`script.${lang}`, script.content, {
1980
- sourceType: "module",
1981
- lang
1982
- });
1983
- const options = resolveOptionsObject(program);
1984
- if (!options) return { diagnostics: [] };
1985
- const diagnostics = [];
1986
- for (const property of options.properties) {
1987
- if (property.type !== "Property") continue;
1988
- if (property.key.type !== "Identifier") continue;
1989
- if (!DISALLOWED.has(property.key.name)) continue;
1990
- const { line, column } = locate(script.content, property.start, script.loc.start.line, script.loc.start.column);
1991
- diagnostics.push({
1992
- file: ctx.file,
1993
- line,
1994
- column,
1995
- ruleId: RULE_ID$5,
1996
- severity: "error",
1997
- message: MESSAGE$5,
1998
- source: "sfc",
1999
- recommendation: RECOMMENDATION$5
2000
- });
2001
- }
2002
- return { diagnostics };
2003
- }
2004
- //#endregion
2005
- //#region src/nuxt/file-role.ts
2006
- const PAGE_DIR = /^(?:app\/)?pages\//;
2007
- const LAYOUT_DIR = /^(?:app\/)?layouts\//;
2008
- function normalize(relPath) {
2009
- return relPath.replace(/\\/g, "/").replace(/^\.\//, "");
2010
- }
2011
- /**
2012
- * True when the path points at a Nuxt page component — a `.vue` file under
2013
- * `pages/` (Nuxt 3 layout) or `app/pages/` (Nuxt 4 layout) at the project root.
2014
- */
2015
- function isNuxtPageFile(relPath) {
2016
- const path = normalize(relPath);
2017
- return PAGE_DIR.test(path) && path.endsWith(".vue");
2018
- }
2019
- /**
2020
- * True when the path points at a Nitro server file — a `.ts` file under the
2021
- * project-root `server/` directory.
2022
- */
2023
- function isNuxtServerFile(relPath) {
2024
- const path = normalize(relPath);
2025
- return path.startsWith("server/") && path.endsWith(".ts");
2026
- }
2027
- /**
2028
- * True when the path points at a Nuxt layout component — a `.vue` file under
2029
- * `layouts/` (Nuxt 3 layout) or `app/layouts/` (Nuxt 4 layout) at the project
2030
- * root.
2031
- */
2032
- function isNuxtLayoutFile(relPath) {
2033
- const path = normalize(relPath);
2034
- return LAYOUT_DIR.test(path) && path.endsWith(".vue");
2035
- }
2036
- //#endregion
2037
- //#region src/template/walk.ts
2038
- const NODE_ELEMENT$2 = 1;
2039
- function isElementNode(node) {
2040
- return node !== null && node !== void 0 && node.type === NODE_ELEMENT$2;
2041
- }
2042
- function walkElements(root, visit) {
2043
- const stack = [...root.children];
2044
- while (stack.length > 0) {
2045
- const node = stack.pop();
2046
- if (!node) continue;
2047
- if (isElementNode(node)) {
2048
- visit(node);
2049
- if (node.children) for (const child of node.children) stack.push(child);
2050
- }
2051
- }
2052
- }
2053
- //#endregion
2054
- //#region src/sfc/rules/nuxt/no-mixed-app-and-root-layout.ts
2055
- const RULE_ID$4 = "nuxt-doctor/ai-slop/no-mixed-app-and-root-layout";
2056
- const MESSAGE$4 = "Layout file renders<NuxtLayout> inside itself. This creates a nested layout chain where the root layout renders itself as a slot. Use<slot /> directly and let the page content flow through.";
2057
- const RECOMMENDATION$4 = "Replace <NuxtLayout> in this layout file with <slot />. The layout slot is already provided by the parent layout wrapper.";
2058
- function check$18(ctx) {
2059
- if (!isNuxtLayoutFile(ctx.relativePath)) return { diagnostics: [] };
2060
- const { template } = ctx.descriptor;
2061
- if (!template || !template.ast) return { diagnostics: [] };
2062
- let foundNuxtLayout = false;
2063
- let nuxtLayoutNode;
2064
- walkElements(template.ast, (el) => {
2065
- if (el.tag === "NuxtLayout") {
2066
- foundNuxtLayout = true;
2067
- nuxtLayoutNode = el;
2068
- }
2069
- });
2070
- if (!foundNuxtLayout) return { diagnostics: [] };
2071
- const node = nuxtLayoutNode;
2072
- return { diagnostics: [{
2073
- file: ctx.file,
2074
- line: node.loc.start.line,
2075
- column: node.loc.start.column,
2076
- endLine: node.loc.end.line,
2077
- endColumn: node.loc.end.column,
2078
- ruleId: RULE_ID$4,
1921
+ //#region src/rule-registry.ts
1922
+ const RULE_REGISTRY = [
1923
+ {
1924
+ id: "vue/no-export-in-script-setup",
1925
+ severity: "error",
1926
+ category: "vue-builtin",
1927
+ source: "oxlint-builtin",
1928
+ recommended: true
1929
+ },
1930
+ {
1931
+ id: "vue/require-typed-ref",
2079
1932
  severity: "warn",
2080
- message: MESSAGE$4,
2081
- source: "sfc",
2082
- recommendation: RECOMMENDATION$4
2083
- }] };
2084
- }
2085
- //#endregion
2086
- //#region src/sfc/rules/nuxt/og-image-defined.ts
2087
- const RULE_ID$3 = "nuxt-doctor/seo/og-image-defined";
2088
- const MESSAGE$3 = "Page uses SEO meta but has no og:image property. Open Graph images improve social sharing previews. Add an og:image value or install @nuxtjs/og-image for automatic OG images.";
2089
- const RECOMMENDATION$3 = "Add ogImage: \"/path/to/image.png\" to useSeoMeta / useHead, or install @nuxtjs/og-image.";
2090
- function hasOgImageInCall(program) {
2091
- for (const stmt of program.body) {
2092
- if (stmt.type !== "ExpressionStatement") continue;
2093
- const call = stmt.expression;
2094
- if (call.type !== "CallExpression") continue;
2095
- if (call.callee.type !== "Identifier") continue;
2096
- const name = call.callee.name;
2097
- if (name !== "useSeoMeta" && name !== "useHead") continue;
2098
- const firstArg = call.arguments[0];
2099
- if (!firstArg || firstArg.type !== "ObjectExpression") continue;
2100
- for (const prop of firstArg.properties) {
2101
- if (prop.type !== "Property") continue;
2102
- if (prop.key.type === "Identifier" && prop.key.name === "ogImage") return true;
2103
- if (prop.key.type === "Literal" && prop.key.value === "og:image") return true;
2104
- }
2105
- }
2106
- return false;
2107
- }
2108
- function hasOgImageDep(packageJsonPath) {
2109
- try {
2110
- const raw = readFileSync(packageJsonPath, "utf8");
2111
- const pkg = JSON.parse(raw);
2112
- const deps = {
2113
- ...pkg.dependencies,
2114
- ...pkg.devDependencies
2115
- };
2116
- return "@nuxtjs/og-image" in deps || "nuxt-og-image" in deps;
2117
- } catch {
2118
- return false;
2119
- }
2120
- }
2121
- function check$17(ctx) {
2122
- if (!isNuxtPageFile(ctx.relativePath)) return { diagnostics: [] };
2123
- const { scriptSetup } = ctx.descriptor;
2124
- if (!scriptSetup) return { diagnostics: [] };
2125
- const lang = scriptSetup.lang === "ts" ? "ts" : "js";
2126
- const { program } = parseSync(`script.${lang}`, scriptSetup.content, {
2127
- sourceType: "module",
2128
- lang
2129
- });
2130
- if (hasOgImageInCall(program)) return { diagnostics: [] };
2131
- if (ctx.projectInfo.packageJsonPath === null) return { diagnostics: [] };
2132
- if (hasOgImageDep(ctx.projectInfo.packageJsonPath)) return { diagnostics: [] };
2133
- const { line, column } = scriptSetup.loc.start;
2134
- return { diagnostics: [{
2135
- file: ctx.file,
2136
- line,
2137
- column,
2138
- ruleId: RULE_ID$3,
1933
+ category: "vue-builtin",
1934
+ source: "oxlint-builtin",
1935
+ recommended: true
1936
+ },
1937
+ {
1938
+ id: "vue/no-arrow-functions-in-watch",
1939
+ severity: "error",
1940
+ category: "vue-builtin",
1941
+ source: "oxlint-builtin",
1942
+ recommended: true
1943
+ },
1944
+ {
1945
+ id: "vue/no-deprecated-data-object-declaration",
1946
+ severity: "error",
1947
+ category: "vue-builtin",
1948
+ source: "oxlint-builtin",
1949
+ recommended: true
1950
+ },
1951
+ {
1952
+ id: "vue/no-deprecated-events-api",
1953
+ severity: "error",
1954
+ category: "vue-builtin",
1955
+ source: "oxlint-builtin",
1956
+ recommended: true
1957
+ },
1958
+ {
1959
+ id: "vue/no-deprecated-destroyed-lifecycle",
1960
+ severity: "error",
1961
+ category: "vue-builtin",
1962
+ source: "oxlint-builtin",
1963
+ recommended: true
1964
+ },
1965
+ {
1966
+ id: "vue/no-deprecated-model-definition",
1967
+ severity: "error",
1968
+ category: "vue-builtin",
1969
+ source: "oxlint-builtin",
1970
+ recommended: true
1971
+ },
1972
+ {
1973
+ id: "vue/no-deprecated-delete-set",
1974
+ severity: "error",
1975
+ category: "vue-builtin",
1976
+ source: "oxlint-builtin",
1977
+ recommended: true
1978
+ },
1979
+ {
1980
+ id: "vue/no-deprecated-vue-config-keycodes",
1981
+ severity: "error",
1982
+ category: "vue-builtin",
1983
+ source: "oxlint-builtin",
1984
+ recommended: true
1985
+ },
1986
+ {
1987
+ id: "vue/no-lifecycle-after-await",
1988
+ severity: "error",
1989
+ category: "vue-builtin",
1990
+ source: "oxlint-builtin",
1991
+ recommended: true
1992
+ },
1993
+ {
1994
+ id: "vue/no-this-in-before-route-enter",
1995
+ severity: "error",
1996
+ category: "vue-builtin",
1997
+ source: "oxlint-builtin",
1998
+ recommended: true
1999
+ },
2000
+ {
2001
+ id: "vue/return-in-computed-property",
2002
+ severity: "error",
2003
+ category: "vue-builtin",
2004
+ source: "oxlint-builtin",
2005
+ recommended: true
2006
+ },
2007
+ {
2008
+ id: "vue/valid-define-emits",
2009
+ severity: "error",
2010
+ category: "vue-builtin",
2011
+ source: "oxlint-builtin",
2012
+ recommended: true
2013
+ },
2014
+ {
2015
+ id: "vue/valid-define-props",
2016
+ severity: "error",
2017
+ category: "vue-builtin",
2018
+ source: "oxlint-builtin",
2019
+ recommended: true
2020
+ },
2021
+ {
2022
+ id: "vue/no-required-prop-with-default",
2139
2023
  severity: "warn",
2140
- message: MESSAGE$3,
2141
- source: "sfc",
2142
- recommendation: RECOMMENDATION$3
2143
- }] };
2144
- }
2145
- //#endregion
2146
- //#region src/sfc/rules/nuxt/use-seo-meta-on-public-page.ts
2147
- const RULE_ID$2 = "nuxt-doctor/seo/useSeoMeta-on-public-page";
2148
- const MESSAGE$2 = "Public page component is missing SEO metadata. Add useSeoMeta, useHead, or definePageMeta with a title so search engines can index it properly.";
2149
- const RECOMMENDATION$2 = "Call useSeoMeta({ title: \"...\" }) in<script setup> to define page title and meta tags for search engines and social previews.";
2150
- function hasTitleInCall(program) {
2151
- for (const stmt of program.body) {
2152
- if (stmt.type !== "ExpressionStatement") continue;
2153
- const call = stmt.expression;
2154
- if (call.type !== "CallExpression") continue;
2155
- if (call.callee.type !== "Identifier") continue;
2156
- const name = call.callee.name;
2157
- if (name !== "useSeoMeta" && name !== "useHead" && name !== "definePageMeta") continue;
2158
- const firstArg = call.arguments[0];
2159
- if (!firstArg || firstArg.type !== "ObjectExpression") continue;
2160
- for (const prop of firstArg.properties) {
2161
- if (prop.type !== "Property") continue;
2162
- if (prop.key.type === "Identifier" && prop.key.name === "title") return true;
2163
- if (prop.key.type === "Literal" && prop.key.value === "title") return true;
2164
- }
2165
- }
2166
- return false;
2167
- }
2168
- function check$16(ctx) {
2169
- if (!isNuxtPageFile(ctx.relativePath)) return { diagnostics: [] };
2170
- const { scriptSetup } = ctx.descriptor;
2171
- if (!scriptSetup) return { diagnostics: [] };
2172
- const lang = scriptSetup.lang === "ts" ? "ts" : "js";
2173
- const { program } = parseSync(`script.${lang}`, scriptSetup.content, {
2174
- sourceType: "module",
2175
- lang
2176
- });
2177
- if (hasTitleInCall(program)) return { diagnostics: [] };
2178
- const { line, column } = scriptSetup.loc.start;
2179
- return { diagnostics: [{
2180
- file: ctx.file,
2181
- line,
2182
- column,
2183
- ruleId: RULE_ID$2,
2024
+ category: "vue-builtin",
2025
+ source: "oxlint-builtin",
2026
+ recommended: true
2027
+ },
2028
+ {
2029
+ id: "vue/prefer-import-from-vue",
2184
2030
  severity: "warn",
2185
- message: MESSAGE$2,
2186
- source: "sfc",
2187
- recommendation: RECOMMENDATION$2
2188
- }] };
2189
- }
2190
- //#endregion
2191
- //#region src/sfc/rules/index.ts
2192
- const SFC_RULES = [
2031
+ category: "vue-builtin",
2032
+ source: "oxlint-builtin",
2033
+ recommended: true
2034
+ },
2193
2035
  {
2194
- id: "vue-doctor/sfc/no-mixed-options-and-composition-api",
2195
- check: check$19
2036
+ id: "vue/no-import-compiler-macros",
2037
+ severity: "warn",
2038
+ category: "vue-builtin",
2039
+ source: "oxlint-builtin",
2040
+ recommended: true
2196
2041
  },
2197
2042
  {
2198
- id: "nuxt-doctor/seo/useSeoMeta-on-public-page",
2199
- check: check$16
2043
+ id: "vue/no-multiple-slot-args",
2044
+ severity: "warn",
2045
+ category: "vue-builtin",
2046
+ source: "oxlint-builtin",
2047
+ recommended: true
2200
2048
  },
2201
2049
  {
2202
- id: "nuxt-doctor/seo/og-image-defined",
2203
- check: check$17
2050
+ id: "vue/require-default-export",
2051
+ severity: "warn",
2052
+ category: "vue-builtin",
2053
+ source: "oxlint-builtin",
2054
+ recommended: true
2204
2055
  },
2205
2056
  {
2206
- id: "nuxt-doctor/ai-slop/no-mixed-app-and-root-layout",
2207
- check: check$18
2208
- }
2209
- ];
2210
- //#endregion
2211
- //#region src/sfc/run.ts
2212
- async function runSfcPass(opts) {
2213
- const all = [];
2214
- const { projectInfo } = opts;
2215
- const rootDir = projectInfo?.rootDirectory ?? process.cwd();
2216
- for (const file of opts.files) {
2217
- if (!file.endsWith(".vue")) continue;
2218
- const descriptor = await parseSfcDescriptor(file);
2219
- if (!descriptor) continue;
2220
- const relativePath = relative(rootDir, file).replace(/\\/g, "/");
2221
- for (const rule of SFC_RULES) {
2222
- const override = opts.ruleOverrides?.[rule.id];
2223
- if (override === "off") continue;
2224
- const { diagnostics } = rule.check({
2225
- file,
2226
- descriptor,
2227
- rootDirectory: rootDir,
2228
- relativePath,
2229
- projectInfo
2230
- });
2231
- for (const d of diagnostics) all.push(override ? {
2232
- ...d,
2233
- severity: override
2234
- } : d);
2235
- }
2236
- }
2237
- return all;
2238
- }
2239
- //#endregion
2240
- //#region src/nuxt/cross-file/run.ts
2241
- function extractDataFetchingKeys(content, lang) {
2242
- const keys = [];
2243
- try {
2244
- const { program } = parseSync(`script.${lang}`, content, {
2245
- sourceType: "module",
2246
- lang
2247
- });
2248
- for (const stmt of program.body) if (stmt.type === "VariableDeclaration") for (const decl of stmt.declarations) {
2249
- const call = unwrapAwait(decl.init);
2250
- if (call?.type !== "CallExpression") continue;
2251
- extractKeyFromCall(call, keys);
2252
- }
2253
- else if (stmt.type === "ExpressionStatement") {
2254
- const expr = unwrapAwait(stmt.expression);
2255
- if (expr?.type === "CallExpression") extractKeyFromCall(expr, keys);
2256
- }
2257
- } catch {}
2258
- return keys;
2259
- }
2260
- function unwrapAwait(node) {
2261
- if (node?.type === "AwaitExpression") return node.argument;
2262
- return node;
2263
- }
2264
- function extractKeyFromCall(call, keys) {
2265
- if (call.callee.type !== "Identifier") return;
2266
- const fnName = call.callee.name;
2267
- if (fnName !== "useAsyncData" && fnName !== "useFetch") return;
2268
- const keyArg = call.arguments[0];
2269
- if (keyArg?.type === "Literal" && typeof keyArg.value === "string") keys.push(keyArg.value);
2270
- else if (keyArg?.type === "TemplateLiteral" && keyArg.quasis.length === 1) {
2271
- const val = keyArg.quasis[0]?.value?.raw;
2272
- if (val) keys.push(val);
2273
- }
2274
- if (fnName === "useFetch" && call.arguments.length >= 2) {
2275
- const optsArg = call.arguments[1];
2276
- if (optsArg?.type === "ObjectExpression") {
2277
- for (const prop of optsArg.properties) if (prop.type === "Property" && prop.key.type === "Identifier" && prop.key.name === "key" && prop.value.type === "Literal" && typeof prop.value.value === "string") keys.push(prop.value.value);
2278
- }
2279
- }
2280
- }
2281
- async function runCrossFilePass(opts) {
2282
- const { files, projectInfo } = opts;
2283
- if (projectInfo.framework !== "nuxt") return [];
2284
- const rootDir = projectInfo.rootDirectory;
2285
- const pageFiles = files.filter((f) => isNuxtPageFile(relative(rootDir, f).replace(/\\/g, "/")));
2286
- if (pageFiles.length === 0) return [];
2287
- const parsed = [];
2288
- for (const file of pageFiles) {
2289
- const descriptor = await parseSfcDescriptor(file);
2290
- if (!descriptor) continue;
2291
- const { scriptSetup } = descriptor;
2292
- if (!scriptSetup) continue;
2293
- parsed.push({
2294
- file,
2295
- relativePath: relative(rootDir, file).replace(/\\/g, "/"),
2296
- keys: extractDataFetchingKeys(scriptSetup.content, scriptSetup.lang === "ts" ? "ts" : "js"),
2297
- scriptSetupContent: scriptSetup.content,
2298
- scriptSetupLang: scriptSetup.lang === "ts" ? "ts" : "js",
2299
- scriptSetupLine: scriptSetup.loc.start.line
2300
- });
2301
- }
2302
- return runCrossFileRules(parsed);
2303
- }
2304
- function runCrossFileRules(pages) {
2305
- const all = [];
2306
- all.push(...ruleNoSharedKeyAcrossPages(pages));
2307
- all.push(...ruleSsrSafeOnMountedOnlyForClient(pages));
2308
- return all;
2309
- }
2310
- function ruleNoSharedKeyAcrossPages(pages) {
2311
- const keyToFiles = /* @__PURE__ */ new Map();
2312
- for (const page of pages) for (const key of page.keys) {
2313
- const existing = keyToFiles.get(key) ?? [];
2314
- if (!existing.includes(page.file)) existing.push(page.file);
2315
- keyToFiles.set(key, existing);
2316
- }
2317
- const diags = [];
2318
- for (const [key, files] of keyToFiles) {
2319
- if (files.length < 2) continue;
2320
- for (const file of files) diags.push({
2321
- file,
2322
- line: 1,
2323
- column: 1,
2324
- ruleId: "nuxt-doctor/data-fetching/no-shared-key-across-pages",
2325
- severity: "warn",
2326
- message: `Data fetching key "${key}" is shared across ${files.length} different page files. This causes cache collisions in useAsyncData/useFetch. Use a unique key per page.`,
2327
- source: "cross-file",
2328
- recommendation: `Rename the key to something page-specific, e.g. "page-${key}" or "users-${key}".`
2329
- });
2330
- }
2331
- return diags;
2332
- }
2333
- const BROWSER_GLOBALS = new Set([
2334
- "window",
2335
- "document",
2336
- "navigator",
2337
- "localStorage",
2338
- "sessionStorage",
2339
- "location",
2340
- "history",
2341
- "fetch",
2342
- "XMLHttpRequest",
2343
- "matchMedia",
2344
- "IntersectionObserver",
2345
- "MutationObserver",
2346
- "indexedDB",
2347
- "webkit",
2348
- "moz",
2349
- "onmessage"
2350
- ]);
2351
- function ruleSsrSafeOnMountedOnlyForClient(pages) {
2352
- const diags = [];
2353
- for (const page of pages) {
2354
- const { program } = parseSync(`script.${page.scriptSetupLang}`, page.scriptSetupContent, {
2355
- sourceType: "module",
2356
- lang: page.scriptSetupLang
2357
- });
2358
- for (const stmt of program.body) if (stmt.type === "VariableDeclaration") for (const decl of stmt.declarations) {
2359
- const init = decl.init;
2360
- if (!init) continue;
2361
- if (init.type === "CallExpression") {
2362
- const call = init;
2363
- if (isSkippedReactiveCall(call)) continue;
2364
- checkCallForBrowserGlobal(call, page, diags);
2365
- } else if (init.type === "MemberExpression") checkMemberForBrowserGlobal(init, page, diags);
2366
- }
2367
- else if (stmt.type === "ExpressionStatement") {
2368
- const expr = stmt.expression;
2369
- if (expr.type === "CallExpression") {
2370
- if (isSkippedReactiveCall(expr)) continue;
2371
- checkCallForBrowserGlobal(expr, page, diags);
2372
- } else if (expr.type === "MemberExpression") checkMemberForBrowserGlobal(expr, page, diags);
2373
- }
2374
- }
2375
- return diags;
2376
- }
2377
- const CLIENT_SAFE_WRAPPERS = new Set([
2378
- "onMounted",
2379
- "watchEffect",
2380
- "watch"
2381
- ]);
2382
- function isSkippedReactiveCall(call) {
2383
- if (call.callee.type !== "Identifier") return false;
2384
- return CLIENT_SAFE_WRAPPERS.has(call.callee.name);
2385
- }
2386
- function checkCallForBrowserGlobal(call, page, diags) {
2387
- const callee = call.callee;
2388
- if (callee.type !== "MemberExpression") return;
2389
- const obj = callee.object;
2390
- if (obj.type !== "Identifier" || !BROWSER_GLOBALS.has(obj.name)) return;
2391
- diags.push({
2392
- file: page.file,
2393
- line: page.scriptSetupLine,
2394
- column: 1,
2395
- ruleId: "nuxt-doctor/data-fetching/ssr-safe-onMounted-only-for-client",
2057
+ id: "vue/define-emits-declaration",
2058
+ severity: "info",
2059
+ category: "vue-builtin",
2060
+ source: "oxlint-builtin",
2061
+ recommended: false
2062
+ },
2063
+ {
2064
+ id: "vue/define-props-declaration",
2065
+ severity: "info",
2066
+ category: "vue-builtin",
2067
+ source: "oxlint-builtin",
2068
+ recommended: false
2069
+ },
2070
+ {
2071
+ id: "vue/define-props-destructuring",
2072
+ severity: "info",
2073
+ category: "vue-builtin",
2074
+ source: "oxlint-builtin",
2075
+ recommended: false
2076
+ },
2077
+ {
2078
+ id: "vue/max-props",
2079
+ severity: "info",
2080
+ category: "vue-builtin",
2081
+ source: "oxlint-builtin",
2082
+ recommended: false
2083
+ },
2084
+ {
2085
+ id: "vue-doctor/no-em-dash-in-string",
2396
2086
  severity: "warn",
2397
- message: `Browser global "${obj.name}" is accessed at setup top-level outside onMounted. This causes SSR/hydration mismatches. Move browser-only code inside onMounted or a client-only plugin.`,
2398
- source: "cross-file",
2399
- recommendation: `Wrap "${obj.name}" access in onMounted(() => { /* code */ }) or use defineNuxtPlugin to run only on the client.`
2400
- });
2401
- }
2402
- function checkMemberForBrowserGlobal(mem, page, diags) {
2403
- const obj = mem.object;
2404
- if (obj.type !== "Identifier" || !BROWSER_GLOBALS.has(obj.name)) return;
2405
- diags.push({
2406
- file: page.file,
2407
- line: page.scriptSetupLine,
2408
- column: 1,
2409
- ruleId: "nuxt-doctor/data-fetching/ssr-safe-onMounted-only-for-client",
2087
+ category: "ai-slop",
2088
+ source: "doctor",
2089
+ recommended: true
2090
+ },
2091
+ {
2092
+ id: "vue-doctor/no-destructure-props-without-to-refs",
2093
+ severity: "error",
2094
+ category: "ai-slop",
2095
+ source: "doctor",
2096
+ recommended: true
2097
+ },
2098
+ {
2099
+ id: "vue-doctor/no-destructure-reactive-without-to-refs",
2100
+ severity: "error",
2101
+ category: "ai-slop",
2102
+ source: "doctor",
2103
+ recommended: true
2104
+ },
2105
+ {
2106
+ id: "vue-doctor/no-non-null-assertion-on-ref-value",
2410
2107
  severity: "warn",
2411
- message: `Browser global "${obj.name}" is accessed at setup top-level outside onMounted. This causes SSR/hydration mismatches. Move browser-only code inside onMounted or a client-only plugin.`,
2412
- source: "cross-file",
2413
- recommendation: `Wrap "${obj.name}" access in onMounted(() => { /* code */ }) or use defineNuxtPlugin to run only on the client.`
2414
- });
2415
- }
2416
- //#endregion
2417
- //#region src/template/parse-sfc.ts
2418
- const cache = /* @__PURE__ */ new Map();
2419
- async function parseSfc(absPath) {
2420
- if (cache.has(absPath)) return cache.get(absPath) ?? null;
2421
- let source;
2422
- try {
2423
- source = await readFile(absPath, "utf8");
2424
- } catch {
2425
- cache.set(absPath, null);
2426
- return null;
2427
- }
2428
- const { descriptor, errors } = parse(source, { filename: absPath });
2429
- if (errors.length > 0 || !descriptor.template) {
2430
- cache.set(absPath, null);
2431
- return null;
2432
- }
2433
- cache.set(absPath, descriptor);
2434
- return descriptor;
2435
- }
2436
- //#endregion
2437
- //#region src/template/directive-helpers.ts
2438
- const NODE_DIRECTIVE$4 = 7;
2439
- function findDirective(el, name) {
2440
- for (const prop of el.props) if (prop.type === NODE_DIRECTIVE$4 && prop.name === name) return prop;
2441
- }
2442
- function findBindAttr(el, attrName) {
2443
- for (const prop of el.props) {
2444
- if (prop.type !== NODE_DIRECTIVE$4) continue;
2445
- const dir = prop;
2446
- if (dir.name !== "bind") continue;
2447
- if (dir.arg?.content === attrName) return dir;
2448
- }
2449
- }
2450
- function findStaticAttr(el, attrName) {
2451
- for (const prop of el.props) if (prop.type === 6 && prop.name === attrName) return prop;
2452
- }
2453
- //#endregion
2454
- //#region src/template/rules/v-for-has-key.ts
2455
- function check$15(ctx) {
2456
- const diagnostics = [];
2457
- walkElements(ctx.template, (el) => {
2458
- if (!findDirective(el, "for")) return;
2459
- if (findBindAttr(el, "key") ?? findStaticAttr(el, "key")) return;
2460
- diagnostics.push({
2461
- file: ctx.file,
2462
- line: el.loc.start.line,
2463
- column: el.loc.start.column,
2464
- endLine: el.loc.end.line,
2465
- endColumn: el.loc.end.column,
2466
- ruleId: "vue-doctor/template/v-for-has-key",
2467
- severity: "error",
2468
- message: `<${el.tag}> uses v-for without :key. Vue cannot efficiently diff this list across renders.`,
2469
- source: "template",
2470
- recommendation: "Add :key with a stable, unique identifier from the iterated item."
2471
- });
2472
- });
2473
- return { diagnostics };
2474
- }
2475
- //#endregion
2476
- //#region src/template/rules/v-if-v-for-precedence.ts
2477
- function check$14(ctx) {
2478
- const diagnostics = [];
2479
- walkElements(ctx.template, (el) => {
2480
- const vIf = findDirective(el, "if");
2481
- const vFor = findDirective(el, "for");
2482
- if (!vIf || !vFor) return;
2483
- diagnostics.push({
2484
- file: ctx.file,
2485
- line: el.loc.start.line,
2486
- column: el.loc.start.column,
2487
- endLine: el.loc.end.line,
2488
- endColumn: el.loc.end.column,
2489
- ruleId: "vue-doctor/template/v-if-v-for-precedence",
2490
- severity: "error",
2491
- message: `<${el.tag}> uses v-if and v-for on the same element. In Vue 3, v-if binds tighter than v-for, so the condition cannot reference loop variables.`,
2492
- source: "template",
2493
- recommendation: "Move the v-if to a <template v-for> wrapper or to a parent element, or filter the iterated source in a computed."
2494
- });
2495
- });
2496
- return { diagnostics };
2497
- }
2498
- //#endregion
2499
- //#region src/template/rules/v-memo-on-large-list.ts
2500
- const ARRAY_LITERAL_THRESHOLD = 100;
2501
- function findLargeArrayBindings(script) {
2502
- const bindings = /* @__PURE__ */ new Map();
2503
- try {
2504
- const ast = babelParse(script, { sourceType: "module" });
2505
- for (const node of ast.program.body) {
2506
- if (node.type !== "VariableDeclaration") continue;
2507
- if (node.kind !== "const" && node.kind !== "let") continue;
2508
- for (const decl of node.declarations) {
2509
- if (decl.id.type !== "Identifier") continue;
2510
- const init = decl.init;
2511
- if (!init || init.type !== "ArrayExpression") continue;
2512
- if (!init.elements || init.elements.length <= ARRAY_LITERAL_THRESHOLD) continue;
2513
- bindings.set(decl.id.name, init.elements.length);
2514
- }
2515
- }
2516
- } catch {}
2517
- return bindings;
2518
- }
2519
- function check$13(ctx) {
2520
- const diagnostics = [];
2521
- const largeArrays = ctx.script && ctx.script.length > 0 ? findLargeArrayBindings(ctx.script) : /* @__PURE__ */ new Map();
2522
- walkElements(ctx.template, (el) => {
2523
- const vFor = findDirective(el, "for");
2524
- if (!vFor) return;
2525
- if (findDirective(el, "memo")) return;
2526
- const source = vFor.forParseResult?.source;
2527
- if (!source) return;
2528
- const sourceName = source.content;
2529
- const largeArraySize = largeArrays.get(sourceName);
2530
- if (largeArraySize !== void 0 && largeArraySize > ARRAY_LITERAL_THRESHOLD) diagnostics.push({
2531
- file: ctx.file,
2532
- line: el.loc.start.line,
2533
- column: el.loc.start.column,
2534
- endLine: el.loc.end.line,
2535
- endColumn: el.loc.end.column,
2536
- ruleId: "vue-doctor/template/v-memo-on-large-list",
2537
- severity: "warn",
2538
- message: `<${el.tag}> uses v-for over a large dataset (array literal with ${largeArraySize} items) without v-memo. Vue cannot skip diffing this list on every render.`,
2539
- source: "template",
2540
- recommendation: "Add v-memo with a meaningful memoization key so Vue can skip re-rendering unchanged items."
2541
- });
2542
- });
2543
- return { diagnostics };
2544
- }
2545
- //#endregion
2546
- //#region src/template/rules/no-inline-object-prop-in-list.ts
2547
- const NODE_DIRECTIVE$3 = 7;
2548
- function checkElement$2(el, inVForSubtree, ctx, diagnostics) {
2549
- const isVFor = el.props.some((p) => p.type === NODE_DIRECTIVE$3 && p.name === "for");
2550
- const effectiveInVFor = inVForSubtree || isVFor;
2551
- if (effectiveInVFor) for (const prop of el.props) {
2552
- if (prop.type !== NODE_DIRECTIVE$3) continue;
2553
- const dir = prop;
2554
- if (dir.name !== "bind") continue;
2555
- const attrName = dir.arg?.content;
2556
- if (!attrName || attrName === "key") continue;
2557
- const astType = dir.exp?.ast?.type;
2558
- if (astType !== "ObjectExpression" && astType !== "ArrayExpression") continue;
2559
- diagnostics.push({
2560
- file: ctx.file,
2561
- line: dir.loc.start.line,
2562
- column: dir.loc.start.column,
2563
- endLine: dir.loc.end.line,
2564
- endColumn: dir.loc.end.column,
2565
- ruleId: "vue-doctor/template/no-inline-object-prop-in-list",
2566
- severity: "warn",
2567
- message: `<${el.tag}> has an inline ${attrName} prop with an object or array literal inside a v-for subtree. Inline objects and arrays create new references on every render, defeating v-for's ability to reuse DOM nodes.`,
2568
- source: "template",
2569
- recommendation: "Move the object or array to a computed property or a module-level constant so the reference remains stable across renders."
2570
- });
2571
- }
2572
- for (const child of el.children) if (child.type === 1) checkElement$2(child, effectiveInVFor, ctx, diagnostics);
2573
- }
2574
- function check$12(ctx) {
2575
- const diagnostics = [];
2576
- function walk(node, inVForSubtree) {
2577
- if (node.type === 1) checkElement$2(node, inVForSubtree, ctx, diagnostics);
2578
- else if (node.type === 0) {
2579
- const root = node;
2580
- for (const child of root.children) walk(child, inVForSubtree);
2581
- }
2582
- }
2583
- walk(ctx.template, false);
2584
- return { diagnostics };
2585
- }
2586
- //#endregion
2587
- //#region src/template/rules/no-computed-getter-in-template-loop.ts
2588
- const NODE_ELEMENT$1 = 1;
2589
- const NODE_INTERPOLATION = 5;
2590
- const NODE_DIRECTIVE$2 = 7;
2591
- function isValueDeref(node) {
2592
- if (node.type !== "MemberExpression" || node.computed === true) return false;
2593
- const { object, property } = node;
2594
- return object.type === "Identifier" && property.name === "value";
2595
- }
2596
- function containsValueDeref(value) {
2597
- if (value === null || typeof value !== "object") return false;
2598
- if (Array.isArray(value)) return value.some((item) => containsValueDeref(item));
2599
- const node = value;
2600
- return isValueDeref(node) || Object.values(node).some((child) => containsValueDeref(child));
2601
- }
2602
- function pushDiagnostic(el, loc, ctx, diagnostics) {
2603
- diagnostics.push({
2604
- file: ctx.file,
2605
- line: loc.start.line,
2606
- column: loc.start.column,
2607
- endLine: loc.end.line,
2608
- endColumn: loc.end.column,
2609
- ruleId: "vue-doctor/template/no-computed-getter-in-template-loop",
2108
+ category: "ai-slop",
2109
+ source: "doctor",
2110
+ recommended: true
2111
+ },
2112
+ {
2113
+ id: "vue-doctor/no-imports-from-vue-when-auto-imported",
2610
2114
  severity: "warn",
2611
- message: `<${el.tag}> reads a ref or computed via .value inside a v-for subtree. The getter re-runs on every item and every render, which is easy to hoist out of the loop.`,
2612
- source: "template",
2613
- recommendation: "Read .value once into a computed property or a local binding outside the loop so the getter runs a single time per render."
2614
- });
2615
- }
2616
- function checkElement$1(el, inVForSubtree, ctx, diagnostics) {
2617
- const isVFor = el.props.some((p) => p.type === NODE_DIRECTIVE$2 && p.name === "for");
2618
- const effectiveInVFor = inVForSubtree || isVFor;
2619
- if (effectiveInVFor) {
2620
- for (const prop of el.props) {
2621
- if (prop.type !== NODE_DIRECTIVE$2) continue;
2622
- const dir = prop;
2623
- if (dir.name !== "bind") continue;
2624
- if (containsValueDeref(dir.exp?.ast)) pushDiagnostic(el, dir.loc, ctx, diagnostics);
2625
- }
2626
- for (const child of el.children) if (child.type === NODE_INTERPOLATION) {
2627
- const interp = child;
2628
- if (containsValueDeref(interp.content.ast)) pushDiagnostic(el, interp.loc, ctx, diagnostics);
2629
- }
2630
- }
2631
- for (const child of el.children) if (child.type === NODE_ELEMENT$1) checkElement$1(child, effectiveInVFor, ctx, diagnostics);
2632
- }
2633
- function check$11(ctx) {
2634
- const diagnostics = [];
2635
- function walk(node, inVForSubtree) {
2636
- if (node.type === NODE_ELEMENT$1) checkElement$1(node, inVForSubtree, ctx, diagnostics);
2637
- else if (node.type === 0) {
2638
- const root = node;
2639
- for (const child of root.children) walk(child, inVForSubtree);
2640
- }
2641
- }
2642
- walk(ctx.template, false);
2643
- return { diagnostics };
2644
- }
2645
- //#endregion
2646
- //#region src/template/rules/avoid-deep-v-bind-spread-in-list.ts
2647
- const NODE_ELEMENT = 1;
2648
- const NODE_DIRECTIVE$1 = 7;
2649
- function isIdentifierSpread(dir) {
2650
- return dir.name === "bind" && dir.arg === void 0 && dir.exp?.ast === null;
2651
- }
2652
- function checkElement(el, inVForSubtree, ctx, diagnostics) {
2653
- const isVFor = el.props.some((p) => p.type === NODE_DIRECTIVE$1 && p.name === "for");
2654
- const effectiveInVFor = inVForSubtree || isVFor;
2655
- if (effectiveInVFor) for (const prop of el.props) {
2656
- if (prop.type !== NODE_DIRECTIVE$1) continue;
2657
- const dir = prop;
2658
- if (!isIdentifierSpread(dir)) continue;
2659
- diagnostics.push({
2660
- file: ctx.file,
2661
- line: dir.loc.start.line,
2662
- column: dir.loc.start.column,
2663
- endLine: dir.loc.end.line,
2664
- endColumn: dir.loc.end.column,
2665
- ruleId: "vue-doctor/template/avoid-deep-v-bind-spread-in-list",
2666
- severity: "info",
2667
- message: `<${el.tag}> spreads a whole object via v-bind inside a v-for subtree. Spreading a reactive object binds every property per item, which can churn props and defeat patching on large lists.`,
2668
- source: "template",
2669
- recommendation: "Bind only the props each item needs explicitly, or hoist a stable per-item object so Vue can patch instead of re-binding the full spread."
2670
- });
2671
- }
2672
- for (const child of el.children) if (child.type === NODE_ELEMENT) checkElement(child, effectiveInVFor, ctx, diagnostics);
2673
- }
2674
- function check$10(ctx) {
2675
- const diagnostics = [];
2676
- function walk(node, inVForSubtree) {
2677
- if (node.type === NODE_ELEMENT) checkElement(node, inVForSubtree, ctx, diagnostics);
2678
- else if (node.type === 0) {
2679
- const root = node;
2680
- for (const child of root.children) walk(child, inVForSubtree);
2681
- }
2682
- }
2683
- walk(ctx.template, false);
2684
- return { diagnostics };
2685
- }
2686
- //#endregion
2687
- //#region src/template/rules/no-v-html.ts
2688
- function check$9(ctx) {
2689
- const diagnostics = [];
2690
- walkElements(ctx.template, (el) => {
2691
- const vHtml = findDirective(el, "html");
2692
- if (!vHtml) return;
2693
- diagnostics.push({
2694
- file: ctx.file,
2695
- line: vHtml.loc.start.line,
2696
- column: vHtml.loc.start.column,
2697
- endLine: vHtml.loc.end.line,
2698
- endColumn: vHtml.loc.end.column,
2699
- ruleId: "vue-doctor/security/no-v-html",
2700
- severity: "error",
2701
- message: `<${el.tag}> uses v-html, which renders raw HTML and bypasses Vue's escaping — the primary XSS sink in Vue apps.`,
2702
- source: "template",
2703
- recommendation: "Render text with {{ }} interpolation, or sanitize the HTML with DOMPurify in a computed before binding it."
2704
- });
2705
- });
2706
- return { diagnostics };
2707
- }
2708
- //#endregion
2709
- //#region src/template/rules/no-target-blank-without-rel.ts
2710
- function unquote$1(raw) {
2711
- const trimmed = raw.trim();
2712
- const first = trimmed.charAt(0);
2713
- if ((first === "\"" || first === "'") && trimmed.endsWith(first)) return trimmed.slice(1, -1);
2714
- return trimmed;
2715
- }
2716
- function attrValue(el, name) {
2717
- const staticAttr = findStaticAttr(el, name);
2718
- if (staticAttr) return {
2719
- value: staticAttr.value?.content ?? "",
2720
- loc: staticAttr.loc
2721
- };
2722
- const bound = findBindAttr(el, name);
2723
- if (bound?.exp && "content" in bound.exp) return {
2724
- value: unquote$1(bound.exp.content),
2725
- loc: bound.loc
2726
- };
2727
- }
2728
- function check$8(ctx) {
2729
- const diagnostics = [];
2730
- walkElements(ctx.template, (el) => {
2731
- if (el.tag !== "a") return;
2732
- const target = attrValue(el, "target");
2733
- if (!target || target.value !== "_blank") return;
2734
- const rel = attrValue(el, "rel");
2735
- if (rel && rel.value.includes("noopener")) return;
2736
- diagnostics.push({
2737
- file: ctx.file,
2738
- line: target.loc.start.line,
2739
- column: target.loc.start.column,
2740
- endLine: target.loc.end.line,
2741
- endColumn: target.loc.end.column,
2742
- ruleId: "vue-doctor/security/no-target-blank-without-rel",
2743
- severity: "warn",
2744
- message: `<a target="_blank"> without rel="noopener noreferrer" exposes the opener window to reverse-tabnabbing.`,
2745
- source: "template",
2746
- recommendation: "Add rel=\"noopener noreferrer\" to anchors that open in a new tab."
2747
- });
2748
- });
2749
- return { diagnostics };
2750
- }
2751
- //#endregion
2752
- //#region src/template/rules/no-javascript-uri.ts
2753
- const NODE_ATTRIBUTE$1 = 6;
2754
- const URI_SINKS = new Set([
2755
- "href",
2756
- "src",
2757
- "srcset",
2758
- "action",
2759
- "xlink:href",
2760
- "formaction",
2761
- "poster",
2762
- "data"
2763
- ]);
2764
- const DANGEROUS_SCHEME = /^\s*(?:javascript:|vbscript:|data:text\/html)/i;
2765
- function unquote(raw) {
2766
- const trimmed = raw.trim();
2767
- const first = trimmed.charAt(0);
2768
- if ((first === "\"" || first === "'" || first === "`") && trimmed.endsWith(first)) return trimmed.slice(1, -1);
2769
- return trimmed;
2770
- }
2771
- function resolveUriProp(prop) {
2772
- if (prop.type === NODE_ATTRIBUTE$1) {
2773
- const attr = prop;
2774
- if (attr.value === void 0) return void 0;
2775
- return {
2776
- name: attr.name,
2777
- value: attr.value.content,
2778
- loc: attr.loc
2779
- };
2780
- }
2781
- const dir = prop;
2782
- if (dir.name !== "bind") return void 0;
2783
- if (!dir.arg || !("content" in dir.arg)) return void 0;
2784
- if (!dir.exp || !("content" in dir.exp)) return void 0;
2785
- return {
2786
- name: dir.arg.content,
2787
- value: unquote(dir.exp.content),
2788
- loc: dir.loc
2789
- };
2790
- }
2791
- function check$7(ctx) {
2792
- const diagnostics = [];
2793
- walkElements(ctx.template, (el) => {
2794
- for (const prop of el.props) {
2795
- const resolved = resolveUriProp(prop);
2796
- if (!resolved) continue;
2797
- if (!URI_SINKS.has(resolved.name)) continue;
2798
- if (!DANGEROUS_SCHEME.test(resolved.value)) continue;
2799
- diagnostics.push({
2800
- file: ctx.file,
2801
- line: resolved.loc.start.line,
2802
- column: resolved.loc.start.column,
2803
- endLine: resolved.loc.end.line,
2804
- endColumn: resolved.loc.end.column,
2805
- ruleId: "vue-doctor/security/no-javascript-uri",
2806
- severity: "error",
2807
- message: `<${el.tag}> binds ${resolved.name} to a dangerous URI scheme (javascript:/vbscript:/data:text/html), which executes arbitrary code.`,
2808
- source: "template",
2809
- recommendation: "Use a safe http(s) or relative URL; never put a javascript:, vbscript:, or data:text/html value in a URL attribute."
2810
- });
2811
- }
2812
- });
2813
- return { diagnostics };
2814
- }
2815
- //#endregion
2816
- //#region src/template/class-attr-helpers.ts
2817
- const NODE_ATTRIBUTE = 6;
2818
- const NODE_DIRECTIVE = 7;
2819
- /**
2820
- * Collect the scannable text of every `class` source on an element: the static
2821
- * `class="..."` value and any bound `:class` expression source. Bound
2822
- * expressions are returned verbatim so a regex can match string-literal class
2823
- * fragments inside ternaries, arrays, and objects without a full JS parse.
2824
- */
2825
- function collectClassSources(el) {
2826
- const sources = [];
2827
- for (const prop of el.props) {
2828
- if (prop.type === NODE_ATTRIBUTE) {
2829
- const attr = prop;
2830
- if (attr.name === "class" && attr.value) sources.push({
2831
- text: attr.value.content,
2832
- loc: attr.loc
2833
- });
2834
- }
2835
- if (prop.type === NODE_DIRECTIVE) {
2836
- const dir = prop;
2837
- if (dir.name === "bind" && dir.arg && "content" in dir.arg && dir.arg.content === "class" && dir.exp && "content" in dir.exp) sources.push({
2838
- text: dir.exp.content,
2839
- loc: dir.loc
2840
- });
2841
- }
2842
- }
2843
- return sources;
2844
- }
2845
- /** Find a static `style="..."` attribute on an element, if present. */
2846
- function findStaticStyle(el) {
2847
- for (const prop of el.props) if (prop.type === NODE_ATTRIBUTE && prop.name === "style") return prop;
2848
- }
2849
- //#endregion
2850
- //#region src/template/rules/no-arbitrary-tailwind-values.ts
2851
- const ARBITRARY_VALUE = /\[[^\]]+\]/;
2852
- function check$6(ctx) {
2853
- const diagnostics = [];
2854
- walkElements(ctx.template, (el) => {
2855
- for (const source of collectClassSources(el)) {
2856
- if (!ARBITRARY_VALUE.test(source.text)) continue;
2857
- diagnostics.push({
2858
- file: ctx.file,
2859
- line: source.loc.start.line,
2860
- column: source.loc.start.column,
2861
- endLine: source.loc.end.line,
2862
- endColumn: source.loc.end.column,
2863
- ruleId: "vue-doctor/design/no-arbitrary-tailwind-values",
2864
- severity: "warn",
2865
- message: `<${el.tag}> uses a Tailwind arbitrary value (e.g. w-[412px]). Arbitrary values are design-system debt; repeated values should become @theme tokens.`,
2866
- source: "template",
2867
- recommendation: "Replace the arbitrary value with a design token defined in your @theme (e.g. w-container-lg)."
2868
- });
2869
- }
2870
- });
2871
- return { diagnostics };
2872
- }
2873
- //#endregion
2874
- //#region src/template/rules/no-raw-hex-color.ts
2875
- const HEX_COLOR = /#(?:[0-9a-fA-F]{3,4}|[0-9a-fA-F]{6}|[0-9a-fA-F]{8})\b/;
2876
- const RULE_ID$1 = "vue-doctor/design/no-raw-hex-color";
2877
- const MESSAGE$1 = "Raw hex color bypasses the token system and breaks theming. Use a semantic color token instead.";
2878
- const RECOMMENDATION$1 = "Replace the hex color with a design token (e.g. bg-danger / text-success) defined in your @theme.";
2879
- function check$5(ctx) {
2880
- const diagnostics = [];
2881
- walkElements(ctx.template, (el) => {
2882
- for (const source of collectClassSources(el)) {
2883
- if (!HEX_COLOR.test(source.text)) continue;
2884
- diagnostics.push({
2885
- file: ctx.file,
2886
- line: source.loc.start.line,
2887
- column: source.loc.start.column,
2888
- endLine: source.loc.end.line,
2889
- endColumn: source.loc.end.column,
2890
- ruleId: RULE_ID$1,
2891
- severity: "warn",
2892
- message: `<${el.tag}> embeds a raw hex color in a class. ${MESSAGE$1}`,
2893
- source: "template",
2894
- recommendation: RECOMMENDATION$1
2895
- });
2896
- }
2897
- const style = findStaticStyle(el);
2898
- if (style?.value && HEX_COLOR.test(style.value.content)) diagnostics.push({
2899
- file: ctx.file,
2900
- line: style.loc.start.line,
2901
- column: style.loc.start.column,
2902
- endLine: style.loc.end.line,
2903
- endColumn: style.loc.end.column,
2904
- ruleId: RULE_ID$1,
2905
- severity: "warn",
2906
- message: `<${el.tag}> embeds a raw hex color in an inline style. ${MESSAGE$1}`,
2907
- source: "template",
2908
- recommendation: RECOMMENDATION$1
2909
- });
2910
- });
2911
- return { diagnostics };
2912
- }
2913
- //#endregion
2914
- //#region src/template/rules/no-default-tailwind-palette.ts
2915
- const DEFAULT_PALETTE = /(?:^|[\s:'"`])(?:bg|text|border|ring|shadow|from|via|to|accent|caret|fill|stroke|outline|decoration|divide|ring-offset|placeholder)-(?:slate|gray|zinc|neutral|stone|red|orange|amber|yellow|lime|green|emerald|teal|cyan|sky|blue|indigo|violet|purple|fuchsia|pink|rose)-\d{2,3}\b/;
2916
- function check$4(ctx) {
2917
- const diagnostics = [];
2918
- walkElements(ctx.template, (el) => {
2919
- for (const source of collectClassSources(el)) {
2920
- if (!DEFAULT_PALETTE.test(source.text)) continue;
2921
- diagnostics.push({
2922
- file: ctx.file,
2923
- line: source.loc.start.line,
2924
- column: source.loc.start.column,
2925
- endLine: source.loc.end.line,
2926
- endColumn: source.loc.end.column,
2927
- ruleId: "vue-doctor/design/no-default-tailwind-palette",
2928
- severity: "warn",
2929
- message: `<${el.tag}> uses a default Tailwind palette utility (e.g. bg-blue-600). The default palette produces generic UI; map colors to brand tokens.`,
2930
- source: "template",
2931
- recommendation: "Map the color to a brand token (e.g. bg-primary-600) defined in your @theme instead of the default palette."
2932
- });
2933
- }
2934
- });
2935
- return { diagnostics };
2936
- }
2937
- //#endregion
2938
- //#region src/template/rules/no-important-utility.ts
2939
- const IMPORTANT_UTILITY = /(?:^|[\s:'"`])!-?[a-z][a-z0-9-]*/;
2940
- function check$3(ctx) {
2941
- const diagnostics = [];
2942
- walkElements(ctx.template, (el) => {
2943
- for (const source of collectClassSources(el)) {
2944
- if (!IMPORTANT_UTILITY.test(source.text)) continue;
2945
- diagnostics.push({
2946
- file: ctx.file,
2947
- line: source.loc.start.line,
2948
- column: source.loc.start.column,
2949
- endLine: source.loc.end.line,
2950
- endColumn: source.loc.end.column,
2951
- ruleId: "vue-doctor/design/no-important-utility",
2952
- severity: "warn",
2953
- message: `<${el.tag}> uses a !important Tailwind utility (e.g. !bg-red-500). !important is a specificity escape hatch that makes maintenance harder.`,
2954
- source: "template",
2955
- recommendation: "Remove the ! prefix and resolve the specificity conflict at its source instead of forcing !important."
2956
- });
2957
- }
2958
- });
2959
- return { diagnostics };
2960
- }
2961
- //#endregion
2962
- //#region src/template/rules/no-hardcoded-inline-style.ts
2963
- const HARDCODED_VALUE = /\d+px\b|#(?:[0-9a-fA-F]{3,4}|[0-9a-fA-F]{6}|[0-9a-fA-F]{8})\b/;
2964
- function check$2(ctx) {
2965
- const diagnostics = [];
2966
- walkElements(ctx.template, (el) => {
2967
- const style = findStaticStyle(el);
2968
- if (!style?.value) return;
2969
- if (!HARDCODED_VALUE.test(style.value.content)) return;
2970
- diagnostics.push({
2971
- file: ctx.file,
2972
- line: style.loc.start.line,
2973
- column: style.loc.start.column,
2974
- endLine: style.loc.end.line,
2975
- endColumn: style.loc.end.column,
2976
- ruleId: "vue-doctor/design/no-hardcoded-inline-style",
2977
- severity: "warn",
2978
- message: `<${el.tag}> uses an inline style with hardcoded px or hex values. Inline styles bypass tokens, hurt caching, and create drift.`,
2979
- source: "template",
2980
- recommendation: "Move the styling to token-based utility classes (e.g. w-60 text-neutral-700) instead of an inline style."
2981
- });
2982
- });
2983
- return { diagnostics };
2984
- }
2985
- //#endregion
2986
- //#region src/template/rules/no-missing-alt.ts
2987
- const IMAGE_TAGS = new Set([
2988
- "img",
2989
- "NuxtImg",
2990
- "nuxt-img"
2991
- ]);
2992
- function check$1(ctx) {
2993
- const diagnostics = [];
2994
- walkElements(ctx.template, (el) => {
2995
- if (!IMAGE_TAGS.has(el.tag)) return;
2996
- if (findStaticAttr(el, "alt") ?? findBindAttr(el, "alt")) return;
2997
- diagnostics.push({
2998
- file: ctx.file,
2999
- line: el.loc.start.line,
3000
- column: el.loc.start.column,
3001
- endLine: el.loc.end.line,
3002
- endColumn: el.loc.end.column,
3003
- ruleId: "vue-doctor/design/no-missing-alt",
3004
- severity: "warn",
3005
- message: `<${el.tag}> has no alt attribute. Screen readers and SEO depend on descriptive alt text.`,
3006
- source: "template",
3007
- recommendation: "Add an alt attribute describing the image, or alt=\"\" for purely decorative images."
3008
- });
3009
- });
3010
- return { diagnostics };
3011
- }
3012
- //#endregion
3013
- //#region src/template/rules/no-absurd-z-index.ts
3014
- const ABSURD_THRESHOLD = 1e3;
3015
- const ARBITRARY_Z = /(?:^|[\s:'"`])-?z-\[(\d+)\]/;
3016
- const INLINE_Z = /z-index\s*:\s*(\d+)/;
3017
- const RULE_ID = "vue-doctor/design/no-absurd-z-index";
3018
- const MESSAGE = "huge z-index values usually paper over layering bugs instead of fixing stacking context.";
3019
- const RECOMMENDATION = "Use a small, tokenized z-index scale (e.g. z-modal) and fix the underlying stacking context.";
3020
- function check(ctx) {
3021
- const diagnostics = [];
3022
- walkElements(ctx.template, (el) => {
3023
- for (const source of collectClassSources(el)) {
3024
- const match = ARBITRARY_Z.exec(source.text);
3025
- if (!match || Number(match[1]) < ABSURD_THRESHOLD) continue;
3026
- diagnostics.push({
3027
- file: ctx.file,
3028
- line: source.loc.start.line,
3029
- column: source.loc.start.column,
3030
- endLine: source.loc.end.line,
3031
- endColumn: source.loc.end.column,
3032
- ruleId: RULE_ID,
3033
- severity: "warn",
3034
- message: `<${el.tag}> uses an absurd z-index class (>= ${ABSURD_THRESHOLD}); ${MESSAGE}`,
3035
- source: "template",
3036
- recommendation: RECOMMENDATION
3037
- });
3038
- }
3039
- const style = findStaticStyle(el);
3040
- if (style?.value) {
3041
- const match = INLINE_Z.exec(style.value.content);
3042
- if (match && Number(match[1]) >= ABSURD_THRESHOLD) diagnostics.push({
3043
- file: ctx.file,
3044
- line: style.loc.start.line,
3045
- column: style.loc.start.column,
3046
- endLine: style.loc.end.line,
3047
- endColumn: style.loc.end.column,
3048
- ruleId: RULE_ID,
3049
- severity: "warn",
3050
- message: `<${el.tag}> uses an absurd inline z-index (>= ${ABSURD_THRESHOLD}); ${MESSAGE}`,
3051
- source: "template",
3052
- recommendation: RECOMMENDATION
3053
- });
3054
- }
3055
- });
3056
- return { diagnostics };
3057
- }
3058
- //#endregion
3059
- //#region src/template/rules/index.ts
3060
- const TEMPLATE_RULES = [
3061
- {
3062
- id: "vue-doctor/template/v-for-has-key",
3063
- check: check$15
3064
- },
3065
- {
3066
- id: "vue-doctor/template/v-if-v-for-precedence",
3067
- check: check$14
3068
- },
3069
- {
3070
- id: "vue-doctor/template/v-memo-on-large-list",
3071
- check: check$13
3072
- },
3073
- {
3074
- id: "vue-doctor/template/no-inline-object-prop-in-list",
3075
- check: check$12
3076
- },
3077
- {
3078
- id: "vue-doctor/template/no-computed-getter-in-template-loop",
3079
- check: check$11
3080
- },
3081
- {
3082
- id: "vue-doctor/template/avoid-deep-v-bind-spread-in-list",
3083
- check: check$10
3084
- },
3085
- {
3086
- id: "vue-doctor/security/no-v-html",
3087
- check: check$9
3088
- },
3089
- {
3090
- id: "vue-doctor/security/no-target-blank-without-rel",
3091
- check: check$8
3092
- },
3093
- {
3094
- id: "vue-doctor/security/no-javascript-uri",
3095
- check: check$7
3096
- },
3097
- {
3098
- id: "vue-doctor/design/no-arbitrary-tailwind-values",
3099
- check: check$6
3100
- },
3101
- {
3102
- id: "vue-doctor/design/no-raw-hex-color",
3103
- check: check$5
3104
- },
3105
- {
3106
- id: "vue-doctor/design/no-default-tailwind-palette",
3107
- check: check$4
3108
- },
3109
- {
3110
- id: "vue-doctor/design/no-important-utility",
3111
- check: check$3
3112
- },
3113
- {
3114
- id: "vue-doctor/design/no-hardcoded-inline-style",
3115
- check: check$2
3116
- },
3117
- {
3118
- id: "vue-doctor/design/no-missing-alt",
3119
- check: check$1
3120
- },
3121
- {
3122
- id: "vue-doctor/design/no-absurd-z-index",
3123
- check
3124
- }
3125
- ];
3126
- //#endregion
3127
- //#region src/template/run.ts
3128
- async function runTemplatePass(opts) {
3129
- const all = [];
3130
- for (const file of opts.files) {
3131
- if (!file.endsWith(".vue")) continue;
3132
- const descriptor = await parseSfc(file);
3133
- if (!descriptor?.template?.ast) continue;
3134
- for (const rule of TEMPLATE_RULES) {
3135
- const override = opts.ruleOverrides?.[rule.id];
3136
- if (override === "off") continue;
3137
- const { diagnostics } = rule.check({
3138
- file,
3139
- template: descriptor.template.ast,
3140
- script: descriptor.scriptSetup?.content ?? descriptor.script?.content
3141
- });
3142
- for (const d of diagnostics) all.push(override ? {
3143
- ...d,
3144
- severity: override
3145
- } : d);
3146
- }
3147
- }
3148
- return all;
3149
- }
3150
- //#endregion
3151
- //#region src/audit.ts
3152
- const DEFAULT_INCLUDE = [
3153
- "**/*.vue",
3154
- "**/*.ts",
3155
- "**/*.tsx",
3156
- "**/*.js",
3157
- "**/*.jsx"
3158
- ];
3159
- const DEFAULT_EXCLUDE = [
3160
- "node_modules",
3161
- "dist",
3162
- ".nuxt",
3163
- ".output",
3164
- "coverage"
3165
- ];
3166
- function countRuleCounts(diagnostics) {
3167
- const counts = {};
3168
- for (const d of diagnostics) counts[d.ruleId] = (counts[d.ruleId] ?? 0) + 1;
3169
- return counts;
3170
- }
3171
- async function audit(config = {}) {
3172
- const rootDir = resolve(config.rootDir ?? process.cwd());
3173
- const include = config.include ?? DEFAULT_INCLUDE;
3174
- const exclude = config.exclude ?? DEFAULT_EXCLUDE;
3175
- const failOn = config.failOn ?? "error";
3176
- const threshold = config.threshold ?? 0;
3177
- const lintEnabled = config.lint !== false;
3178
- const files = await listSourceFiles({
3179
- rootDir,
3180
- include,
3181
- exclude
3182
- });
3183
- const overallStart = performance.now();
3184
- const project = await detectProject(rootDir);
3185
- const templateStart = performance.now();
3186
- const templateDiagnostics = lintEnabled ? await runTemplatePass({
3187
- files,
3188
- ruleOverrides: config.rules
3189
- }) : [];
3190
- const templateElapsed = performance.now() - templateStart;
3191
- const sfcStart = performance.now();
3192
- const sfcDiagnostics = lintEnabled ? await runSfcPass({
3193
- files,
3194
- ruleOverrides: config.rules,
3195
- projectInfo: project
3196
- }) : [];
3197
- const sfcElapsed = performance.now() - sfcStart;
3198
- let scriptDiagnostics = [];
3199
- let oxlintStderr = "";
3200
- const scriptStart = performance.now();
3201
- if (lintEnabled) try {
3202
- const result = await runScriptPass({
3203
- rootDir,
3204
- targetPath: rootDir,
3205
- ruleOverrides: config.rules,
3206
- framework: project.framework === "nuxt" ? "nuxt" : "vue",
3207
- fix: config.fix === true && config.scopeFiles === void 0,
3208
- fixExcludes: config.fixExcludes
3209
- });
3210
- scriptDiagnostics = result.diagnostics;
3211
- oxlintStderr = result.stderr;
3212
- } catch (err) {
3213
- oxlintStderr = err instanceof Error ? err.message : String(err);
3214
- if (process.env.DOCTOR_DEBUG) process.stderr.write(`[doctor-core] script pass failed: ${oxlintStderr}\n`);
3215
- }
3216
- const scriptElapsed = performance.now() - scriptStart;
3217
- let deadCodeDiagnostics = [];
3218
- let deadCodeElapsed = 0;
3219
- if (config.deadCode !== false) {
3220
- const deadCodeStart = performance.now();
3221
- try {
3222
- const { loadDoctorConfig } = await Promise.resolve().then(() => load_exports);
3223
- deadCodeDiagnostics = dedupeDeadCodeAgainstLint(await checkDeadCode({
3224
- projectInfo: project,
3225
- doctorConfig: await loadDoctorConfig(rootDir),
3226
- enabled: true
3227
- }), [
3228
- ...templateDiagnostics,
3229
- ...sfcDiagnostics,
3230
- ...scriptDiagnostics
3231
- ]).filter((d) => config.rules?.[d.ruleId] !== "off");
3232
- } catch (err) {
3233
- if (process.env.DOCTOR_DEBUG) process.stderr.write(`[doctor-core] dead-code pass failed: ${String(err)}\n`);
3234
- }
3235
- deadCodeElapsed = performance.now() - deadCodeStart;
3236
- }
3237
- let buildQualityDiagnostics = [];
3238
- try {
3239
- buildQualityDiagnostics = (await checkBuildQuality(project)).filter((d) => config.rules?.[d.ruleId] !== "off").map((d) => {
3240
- const override = config.rules?.[d.ruleId];
3241
- return override ? {
3242
- ...d,
3243
- severity: override
3244
- } : d;
3245
- });
3246
- } catch {}
3247
- let depsDiagnostics = [];
3248
- try {
3249
- depsDiagnostics = (await checkDeps(project)).filter((d) => config.rules?.[d.ruleId] !== "off").map((d) => {
3250
- const override = config.rules?.[d.ruleId];
3251
- return override ? {
3252
- ...d,
3253
- severity: override
3254
- } : d;
3255
- });
3256
- } catch {}
3257
- let nuxtProjectDiagnostics = [];
3258
- if (project.framework === "nuxt") try {
3259
- nuxtProjectDiagnostics = (await checkNuxtProject(project)).filter((d) => config.rules?.[d.ruleId] !== "off").map((d) => {
3260
- const override = config.rules?.[d.ruleId];
3261
- return override ? {
3262
- ...d,
3263
- severity: override
3264
- } : d;
3265
- });
3266
- } catch {}
3267
- let crossFileDiagnostics = [];
3268
- if (project.framework === "nuxt") try {
3269
- crossFileDiagnostics = (await runCrossFilePass({
3270
- files,
3271
- projectInfo: project
3272
- })).filter((d) => config.rules?.[d.ruleId] !== "off").map((d) => {
3273
- const override = config.rules?.[d.ruleId];
3274
- return override ? {
3275
- ...d,
3276
- severity: override
3277
- } : d;
3278
- });
3279
- } catch {}
3280
- const elapsedMs = performance.now() - overallStart;
3281
- const timings = {
3282
- template: templateElapsed,
3283
- sfc: sfcElapsed,
3284
- script: scriptElapsed,
3285
- deadCode: deadCodeElapsed,
3286
- total: elapsedMs
3287
- };
3288
- let afterDisables = applyInlineDisables(mergeDiagnostics(templateDiagnostics, sfcDiagnostics, scriptDiagnostics, deadCodeDiagnostics, buildQualityDiagnostics, depsDiagnostics, nuxtProjectDiagnostics, crossFileDiagnostics), { respect: config.respectInlineDisables !== false });
3289
- if (config.scopeFiles) {
3290
- const scope = new Set(config.scopeFiles.map((f) => resolve(rootDir, f)));
3291
- afterDisables = afterDisables.filter((d) => scope.has(resolve(rootDir, d.file)));
3292
- }
3293
- const diagnostics = await attachCodeSnippets(afterDisables);
3294
- const scored = scoreDiagnostics(diagnostics, { threshold });
3295
- const ruleCounts = countRuleCounts(diagnostics);
3296
- const projectInfo = {
3297
- framework: project.framework,
3298
- vueVersion: project.vueVersion,
3299
- nuxtVersion: project.nuxtVersion,
3300
- capabilities: [...project.capabilities].sort(),
3301
- rootDirectory: project.rootDirectory
3302
- };
3303
- let exitCode = 0;
3304
- if (oxlintStderr && scriptDiagnostics.length === 0 && oxlintStderr.includes("Failed")) exitCode = 2;
3305
- else if (failOn !== "none") {
3306
- if ((failOn === "warn" ? scored.errorCount + scored.warnCount : scored.errorCount) > 0) exitCode = 1;
3307
- }
3308
- return {
3309
- rootDir,
3310
- filesScanned: files.length,
3311
- diagnostics,
3312
- score: scored.score,
3313
- errorCount: scored.errorCount,
3314
- warnCount: scored.warnCount,
3315
- infoCount: scored.infoCount,
3316
- exitCode,
3317
- scoreResult: scored,
3318
- projectInfo,
3319
- elapsedMs,
3320
- timings,
3321
- ruleCounts
3322
- };
3323
- }
3324
- //#endregion
3325
- //#region src/config/built-in.ts
3326
- const BUILT_IN_RECOMMENDED = {
3327
- include: [
3328
- "**/*.vue",
3329
- "**/*.ts",
3330
- "**/*.tsx",
3331
- "**/*.js",
3332
- "**/*.jsx"
3333
- ],
3334
- exclude: [
3335
- "node_modules",
3336
- "dist",
3337
- ".nuxt",
3338
- ".output",
3339
- "coverage"
3340
- ],
3341
- failOn: "error",
3342
- threshold: 0,
3343
- rules: {}
3344
- };
3345
- //#endregion
3346
- //#region src/config/errors.ts
3347
- var ConfigFileNotFoundError = class extends Error {
3348
- name = "ConfigFileNotFoundError";
3349
- constructor(path) {
3350
- super(`Config file not found: ${path}`);
3351
- }
3352
- };
3353
- var ConfigCycleError = class extends Error {
3354
- name = "ConfigCycleError";
3355
- constructor(chain) {
3356
- super(`Config extends cycle detected: ${chain.join(" -> ")}`);
3357
- }
3358
- };
3359
- var InvalidConfigError = class extends Error {
3360
- name = "InvalidConfigError";
3361
- issues;
3362
- constructor(message, issues = []) {
3363
- super(message);
3364
- this.issues = issues;
3365
- }
3366
- };
3367
- //#endregion
3368
- //#region src/config/define-config.ts
3369
- function defineConfig(config) {
3370
- return config;
3371
- }
3372
- //#endregion
3373
- //#region src/rule-registry.ts
3374
- const RULE_REGISTRY = [
2115
+ category: "ai-slop",
2116
+ source: "doctor",
2117
+ recommended: true
2118
+ },
3375
2119
  {
3376
- id: "vue/no-export-in-script-setup",
3377
- severity: "error",
3378
- category: "vue-builtin",
3379
- source: "oxlint-builtin",
2120
+ id: "vue-doctor/reactivity/watch-without-cleanup",
2121
+ severity: "warn",
2122
+ category: "reactivity",
2123
+ source: "doctor",
3380
2124
  recommended: true
3381
2125
  },
3382
2126
  {
3383
- id: "vue/require-typed-ref",
2127
+ id: "vue-doctor/reactivity/prefer-shallowRef-for-large-data",
2128
+ severity: "info",
2129
+ category: "reactivity",
2130
+ source: "doctor",
2131
+ recommended: false
2132
+ },
2133
+ {
2134
+ id: "vue-doctor/reactivity/prefer-readonly-for-injected",
2135
+ severity: "info",
2136
+ category: "reactivity",
2137
+ source: "doctor",
2138
+ recommended: false
2139
+ },
2140
+ {
2141
+ id: "vue-doctor/reactivity/no-fresh-deps-in-watch",
3384
2142
  severity: "warn",
3385
- category: "vue-builtin",
3386
- source: "oxlint-builtin",
2143
+ category: "reactivity",
2144
+ source: "doctor",
3387
2145
  recommended: true
3388
2146
  },
3389
2147
  {
3390
- id: "vue/no-arrow-functions-in-watch",
3391
- severity: "error",
3392
- category: "vue-builtin",
3393
- source: "oxlint-builtin",
2148
+ id: "vue-doctor/composition/prefer-script-setup-for-new-files",
2149
+ severity: "warn",
2150
+ category: "composition",
2151
+ source: "doctor",
3394
2152
  recommended: true
3395
2153
  },
3396
2154
  {
3397
- id: "vue/no-deprecated-data-object-declaration",
3398
- severity: "error",
3399
- category: "vue-builtin",
3400
- source: "oxlint-builtin",
2155
+ id: "vue-doctor/composition/defineProps-typed",
2156
+ severity: "warn",
2157
+ category: "composition",
2158
+ source: "doctor",
3401
2159
  recommended: true
3402
2160
  },
3403
2161
  {
3404
- id: "vue/no-deprecated-events-api",
3405
- severity: "error",
3406
- category: "vue-builtin",
3407
- source: "oxlint-builtin",
2162
+ id: "vue-doctor/composition/no-pinia-store-in-setup",
2163
+ severity: "warn",
2164
+ category: "composition",
2165
+ source: "doctor",
3408
2166
  recommended: true
3409
2167
  },
3410
2168
  {
3411
- id: "vue/no-deprecated-destroyed-lifecycle",
3412
- severity: "error",
3413
- category: "vue-builtin",
3414
- source: "oxlint-builtin",
2169
+ id: "vue-doctor/performance/prefer-defineAsyncComponent-on-route",
2170
+ severity: "info",
2171
+ category: "performance",
2172
+ source: "doctor",
2173
+ recommended: false
2174
+ },
2175
+ {
2176
+ id: "vue-doctor/performance/prefer-module-scope-static-value",
2177
+ severity: "info",
2178
+ category: "performance",
2179
+ source: "doctor",
2180
+ recommended: false
2181
+ },
2182
+ {
2183
+ id: "vue-doctor/performance/prefer-module-scope-pure-function",
2184
+ severity: "info",
2185
+ category: "performance",
2186
+ source: "doctor",
2187
+ recommended: false
2188
+ },
2189
+ {
2190
+ id: "vue-doctor/performance/prefer-stable-empty-fallback",
2191
+ severity: "warn",
2192
+ category: "performance",
2193
+ source: "doctor",
3415
2194
  recommended: true
3416
2195
  },
3417
2196
  {
3418
- id: "vue/no-deprecated-model-definition",
2197
+ id: "vue-doctor/template/v-for-has-key",
3419
2198
  severity: "error",
3420
- category: "vue-builtin",
3421
- source: "oxlint-builtin",
2199
+ category: "template",
2200
+ source: "doctor",
3422
2201
  recommended: true
3423
2202
  },
3424
2203
  {
3425
- id: "vue/no-deprecated-delete-set",
2204
+ id: "vue-doctor/template/v-if-v-for-precedence",
3426
2205
  severity: "error",
3427
- category: "vue-builtin",
3428
- source: "oxlint-builtin",
2206
+ category: "template",
2207
+ source: "doctor",
3429
2208
  recommended: true
3430
2209
  },
3431
2210
  {
3432
- id: "vue/no-deprecated-vue-config-keycodes",
3433
- severity: "error",
3434
- category: "vue-builtin",
3435
- source: "oxlint-builtin",
2211
+ id: "vue-doctor/template/v-memo-on-large-list",
2212
+ severity: "warn",
2213
+ category: "performance",
2214
+ source: "doctor",
3436
2215
  recommended: true
3437
2216
  },
3438
2217
  {
3439
- id: "vue/no-lifecycle-after-await",
3440
- severity: "error",
3441
- category: "vue-builtin",
3442
- source: "oxlint-builtin",
2218
+ id: "vue-doctor/template/no-inline-object-prop-in-list",
2219
+ severity: "warn",
2220
+ category: "performance",
2221
+ source: "doctor",
3443
2222
  recommended: true
3444
2223
  },
3445
2224
  {
3446
- id: "vue/no-this-in-before-route-enter",
3447
- severity: "error",
3448
- category: "vue-builtin",
3449
- source: "oxlint-builtin",
2225
+ id: "vue-doctor/template/no-computed-getter-in-template-loop",
2226
+ severity: "warn",
2227
+ category: "template-perf",
2228
+ source: "doctor",
3450
2229
  recommended: true
3451
2230
  },
3452
2231
  {
3453
- id: "vue/return-in-computed-property",
3454
- severity: "error",
3455
- category: "vue-builtin",
3456
- source: "oxlint-builtin",
2232
+ id: "vue-doctor/template/avoid-deep-v-bind-spread-in-list",
2233
+ severity: "info",
2234
+ category: "template-perf",
2235
+ source: "doctor",
3457
2236
  recommended: true
3458
2237
  },
3459
2238
  {
3460
- id: "vue/valid-define-emits",
3461
- severity: "error",
3462
- category: "vue-builtin",
3463
- source: "oxlint-builtin",
2239
+ id: "vue-doctor/template/no-random-key",
2240
+ severity: "warn",
2241
+ category: "performance",
2242
+ source: "doctor",
3464
2243
  recommended: true
3465
2244
  },
3466
2245
  {
3467
- id: "vue/valid-define-props",
2246
+ id: "vue-doctor/template/no-v-memo-in-vapor",
2247
+ severity: "warn",
2248
+ category: "template-perf",
2249
+ source: "doctor",
2250
+ recommended: true
2251
+ },
2252
+ {
2253
+ id: "vue-doctor/sfc/no-mixed-options-and-composition-api",
2254
+ severity: "warn",
2255
+ category: "sfc",
2256
+ source: "doctor",
2257
+ recommended: true
2258
+ },
2259
+ {
2260
+ id: "vue-doctor/build-quality/tsconfig-strict-required",
2261
+ severity: "warn",
2262
+ category: "build-quality",
2263
+ source: "doctor",
2264
+ recommended: true
2265
+ },
2266
+ {
2267
+ id: "vue-doctor/build-quality/vue-tsc-in-devDeps",
2268
+ severity: "warn",
2269
+ category: "build-quality",
2270
+ source: "doctor",
2271
+ recommended: true
2272
+ },
2273
+ {
2274
+ id: "vue-doctor/build-quality/no-vue-cli",
2275
+ severity: "warn",
2276
+ category: "build-quality",
2277
+ source: "doctor",
2278
+ recommended: true
2279
+ },
2280
+ {
2281
+ id: "vue-doctor/build-quality/eslint-plugin-vue-installed",
2282
+ severity: "info",
2283
+ category: "build-quality",
2284
+ source: "doctor",
2285
+ recommended: true
2286
+ },
2287
+ {
2288
+ id: "vue-doctor/deps/duplicate-vue-versions",
3468
2289
  severity: "error",
3469
- category: "vue-builtin",
3470
- source: "oxlint-builtin",
2290
+ category: "deps",
2291
+ source: "doctor",
3471
2292
  recommended: true
3472
2293
  },
3473
2294
  {
3474
- id: "vue/no-required-prop-with-default",
2295
+ id: "vue-doctor/deps/vue-major-current",
2296
+ severity: "info",
2297
+ category: "deps",
2298
+ source: "doctor",
2299
+ recommended: false
2300
+ },
2301
+ {
2302
+ id: "dead-code/unused-file",
3475
2303
  severity: "warn",
3476
- category: "vue-builtin",
3477
- source: "oxlint-builtin",
2304
+ category: "dead-code",
2305
+ source: "doctor",
2306
+ recommended: true
2307
+ },
2308
+ {
2309
+ id: "dead-code/unused-export",
2310
+ severity: "warn",
2311
+ category: "dead-code",
2312
+ source: "doctor",
2313
+ recommended: true
2314
+ },
2315
+ {
2316
+ id: "dead-code/unused-type-export",
2317
+ severity: "info",
2318
+ category: "dead-code",
2319
+ source: "doctor",
2320
+ recommended: true
2321
+ },
2322
+ {
2323
+ id: "dead-code/unused-member",
2324
+ severity: "info",
2325
+ category: "dead-code",
2326
+ source: "doctor",
3478
2327
  recommended: true
3479
2328
  },
3480
2329
  {
3481
- id: "vue/prefer-import-from-vue",
2330
+ id: "dead-code/unused-dependency",
3482
2331
  severity: "warn",
3483
- category: "vue-builtin",
3484
- source: "oxlint-builtin",
2332
+ category: "dead-code",
2333
+ source: "doctor",
3485
2334
  recommended: true
3486
2335
  },
3487
2336
  {
3488
- id: "vue/no-import-compiler-macros",
3489
- severity: "warn",
3490
- category: "vue-builtin",
3491
- source: "oxlint-builtin",
2337
+ id: "dead-code/unlisted-dependency",
2338
+ severity: "error",
2339
+ category: "dead-code",
2340
+ source: "doctor",
3492
2341
  recommended: true
3493
2342
  },
3494
2343
  {
3495
- id: "vue/no-multiple-slot-args",
2344
+ id: "dead-code/duplicate-export",
3496
2345
  severity: "warn",
3497
- category: "vue-builtin",
3498
- source: "oxlint-builtin",
2346
+ category: "dead-code",
2347
+ source: "doctor",
3499
2348
  recommended: true
3500
2349
  },
3501
2350
  {
3502
- id: "vue/require-default-export",
2351
+ id: "nuxt-doctor/structure/uses-app-directory",
3503
2352
  severity: "warn",
3504
- category: "vue-builtin",
3505
- source: "oxlint-builtin",
2353
+ category: "structure",
2354
+ source: "doctor",
3506
2355
  recommended: true
3507
2356
  },
3508
2357
  {
3509
- id: "vue/define-emits-declaration",
2358
+ id: "nuxt-doctor/structure/nuxt-major-current",
3510
2359
  severity: "info",
3511
- category: "vue-builtin",
3512
- source: "oxlint-builtin",
2360
+ category: "structure",
2361
+ source: "doctor",
3513
2362
  recommended: false
3514
2363
  },
3515
2364
  {
3516
- id: "vue/define-props-declaration",
3517
- severity: "info",
3518
- category: "vue-builtin",
3519
- source: "oxlint-builtin",
3520
- recommended: false
2365
+ id: "nuxt-doctor/modules-deps/no-modules-incompatible-with-nuxt-4",
2366
+ severity: "warn",
2367
+ category: "modules-deps",
2368
+ source: "doctor",
2369
+ recommended: true
3521
2370
  },
3522
2371
  {
3523
- id: "vue/define-props-destructuring",
2372
+ id: "nuxt-doctor/modules-deps/recommended-modules-installed",
3524
2373
  severity: "info",
3525
- category: "vue-builtin",
3526
- source: "oxlint-builtin",
2374
+ category: "modules-deps",
2375
+ source: "doctor",
3527
2376
  recommended: false
3528
2377
  },
3529
2378
  {
3530
- id: "vue/max-props",
3531
- severity: "info",
3532
- category: "vue-builtin",
3533
- source: "oxlint-builtin",
3534
- recommended: false
2379
+ id: "nuxt-doctor/nitro/compatibilityDate-set",
2380
+ severity: "error",
2381
+ category: "nitro",
2382
+ source: "doctor",
2383
+ recommended: true
3535
2384
  },
3536
2385
  {
3537
- id: "vue-doctor/no-em-dash-in-string",
2386
+ id: "nuxt-doctor/nitro/preset-defined-for-deploy-target",
3538
2387
  severity: "warn",
3539
- category: "ai-slop",
2388
+ category: "nitro",
3540
2389
  source: "doctor",
3541
2390
  recommended: true
3542
2391
  },
3543
2392
  {
3544
- id: "vue-doctor/no-destructure-props-without-to-refs",
3545
- severity: "error",
3546
- category: "ai-slop",
2393
+ id: "nuxt-doctor/nitro/runtime-config-typed",
2394
+ severity: "info",
2395
+ category: "nitro",
3547
2396
  source: "doctor",
3548
- recommended: true
2397
+ recommended: false
3549
2398
  },
3550
2399
  {
3551
- id: "vue-doctor/no-destructure-reactive-without-to-refs",
3552
- severity: "error",
3553
- category: "ai-slop",
2400
+ id: "nuxt-doctor/seo/lang-on-html",
2401
+ severity: "warn",
2402
+ category: "seo",
3554
2403
  source: "doctor",
3555
2404
  recommended: true
3556
2405
  },
3557
2406
  {
3558
- id: "vue-doctor/no-non-null-assertion-on-ref-value",
2407
+ id: "nuxt-doctor/seo/useSeoMeta-on-public-page",
3559
2408
  severity: "warn",
3560
- category: "ai-slop",
2409
+ category: "seo",
3561
2410
  source: "doctor",
3562
2411
  recommended: true
3563
2412
  },
3564
2413
  {
3565
- id: "vue-doctor/no-imports-from-vue-when-auto-imported",
2414
+ id: "nuxt-doctor/seo/og-image-defined",
3566
2415
  severity: "warn",
3567
- category: "ai-slop",
2416
+ category: "seo",
3568
2417
  source: "doctor",
3569
2418
  recommended: true
3570
2419
  },
3571
2420
  {
3572
- id: "vue-doctor/reactivity/watch-without-cleanup",
2421
+ id: "nuxt-doctor/cloudflare/nitro-cloudflare-preset",
3573
2422
  severity: "warn",
3574
- category: "reactivity",
2423
+ category: "cloudflare",
3575
2424
  source: "doctor",
3576
2425
  recommended: true
3577
2426
  },
3578
2427
  {
3579
- id: "vue-doctor/reactivity/prefer-shallowRef-for-large-data",
2428
+ id: "nuxt-doctor/cloudflare/og-image-via-satori",
3580
2429
  severity: "info",
3581
- category: "reactivity",
2430
+ category: "cloudflare",
3582
2431
  source: "doctor",
3583
2432
  recommended: false
3584
2433
  },
3585
2434
  {
3586
- id: "vue-doctor/reactivity/prefer-readonly-for-injected",
3587
- severity: "info",
3588
- category: "reactivity",
2435
+ id: "nuxt-doctor/cloudflare/no-node-only-modules",
2436
+ severity: "warn",
2437
+ category: "cloudflare",
3589
2438
  source: "doctor",
3590
- recommended: false
2439
+ recommended: true
3591
2440
  },
3592
2441
  {
3593
- id: "vue-doctor/composition/prefer-script-setup-for-new-files",
2442
+ id: "nuxt-doctor/data-fetching/no-shared-key-across-pages",
3594
2443
  severity: "warn",
3595
- category: "composition",
2444
+ category: "data-fetching",
3596
2445
  source: "doctor",
3597
2446
  recommended: true
3598
2447
  },
3599
2448
  {
3600
- id: "vue-doctor/composition/defineProps-typed",
2449
+ id: "nuxt-doctor/data-fetching/ssr-safe-onMounted-only-for-client",
3601
2450
  severity: "warn",
3602
- category: "composition",
2451
+ category: "data-fetching",
3603
2452
  source: "doctor",
3604
2453
  recommended: true
3605
2454
  },
3606
2455
  {
3607
- id: "vue-doctor/performance/prefer-defineAsyncComponent-on-route",
3608
- severity: "info",
3609
- category: "performance",
2456
+ id: "nuxt-doctor/ai-slop/no-mixed-app-and-root-layout",
2457
+ severity: "warn",
2458
+ category: "ai-slop",
3610
2459
  source: "doctor",
3611
- recommended: false
2460
+ recommended: true
3612
2461
  },
3613
2462
  {
3614
- id: "vue-doctor/template/v-for-has-key",
2463
+ id: "nuxt-doctor/ai-slop/no-process-client-server",
3615
2464
  severity: "error",
3616
- category: "template",
2465
+ category: "ai-slop",
3617
2466
  source: "doctor",
3618
2467
  recommended: true
3619
2468
  },
3620
2469
  {
3621
- id: "vue-doctor/template/v-if-v-for-precedence",
3622
- severity: "error",
3623
- category: "template",
2470
+ id: "nuxt-doctor/ai-slop/no-explicit-imports-of-auto-imported",
2471
+ severity: "warn",
2472
+ category: "ai-slop",
3624
2473
  source: "doctor",
3625
2474
  recommended: true
3626
2475
  },
3627
2476
  {
3628
- id: "vue-doctor/template/v-memo-on-large-list",
2477
+ id: "nuxt-doctor/ai-slop/no-useState-for-server-data",
3629
2478
  severity: "warn",
3630
- category: "performance",
2479
+ category: "ai-slop",
3631
2480
  source: "doctor",
3632
2481
  recommended: true
3633
2482
  },
3634
2483
  {
3635
- id: "vue-doctor/template/no-inline-object-prop-in-list",
2484
+ id: "nuxt-doctor/ai-slop/no-fetch-in-setup",
3636
2485
  severity: "warn",
3637
- category: "performance",
2486
+ category: "ai-slop",
3638
2487
  source: "doctor",
3639
2488
  recommended: true
3640
2489
  },
3641
2490
  {
3642
- id: "vue-doctor/template/no-computed-getter-in-template-loop",
3643
- severity: "warn",
3644
- category: "template-perf",
2491
+ id: "nuxt-doctor/data-fetching/useAsyncData-key-required-in-loop",
2492
+ severity: "error",
2493
+ category: "data-fetching",
3645
2494
  source: "doctor",
3646
2495
  recommended: true
3647
2496
  },
3648
2497
  {
3649
- id: "vue-doctor/template/avoid-deep-v-bind-spread-in-list",
3650
- severity: "info",
3651
- category: "template-perf",
2498
+ id: "nuxt-doctor/server-routes/defineEventHandler-typed",
2499
+ severity: "warn",
2500
+ category: "server-routes",
3652
2501
  source: "doctor",
3653
2502
  recommended: true
3654
2503
  },
3655
2504
  {
3656
- id: "vue-doctor/sfc/no-mixed-options-and-composition-api",
2505
+ id: "nuxt-doctor/server-routes/validate-body-with-h3-v2",
3657
2506
  severity: "warn",
3658
- category: "sfc",
2507
+ category: "server-routes",
3659
2508
  source: "doctor",
3660
2509
  recommended: true
3661
2510
  },
3662
2511
  {
3663
- id: "vue-doctor/build-quality/tsconfig-strict-required",
2512
+ id: "nuxt-doctor/server-routes/createError-on-failure",
3664
2513
  severity: "warn",
3665
- category: "build-quality",
2514
+ category: "server-routes",
3666
2515
  source: "doctor",
3667
2516
  recommended: true
3668
2517
  },
3669
2518
  {
3670
- id: "vue-doctor/build-quality/vue-tsc-in-devDeps",
2519
+ id: "nuxt-doctor/hydration/no-document-in-setup",
2520
+ severity: "error",
2521
+ category: "hydration",
2522
+ source: "doctor",
2523
+ recommended: true
2524
+ },
2525
+ {
2526
+ id: "nuxt-doctor/hydration/clientOnly-for-browser-apis",
2527
+ severity: "error",
2528
+ category: "hydration",
2529
+ source: "doctor",
2530
+ recommended: true
2531
+ },
2532
+ {
2533
+ id: "vue-doctor/security/no-v-html",
2534
+ severity: "error",
2535
+ category: "security",
2536
+ source: "doctor",
2537
+ recommended: true
2538
+ },
2539
+ {
2540
+ id: "vue-doctor/security/no-inner-html",
2541
+ severity: "error",
2542
+ category: "security",
2543
+ source: "doctor",
2544
+ recommended: true
2545
+ },
2546
+ {
2547
+ id: "vue-doctor/security/no-eval-like",
2548
+ severity: "error",
2549
+ category: "security",
2550
+ source: "doctor",
2551
+ recommended: true
2552
+ },
2553
+ {
2554
+ id: "vue-doctor/security/no-auth-token-in-web-storage",
3671
2555
  severity: "warn",
3672
- category: "build-quality",
2556
+ category: "security",
3673
2557
  source: "doctor",
3674
2558
  recommended: true
3675
2559
  },
3676
2560
  {
3677
- id: "vue-doctor/build-quality/no-vue-cli",
2561
+ id: "vue-doctor/security/no-secrets-in-source",
3678
2562
  severity: "warn",
3679
- category: "build-quality",
2563
+ category: "security",
3680
2564
  source: "doctor",
3681
2565
  recommended: true
3682
2566
  },
3683
2567
  {
3684
- id: "vue-doctor/build-quality/eslint-plugin-vue-installed",
3685
- severity: "info",
3686
- category: "build-quality",
2568
+ id: "vue-doctor/security/no-target-blank-without-rel",
2569
+ severity: "warn",
2570
+ category: "security",
3687
2571
  source: "doctor",
3688
2572
  recommended: true
3689
2573
  },
3690
2574
  {
3691
- id: "vue-doctor/deps/duplicate-vue-versions",
2575
+ id: "vue-doctor/security/no-javascript-uri",
3692
2576
  severity: "error",
3693
- category: "deps",
2577
+ category: "security",
3694
2578
  source: "doctor",
3695
2579
  recommended: true
3696
2580
  },
3697
2581
  {
3698
- id: "vue-doctor/deps/vue-major-current",
3699
- severity: "info",
3700
- category: "deps",
2582
+ id: "nuxt-doctor/security/no-secret-in-public-runtime-config",
2583
+ severity: "error",
2584
+ category: "security",
3701
2585
  source: "doctor",
3702
- recommended: false
2586
+ recommended: true
3703
2587
  },
3704
2588
  {
3705
- id: "dead-code/unused-file",
2589
+ id: "nuxt-doctor/security/no-user-input-in-fetch-url",
3706
2590
  severity: "warn",
3707
- category: "dead-code",
2591
+ category: "security",
3708
2592
  source: "doctor",
3709
2593
  recommended: true
3710
2594
  },
3711
2595
  {
3712
- id: "dead-code/unused-export",
2596
+ id: "vue-doctor/design/no-arbitrary-tailwind-values",
3713
2597
  severity: "warn",
3714
- category: "dead-code",
2598
+ category: "design",
3715
2599
  source: "doctor",
3716
2600
  recommended: true
3717
2601
  },
3718
2602
  {
3719
- id: "dead-code/unused-type-export",
3720
- severity: "info",
3721
- category: "dead-code",
2603
+ id: "vue-doctor/design/no-raw-hex-color",
2604
+ severity: "warn",
2605
+ category: "design",
3722
2606
  source: "doctor",
3723
2607
  recommended: true
3724
2608
  },
3725
2609
  {
3726
- id: "dead-code/unused-member",
3727
- severity: "info",
3728
- category: "dead-code",
2610
+ id: "vue-doctor/design/no-default-tailwind-palette",
2611
+ severity: "warn",
2612
+ category: "design",
3729
2613
  source: "doctor",
3730
2614
  recommended: true
3731
2615
  },
3732
2616
  {
3733
- id: "dead-code/unused-dependency",
2617
+ id: "vue-doctor/design/no-important-utility",
3734
2618
  severity: "warn",
3735
- category: "dead-code",
2619
+ category: "design",
3736
2620
  source: "doctor",
3737
2621
  recommended: true
3738
2622
  },
3739
2623
  {
3740
- id: "dead-code/unlisted-dependency",
3741
- severity: "error",
3742
- category: "dead-code",
2624
+ id: "vue-doctor/design/no-hardcoded-inline-style",
2625
+ severity: "warn",
2626
+ category: "design",
3743
2627
  source: "doctor",
3744
2628
  recommended: true
3745
2629
  },
3746
2630
  {
3747
- id: "dead-code/duplicate-export",
2631
+ id: "vue-doctor/design/no-missing-alt",
3748
2632
  severity: "warn",
3749
- category: "dead-code",
2633
+ category: "design",
3750
2634
  source: "doctor",
3751
2635
  recommended: true
3752
2636
  },
3753
2637
  {
3754
- id: "nuxt-doctor/structure/uses-app-directory",
2638
+ id: "vue-doctor/design/no-absurd-z-index",
2639
+ severity: "warn",
2640
+ category: "design",
2641
+ source: "doctor",
2642
+ recommended: true
2643
+ }
2644
+ ];
2645
+ function listRules(filter = {}) {
2646
+ let rules = [...RULE_REGISTRY];
2647
+ if (filter.preset === "recommended") rules = rules.filter((r) => r.recommended);
2648
+ if (filter.category) rules = rules.filter((r) => r.category === filter.category);
2649
+ if (filter.source) rules = rules.filter((r) => r.source === filter.source);
2650
+ if (filter.severity) rules = rules.filter((r) => r.severity === filter.severity);
2651
+ return rules.sort((a, b) => a.id.localeCompare(b.id));
2652
+ }
2653
+ //#endregion
2654
+ //#region src/score-dimensions.ts
2655
+ const SCORE_DIMENSIONS = [
2656
+ "performance",
2657
+ "correctness",
2658
+ "security",
2659
+ "maintainability",
2660
+ "nuxt",
2661
+ "design"
2662
+ ];
2663
+ const CATEGORY_TO_DIMENSION = {
2664
+ performance: "performance",
2665
+ "template-perf": "performance",
2666
+ reactivity: "correctness",
2667
+ composition: "correctness",
2668
+ template: "correctness",
2669
+ sfc: "correctness",
2670
+ "vue-builtin": "correctness",
2671
+ hydration: "correctness",
2672
+ security: "security",
2673
+ "ai-slop": "maintainability",
2674
+ "dead-code": "maintainability",
2675
+ "build-quality": "maintainability",
2676
+ deps: "maintainability",
2677
+ structure: "maintainability",
2678
+ "modules-deps": "maintainability",
2679
+ nitro: "nuxt",
2680
+ seo: "nuxt",
2681
+ cloudflare: "nuxt",
2682
+ "server-routes": "nuxt",
2683
+ "data-fetching": "nuxt",
2684
+ design: "design"
2685
+ };
2686
+ function dimensionForCategory(category) {
2687
+ return CATEGORY_TO_DIMENSION[category];
2688
+ }
2689
+ function isScoreDimension(value) {
2690
+ return SCORE_DIMENSIONS.includes(value);
2691
+ }
2692
+ function categoriesForDimension(dimension) {
2693
+ const out = [];
2694
+ for (const [cat, dim] of Object.entries(CATEGORY_TO_DIMENSION)) if (dim === dimension) out.push(cat);
2695
+ return out;
2696
+ }
2697
+ //#endregion
2698
+ //#region src/score.ts
2699
+ const SEVERITY_WEIGHTS = {
2700
+ error: 5,
2701
+ warn: 2,
2702
+ info: .5
2703
+ };
2704
+ const DIMENSION_BY_RULE_ID = new Map(RULE_REGISTRY.map((r) => [r.id, dimensionForCategory(r.category)]));
2705
+ function weightFor(diag, config) {
2706
+ return config?.rules?.[diag.ruleId]?.weight ?? SEVERITY_WEIGHTS[diag.severity];
2707
+ }
2708
+ function penaltyForGroups(byRule, config) {
2709
+ let penalty = 0;
2710
+ for (const list of byRule.values()) {
2711
+ const weight = weightFor(list[0], config);
2712
+ for (let i = 0; i < list.length; i++) penalty += weight * (i === 0 ? 1 : 1 / Math.sqrt(i + 1));
2713
+ }
2714
+ return penalty;
2715
+ }
2716
+ function emptyDimensionScores() {
2717
+ const out = {};
2718
+ for (const dim of SCORE_DIMENSIONS) out[dim] = {
2719
+ score: 100,
2720
+ totalFindings: 0,
2721
+ errorCount: 0,
2722
+ warnCount: 0,
2723
+ infoCount: 0
2724
+ };
2725
+ return out;
2726
+ }
2727
+ function computeByDimension(diagnostics, config) {
2728
+ const grouped = /* @__PURE__ */ new Map();
2729
+ const counts = emptyDimensionScores();
2730
+ for (const d of diagnostics) {
2731
+ const dim = DIMENSION_BY_RULE_ID.get(d.ruleId);
2732
+ if (dim === void 0) continue;
2733
+ const bucket = counts[dim];
2734
+ bucket.totalFindings += 1;
2735
+ if (d.severity === "error") bucket.errorCount += 1;
2736
+ else if (d.severity === "warn") bucket.warnCount += 1;
2737
+ else bucket.infoCount += 1;
2738
+ let byRule = grouped.get(dim);
2739
+ if (!byRule) {
2740
+ byRule = /* @__PURE__ */ new Map();
2741
+ grouped.set(dim, byRule);
2742
+ }
2743
+ const list = byRule.get(d.ruleId);
2744
+ if (list) list.push(d);
2745
+ else byRule.set(d.ruleId, [d]);
2746
+ }
2747
+ for (const [dim, byRule] of grouped) {
2748
+ const penalty = penaltyForGroups(byRule, config);
2749
+ counts[dim].score = Math.max(0, Math.round(100 - penalty));
2750
+ }
2751
+ return counts;
2752
+ }
2753
+ function scoreDiagnostics(diagnostics, config) {
2754
+ const threshold = config?.threshold ?? 0;
2755
+ let errorCount = 0;
2756
+ let warnCount = 0;
2757
+ let infoCount = 0;
2758
+ const byRule = /* @__PURE__ */ new Map();
2759
+ for (const d of diagnostics) {
2760
+ if (d.severity === "error") errorCount += 1;
2761
+ else if (d.severity === "warn") warnCount += 1;
2762
+ else infoCount += 1;
2763
+ const list = byRule.get(d.ruleId);
2764
+ if (list) list.push(d);
2765
+ else byRule.set(d.ruleId, [d]);
2766
+ }
2767
+ const sortedRuleIds = [...byRule.keys()].sort();
2768
+ const breakdown = [];
2769
+ let penalty = 0;
2770
+ for (const ruleId of sortedRuleIds) {
2771
+ const list = byRule.get(ruleId);
2772
+ const weight = config?.rules?.[ruleId]?.weight ?? SEVERITY_WEIGHTS[list[0].severity];
2773
+ let rulePenalty = 0;
2774
+ for (let i = 0; i < list.length; i++) rulePenalty += weight * (i === 0 ? 1 : 1 / Math.sqrt(i + 1));
2775
+ penalty += rulePenalty;
2776
+ breakdown.push({
2777
+ ruleId,
2778
+ occurrences: list.length,
2779
+ weightPerOccurrence: weight,
2780
+ penalty: rulePenalty
2781
+ });
2782
+ }
2783
+ breakdown.sort((a, b) => b.penalty - a.penalty);
2784
+ const score = Math.max(0, Math.round(100 - penalty));
2785
+ return {
2786
+ score,
2787
+ passed: score >= threshold,
2788
+ threshold,
2789
+ totalFindings: diagnostics.length,
2790
+ errorCount,
2791
+ warnCount,
2792
+ infoCount,
2793
+ breakdown,
2794
+ byDimension: computeByDimension(diagnostics, config)
2795
+ };
2796
+ }
2797
+ //#endregion
2798
+ //#region src/sfc/parse-sfc-descriptor.ts
2799
+ const cache$1 = /* @__PURE__ */ new Map();
2800
+ async function parseSfcDescriptor(absPath) {
2801
+ if (cache$1.has(absPath)) return cache$1.get(absPath) ?? null;
2802
+ let source;
2803
+ try {
2804
+ source = await readFile(absPath, "utf8");
2805
+ } catch {
2806
+ cache$1.set(absPath, null);
2807
+ return null;
2808
+ }
2809
+ const { descriptor, errors } = parse(source, { filename: absPath });
2810
+ if (errors.length > 0) {
2811
+ cache$1.set(absPath, null);
2812
+ return null;
2813
+ }
2814
+ cache$1.set(absPath, descriptor);
2815
+ return descriptor;
2816
+ }
2817
+ //#endregion
2818
+ //#region src/sfc/rules/no-mixed-options-and-composition-api.ts
2819
+ const RULE_ID$5 = "vue-doctor/sfc/no-mixed-options-and-composition-api";
2820
+ const MESSAGE$5 = "Mixed Options API in a <script setup> SFC. Move data/methods/computed/watch/lifecycle into <script setup> Composition API; keep <script> only for options like name/inheritAttrs. See https://vuejs.org/api/sfc-script-setup.html#usage-alongside-normal-script";
2821
+ const RECOMMENDATION$5 = "Move this option into the <script setup> block using the Composition API, or keep <script> only for options-only config such as name or inheritAttrs.";
2822
+ const DISALLOWED = new Set([
2823
+ "data",
2824
+ "methods",
2825
+ "computed",
2826
+ "watch",
2827
+ "props",
2828
+ "emits",
2829
+ "provide",
2830
+ "inject",
2831
+ "beforeCreate",
2832
+ "created",
2833
+ "beforeMount",
2834
+ "mounted",
2835
+ "beforeUpdate",
2836
+ "updated",
2837
+ "beforeUnmount",
2838
+ "unmounted",
2839
+ "activated",
2840
+ "deactivated",
2841
+ "errorCaptured",
2842
+ "serverPrefetch"
2843
+ ]);
2844
+ function resolveDefineComponentCall(call) {
2845
+ if (call.callee.type !== "Identifier") return null;
2846
+ if (call.callee.name !== "defineComponent") return null;
2847
+ const first = call.arguments[0];
2848
+ if (!first || first.type !== "ObjectExpression") return null;
2849
+ return first;
2850
+ }
2851
+ function findBindingInit(body, name) {
2852
+ for (const stmt of body) {
2853
+ if (stmt.type !== "VariableDeclaration") continue;
2854
+ for (const declarator of stmt.declarations) {
2855
+ if (declarator.id.type !== "Identifier") continue;
2856
+ if (declarator.id.name !== name) continue;
2857
+ return declarator.init;
2858
+ }
2859
+ }
2860
+ return null;
2861
+ }
2862
+ function resolveOptionsObject(program) {
2863
+ const exported = program.body.find((node) => node.type === "ExportDefaultDeclaration");
2864
+ if (!exported) return null;
2865
+ const declaration = exported.declaration;
2866
+ if (declaration.type === "ObjectExpression") return declaration;
2867
+ if (declaration.type === "CallExpression") return resolveDefineComponentCall(declaration);
2868
+ if (declaration.type === "Identifier") {
2869
+ const init = findBindingInit(program.body, declaration.name);
2870
+ if (init && init.type === "CallExpression") return resolveDefineComponentCall(init);
2871
+ return null;
2872
+ }
2873
+ return null;
2874
+ }
2875
+ function locate(content, offset, startLine, startColumn) {
2876
+ let line = startLine;
2877
+ let lastNewline = -1;
2878
+ for (let i = 0; i < offset; i += 1) if (content.charCodeAt(i) === 10) {
2879
+ line += 1;
2880
+ lastNewline = i;
2881
+ }
2882
+ const column = lastNewline === -1 ? startColumn + offset : offset - lastNewline;
2883
+ return {
2884
+ line,
2885
+ column
2886
+ };
2887
+ }
2888
+ function check$21(ctx) {
2889
+ const { script, scriptSetup } = ctx.descriptor;
2890
+ if (!script || !scriptSetup) return { diagnostics: [] };
2891
+ const lang = script.lang === "ts" ? "ts" : "js";
2892
+ const { program } = parseSync(`script.${lang}`, script.content, {
2893
+ sourceType: "module",
2894
+ lang
2895
+ });
2896
+ const options = resolveOptionsObject(program);
2897
+ if (!options) return { diagnostics: [] };
2898
+ const diagnostics = [];
2899
+ for (const property of options.properties) {
2900
+ if (property.type !== "Property") continue;
2901
+ if (property.key.type !== "Identifier") continue;
2902
+ if (!DISALLOWED.has(property.key.name)) continue;
2903
+ const { line, column } = locate(script.content, property.start, script.loc.start.line, script.loc.start.column);
2904
+ diagnostics.push({
2905
+ file: ctx.file,
2906
+ line,
2907
+ column,
2908
+ ruleId: RULE_ID$5,
2909
+ severity: "error",
2910
+ message: MESSAGE$5,
2911
+ source: "sfc",
2912
+ recommendation: RECOMMENDATION$5
2913
+ });
2914
+ }
2915
+ return { diagnostics };
2916
+ }
2917
+ //#endregion
2918
+ //#region src/nuxt/file-role.ts
2919
+ const PAGE_DIR = /^(?:app\/)?pages\//;
2920
+ const LAYOUT_DIR = /^(?:app\/)?layouts\//;
2921
+ function normalize(relPath) {
2922
+ return relPath.replace(/\\/g, "/").replace(/^\.\//, "");
2923
+ }
2924
+ /**
2925
+ * True when the path points at a Nuxt page component — a `.vue` file under
2926
+ * `pages/` (Nuxt 3 layout) or `app/pages/` (Nuxt 4 layout) at the project root.
2927
+ */
2928
+ function isNuxtPageFile(relPath) {
2929
+ const path = normalize(relPath);
2930
+ return PAGE_DIR.test(path) && path.endsWith(".vue");
2931
+ }
2932
+ /**
2933
+ * True when the path points at a Nitro server file — a `.ts` file under the
2934
+ * project-root `server/` directory.
2935
+ */
2936
+ function isNuxtServerFile(relPath) {
2937
+ const path = normalize(relPath);
2938
+ return path.startsWith("server/") && path.endsWith(".ts");
2939
+ }
2940
+ /**
2941
+ * True when the path points at a Nuxt layout component — a `.vue` file under
2942
+ * `layouts/` (Nuxt 3 layout) or `app/layouts/` (Nuxt 4 layout) at the project
2943
+ * root.
2944
+ */
2945
+ function isNuxtLayoutFile(relPath) {
2946
+ const path = normalize(relPath);
2947
+ return LAYOUT_DIR.test(path) && path.endsWith(".vue");
2948
+ }
2949
+ //#endregion
2950
+ //#region src/template/walk.ts
2951
+ const NODE_ELEMENT$2 = 1;
2952
+ function isElementNode(node) {
2953
+ return node !== null && node !== void 0 && node.type === NODE_ELEMENT$2;
2954
+ }
2955
+ function walkElements(root, visit) {
2956
+ const stack = [...root.children];
2957
+ while (stack.length > 0) {
2958
+ const node = stack.pop();
2959
+ if (!node) continue;
2960
+ if (isElementNode(node)) {
2961
+ visit(node);
2962
+ if (node.children) for (const child of node.children) stack.push(child);
2963
+ }
2964
+ }
2965
+ }
2966
+ //#endregion
2967
+ //#region src/sfc/rules/nuxt/no-mixed-app-and-root-layout.ts
2968
+ const RULE_ID$4 = "nuxt-doctor/ai-slop/no-mixed-app-and-root-layout";
2969
+ const MESSAGE$4 = "Layout file renders<NuxtLayout> inside itself. This creates a nested layout chain where the root layout renders itself as a slot. Use<slot /> directly and let the page content flow through.";
2970
+ const RECOMMENDATION$4 = "Replace <NuxtLayout> in this layout file with <slot />. The layout slot is already provided by the parent layout wrapper.";
2971
+ function check$20(ctx) {
2972
+ if (!isNuxtLayoutFile(ctx.relativePath)) return { diagnostics: [] };
2973
+ const { template } = ctx.descriptor;
2974
+ if (!template || !template.ast) return { diagnostics: [] };
2975
+ let foundNuxtLayout = false;
2976
+ let nuxtLayoutNode;
2977
+ walkElements(template.ast, (el) => {
2978
+ if (el.tag === "NuxtLayout") {
2979
+ foundNuxtLayout = true;
2980
+ nuxtLayoutNode = el;
2981
+ }
2982
+ });
2983
+ if (!foundNuxtLayout) return { diagnostics: [] };
2984
+ const node = nuxtLayoutNode;
2985
+ return { diagnostics: [{
2986
+ file: ctx.file,
2987
+ line: node.loc.start.line,
2988
+ column: node.loc.start.column,
2989
+ endLine: node.loc.end.line,
2990
+ endColumn: node.loc.end.column,
2991
+ ruleId: RULE_ID$4,
3755
2992
  severity: "warn",
3756
- category: "structure",
3757
- source: "doctor",
3758
- recommended: true
3759
- },
3760
- {
3761
- id: "nuxt-doctor/structure/nuxt-major-current",
3762
- severity: "info",
3763
- category: "structure",
3764
- source: "doctor",
3765
- recommended: false
3766
- },
3767
- {
3768
- id: "nuxt-doctor/modules-deps/no-modules-incompatible-with-nuxt-4",
2993
+ message: MESSAGE$4,
2994
+ source: "sfc",
2995
+ recommendation: RECOMMENDATION$4
2996
+ }] };
2997
+ }
2998
+ //#endregion
2999
+ //#region src/sfc/rules/nuxt/og-image-defined.ts
3000
+ const RULE_ID$3 = "nuxt-doctor/seo/og-image-defined";
3001
+ const MESSAGE$3 = "Page uses SEO meta but has no og:image property. Open Graph images improve social sharing previews. Add an og:image value or install @nuxtjs/og-image for automatic OG images.";
3002
+ const RECOMMENDATION$3 = "Add ogImage: \"/path/to/image.png\" to useSeoMeta / useHead, or install @nuxtjs/og-image.";
3003
+ function hasOgImageInCall(program) {
3004
+ for (const stmt of program.body) {
3005
+ if (stmt.type !== "ExpressionStatement") continue;
3006
+ const call = stmt.expression;
3007
+ if (call.type !== "CallExpression") continue;
3008
+ if (call.callee.type !== "Identifier") continue;
3009
+ const name = call.callee.name;
3010
+ if (name !== "useSeoMeta" && name !== "useHead") continue;
3011
+ const firstArg = call.arguments[0];
3012
+ if (!firstArg || firstArg.type !== "ObjectExpression") continue;
3013
+ for (const prop of firstArg.properties) {
3014
+ if (prop.type !== "Property") continue;
3015
+ if (prop.key.type === "Identifier" && prop.key.name === "ogImage") return true;
3016
+ if (prop.key.type === "Literal" && prop.key.value === "og:image") return true;
3017
+ }
3018
+ }
3019
+ return false;
3020
+ }
3021
+ function hasOgImageDep(packageJsonPath) {
3022
+ try {
3023
+ const raw = readFileSync(packageJsonPath, "utf8");
3024
+ const pkg = JSON.parse(raw);
3025
+ const deps = {
3026
+ ...pkg.dependencies,
3027
+ ...pkg.devDependencies
3028
+ };
3029
+ return "@nuxtjs/og-image" in deps || "nuxt-og-image" in deps;
3030
+ } catch {
3031
+ return false;
3032
+ }
3033
+ }
3034
+ function check$19(ctx) {
3035
+ if (!isNuxtPageFile(ctx.relativePath)) return { diagnostics: [] };
3036
+ const { scriptSetup } = ctx.descriptor;
3037
+ if (!scriptSetup) return { diagnostics: [] };
3038
+ const lang = scriptSetup.lang === "ts" ? "ts" : "js";
3039
+ const { program } = parseSync(`script.${lang}`, scriptSetup.content, {
3040
+ sourceType: "module",
3041
+ lang
3042
+ });
3043
+ if (hasOgImageInCall(program)) return { diagnostics: [] };
3044
+ if (ctx.projectInfo.packageJsonPath === null) return { diagnostics: [] };
3045
+ if (hasOgImageDep(ctx.projectInfo.packageJsonPath)) return { diagnostics: [] };
3046
+ const { line, column } = scriptSetup.loc.start;
3047
+ return { diagnostics: [{
3048
+ file: ctx.file,
3049
+ line,
3050
+ column,
3051
+ ruleId: RULE_ID$3,
3769
3052
  severity: "warn",
3770
- category: "modules-deps",
3771
- source: "doctor",
3772
- recommended: true
3773
- },
3774
- {
3775
- id: "nuxt-doctor/modules-deps/recommended-modules-installed",
3776
- severity: "info",
3777
- category: "modules-deps",
3778
- source: "doctor",
3779
- recommended: false
3780
- },
3781
- {
3782
- id: "nuxt-doctor/nitro/compatibilityDate-set",
3783
- severity: "error",
3784
- category: "nitro",
3785
- source: "doctor",
3786
- recommended: true
3787
- },
3788
- {
3789
- id: "nuxt-doctor/nitro/preset-defined-for-deploy-target",
3053
+ message: MESSAGE$3,
3054
+ source: "sfc",
3055
+ recommendation: RECOMMENDATION$3
3056
+ }] };
3057
+ }
3058
+ //#endregion
3059
+ //#region src/sfc/rules/nuxt/use-seo-meta-on-public-page.ts
3060
+ const RULE_ID$2 = "nuxt-doctor/seo/useSeoMeta-on-public-page";
3061
+ const MESSAGE$2 = "Public page component is missing SEO metadata. Add useSeoMeta, useHead, or definePageMeta with a title so search engines can index it properly.";
3062
+ const RECOMMENDATION$2 = "Call useSeoMeta({ title: \"...\" }) in<script setup> to define page title and meta tags for search engines and social previews.";
3063
+ function hasTitleInCall(program) {
3064
+ for (const stmt of program.body) {
3065
+ if (stmt.type !== "ExpressionStatement") continue;
3066
+ const call = stmt.expression;
3067
+ if (call.type !== "CallExpression") continue;
3068
+ if (call.callee.type !== "Identifier") continue;
3069
+ const name = call.callee.name;
3070
+ if (name !== "useSeoMeta" && name !== "useHead" && name !== "definePageMeta") continue;
3071
+ const firstArg = call.arguments[0];
3072
+ if (!firstArg || firstArg.type !== "ObjectExpression") continue;
3073
+ for (const prop of firstArg.properties) {
3074
+ if (prop.type !== "Property") continue;
3075
+ if (prop.key.type === "Identifier" && prop.key.name === "title") return true;
3076
+ if (prop.key.type === "Literal" && prop.key.value === "title") return true;
3077
+ }
3078
+ }
3079
+ return false;
3080
+ }
3081
+ function check$18(ctx) {
3082
+ if (!isNuxtPageFile(ctx.relativePath)) return { diagnostics: [] };
3083
+ const { scriptSetup } = ctx.descriptor;
3084
+ if (!scriptSetup) return { diagnostics: [] };
3085
+ const lang = scriptSetup.lang === "ts" ? "ts" : "js";
3086
+ const { program } = parseSync(`script.${lang}`, scriptSetup.content, {
3087
+ sourceType: "module",
3088
+ lang
3089
+ });
3090
+ if (hasTitleInCall(program)) return { diagnostics: [] };
3091
+ const { line, column } = scriptSetup.loc.start;
3092
+ return { diagnostics: [{
3093
+ file: ctx.file,
3094
+ line,
3095
+ column,
3096
+ ruleId: RULE_ID$2,
3790
3097
  severity: "warn",
3791
- category: "nitro",
3792
- source: "doctor",
3793
- recommended: true
3794
- },
3795
- {
3796
- id: "nuxt-doctor/nitro/runtime-config-typed",
3797
- severity: "info",
3798
- category: "nitro",
3799
- source: "doctor",
3800
- recommended: false
3801
- },
3098
+ message: MESSAGE$2,
3099
+ source: "sfc",
3100
+ recommendation: RECOMMENDATION$2
3101
+ }] };
3102
+ }
3103
+ //#endregion
3104
+ //#region src/sfc/rules/index.ts
3105
+ const SFC_RULES = [
3802
3106
  {
3803
- id: "nuxt-doctor/seo/lang-on-html",
3804
- severity: "warn",
3805
- category: "seo",
3806
- source: "doctor",
3807
- recommended: true
3107
+ id: "vue-doctor/sfc/no-mixed-options-and-composition-api",
3108
+ check: check$21
3808
3109
  },
3809
3110
  {
3810
3111
  id: "nuxt-doctor/seo/useSeoMeta-on-public-page",
3811
- severity: "warn",
3812
- category: "seo",
3813
- source: "doctor",
3814
- recommended: true
3112
+ check: check$18
3815
3113
  },
3816
3114
  {
3817
3115
  id: "nuxt-doctor/seo/og-image-defined",
3818
- severity: "warn",
3819
- category: "seo",
3820
- source: "doctor",
3821
- recommended: true
3822
- },
3823
- {
3824
- id: "nuxt-doctor/cloudflare/nitro-cloudflare-preset",
3825
- severity: "warn",
3826
- category: "cloudflare",
3827
- source: "doctor",
3828
- recommended: true
3829
- },
3830
- {
3831
- id: "nuxt-doctor/cloudflare/og-image-via-satori",
3832
- severity: "info",
3833
- category: "cloudflare",
3834
- source: "doctor",
3835
- recommended: false
3836
- },
3837
- {
3838
- id: "nuxt-doctor/cloudflare/no-node-only-modules",
3839
- severity: "warn",
3840
- category: "cloudflare",
3841
- source: "doctor",
3842
- recommended: true
3843
- },
3844
- {
3845
- id: "nuxt-doctor/data-fetching/no-shared-key-across-pages",
3846
- severity: "warn",
3847
- category: "data-fetching",
3848
- source: "doctor",
3849
- recommended: true
3116
+ check: check$19
3850
3117
  },
3851
3118
  {
3852
- id: "nuxt-doctor/data-fetching/ssr-safe-onMounted-only-for-client",
3119
+ id: "nuxt-doctor/ai-slop/no-mixed-app-and-root-layout",
3120
+ check: check$20
3121
+ }
3122
+ ];
3123
+ //#endregion
3124
+ //#region src/sfc/run.ts
3125
+ async function runSfcPass(opts) {
3126
+ const all = [];
3127
+ const { projectInfo } = opts;
3128
+ const rootDir = projectInfo?.rootDirectory ?? process.cwd();
3129
+ for (const file of opts.files) {
3130
+ if (!file.endsWith(".vue")) continue;
3131
+ const descriptor = await parseSfcDescriptor(file);
3132
+ if (!descriptor) continue;
3133
+ const relativePath = relative(rootDir, file).replace(/\\/g, "/");
3134
+ for (const rule of SFC_RULES) {
3135
+ const override = opts.ruleOverrides?.[rule.id];
3136
+ if (override === "off") continue;
3137
+ const { diagnostics } = rule.check({
3138
+ file,
3139
+ descriptor,
3140
+ rootDirectory: rootDir,
3141
+ relativePath,
3142
+ projectInfo
3143
+ });
3144
+ for (const d of diagnostics) all.push(override ? {
3145
+ ...d,
3146
+ severity: override
3147
+ } : d);
3148
+ }
3149
+ }
3150
+ return all;
3151
+ }
3152
+ //#endregion
3153
+ //#region src/nuxt/cross-file/run.ts
3154
+ function extractDataFetchingKeys(content, lang) {
3155
+ const keys = [];
3156
+ try {
3157
+ const { program } = parseSync(`script.${lang}`, content, {
3158
+ sourceType: "module",
3159
+ lang
3160
+ });
3161
+ for (const stmt of program.body) if (stmt.type === "VariableDeclaration") for (const decl of stmt.declarations) {
3162
+ const call = unwrapAwait(decl.init);
3163
+ if (call?.type !== "CallExpression") continue;
3164
+ extractKeyFromCall(call, keys);
3165
+ }
3166
+ else if (stmt.type === "ExpressionStatement") {
3167
+ const expr = unwrapAwait(stmt.expression);
3168
+ if (expr?.type === "CallExpression") extractKeyFromCall(expr, keys);
3169
+ }
3170
+ } catch {}
3171
+ return keys;
3172
+ }
3173
+ function unwrapAwait(node) {
3174
+ if (node?.type === "AwaitExpression") return node.argument;
3175
+ return node;
3176
+ }
3177
+ function extractKeyFromCall(call, keys) {
3178
+ if (call.callee.type !== "Identifier") return;
3179
+ const fnName = call.callee.name;
3180
+ if (fnName !== "useAsyncData" && fnName !== "useFetch") return;
3181
+ const keyArg = call.arguments[0];
3182
+ if (keyArg?.type === "Literal" && typeof keyArg.value === "string") keys.push(keyArg.value);
3183
+ else if (keyArg?.type === "TemplateLiteral" && keyArg.quasis.length === 1) {
3184
+ const val = keyArg.quasis[0]?.value?.raw;
3185
+ if (val) keys.push(val);
3186
+ }
3187
+ if (fnName === "useFetch" && call.arguments.length >= 2) {
3188
+ const optsArg = call.arguments[1];
3189
+ if (optsArg?.type === "ObjectExpression") {
3190
+ for (const prop of optsArg.properties) if (prop.type === "Property" && prop.key.type === "Identifier" && prop.key.name === "key" && prop.value.type === "Literal" && typeof prop.value.value === "string") keys.push(prop.value.value);
3191
+ }
3192
+ }
3193
+ }
3194
+ async function runCrossFilePass(opts) {
3195
+ const { files, projectInfo } = opts;
3196
+ if (projectInfo.framework !== "nuxt") return [];
3197
+ const rootDir = projectInfo.rootDirectory;
3198
+ const pageFiles = files.filter((f) => isNuxtPageFile(relative(rootDir, f).replace(/\\/g, "/")));
3199
+ if (pageFiles.length === 0) return [];
3200
+ const parsed = [];
3201
+ for (const file of pageFiles) {
3202
+ const descriptor = await parseSfcDescriptor(file);
3203
+ if (!descriptor) continue;
3204
+ const { scriptSetup } = descriptor;
3205
+ if (!scriptSetup) continue;
3206
+ parsed.push({
3207
+ file,
3208
+ relativePath: relative(rootDir, file).replace(/\\/g, "/"),
3209
+ keys: extractDataFetchingKeys(scriptSetup.content, scriptSetup.lang === "ts" ? "ts" : "js"),
3210
+ scriptSetupContent: scriptSetup.content,
3211
+ scriptSetupLang: scriptSetup.lang === "ts" ? "ts" : "js",
3212
+ scriptSetupLine: scriptSetup.loc.start.line
3213
+ });
3214
+ }
3215
+ return runCrossFileRules(parsed);
3216
+ }
3217
+ function runCrossFileRules(pages) {
3218
+ const all = [];
3219
+ all.push(...ruleNoSharedKeyAcrossPages(pages));
3220
+ all.push(...ruleSsrSafeOnMountedOnlyForClient(pages));
3221
+ return all;
3222
+ }
3223
+ function ruleNoSharedKeyAcrossPages(pages) {
3224
+ const keyToFiles = /* @__PURE__ */ new Map();
3225
+ for (const page of pages) for (const key of page.keys) {
3226
+ const existing = keyToFiles.get(key) ?? [];
3227
+ if (!existing.includes(page.file)) existing.push(page.file);
3228
+ keyToFiles.set(key, existing);
3229
+ }
3230
+ const diags = [];
3231
+ for (const [key, files] of keyToFiles) {
3232
+ if (files.length < 2) continue;
3233
+ for (const file of files) diags.push({
3234
+ file,
3235
+ line: 1,
3236
+ column: 1,
3237
+ ruleId: "nuxt-doctor/data-fetching/no-shared-key-across-pages",
3238
+ severity: "warn",
3239
+ message: `Data fetching key "${key}" is shared across ${files.length} different page files. This causes cache collisions in useAsyncData/useFetch. Use a unique key per page.`,
3240
+ source: "cross-file",
3241
+ recommendation: `Rename the key to something page-specific, e.g. "page-${key}" or "users-${key}".`
3242
+ });
3243
+ }
3244
+ return diags;
3245
+ }
3246
+ const BROWSER_GLOBALS = new Set([
3247
+ "window",
3248
+ "document",
3249
+ "navigator",
3250
+ "localStorage",
3251
+ "sessionStorage",
3252
+ "location",
3253
+ "history",
3254
+ "fetch",
3255
+ "XMLHttpRequest",
3256
+ "matchMedia",
3257
+ "IntersectionObserver",
3258
+ "MutationObserver",
3259
+ "indexedDB",
3260
+ "webkit",
3261
+ "moz",
3262
+ "onmessage"
3263
+ ]);
3264
+ function ruleSsrSafeOnMountedOnlyForClient(pages) {
3265
+ const diags = [];
3266
+ for (const page of pages) {
3267
+ const { program } = parseSync(`script.${page.scriptSetupLang}`, page.scriptSetupContent, {
3268
+ sourceType: "module",
3269
+ lang: page.scriptSetupLang
3270
+ });
3271
+ for (const stmt of program.body) if (stmt.type === "VariableDeclaration") for (const decl of stmt.declarations) {
3272
+ const init = decl.init;
3273
+ if (!init) continue;
3274
+ if (init.type === "CallExpression") {
3275
+ const call = init;
3276
+ if (isSkippedReactiveCall(call)) continue;
3277
+ checkCallForBrowserGlobal(call, page, diags);
3278
+ } else if (init.type === "MemberExpression") checkMemberForBrowserGlobal(init, page, diags);
3279
+ }
3280
+ else if (stmt.type === "ExpressionStatement") {
3281
+ const expr = stmt.expression;
3282
+ if (expr.type === "CallExpression") {
3283
+ if (isSkippedReactiveCall(expr)) continue;
3284
+ checkCallForBrowserGlobal(expr, page, diags);
3285
+ } else if (expr.type === "MemberExpression") checkMemberForBrowserGlobal(expr, page, diags);
3286
+ }
3287
+ }
3288
+ return diags;
3289
+ }
3290
+ const CLIENT_SAFE_WRAPPERS = new Set([
3291
+ "onMounted",
3292
+ "watchEffect",
3293
+ "watch"
3294
+ ]);
3295
+ function isSkippedReactiveCall(call) {
3296
+ if (call.callee.type !== "Identifier") return false;
3297
+ return CLIENT_SAFE_WRAPPERS.has(call.callee.name);
3298
+ }
3299
+ function checkCallForBrowserGlobal(call, page, diags) {
3300
+ const callee = call.callee;
3301
+ if (callee.type !== "MemberExpression") return;
3302
+ const obj = callee.object;
3303
+ if (obj.type !== "Identifier" || !BROWSER_GLOBALS.has(obj.name)) return;
3304
+ diags.push({
3305
+ file: page.file,
3306
+ line: page.scriptSetupLine,
3307
+ column: 1,
3308
+ ruleId: "nuxt-doctor/data-fetching/ssr-safe-onMounted-only-for-client",
3853
3309
  severity: "warn",
3854
- category: "data-fetching",
3855
- source: "doctor",
3856
- recommended: true
3857
- },
3858
- {
3859
- id: "nuxt-doctor/ai-slop/no-mixed-app-and-root-layout",
3310
+ message: `Browser global "${obj.name}" is accessed at setup top-level outside onMounted. This causes SSR/hydration mismatches. Move browser-only code inside onMounted or a client-only plugin.`,
3311
+ source: "cross-file",
3312
+ recommendation: `Wrap "${obj.name}" access in onMounted(() => { /* code */ }) or use defineNuxtPlugin to run only on the client.`
3313
+ });
3314
+ }
3315
+ function checkMemberForBrowserGlobal(mem, page, diags) {
3316
+ const obj = mem.object;
3317
+ if (obj.type !== "Identifier" || !BROWSER_GLOBALS.has(obj.name)) return;
3318
+ diags.push({
3319
+ file: page.file,
3320
+ line: page.scriptSetupLine,
3321
+ column: 1,
3322
+ ruleId: "nuxt-doctor/data-fetching/ssr-safe-onMounted-only-for-client",
3860
3323
  severity: "warn",
3861
- category: "ai-slop",
3862
- source: "doctor",
3863
- recommended: true
3864
- },
3865
- {
3866
- id: "nuxt-doctor/ai-slop/no-process-client-server",
3867
- severity: "error",
3868
- category: "ai-slop",
3869
- source: "doctor",
3870
- recommended: true
3871
- },
3872
- {
3873
- id: "nuxt-doctor/ai-slop/no-explicit-imports-of-auto-imported",
3324
+ message: `Browser global "${obj.name}" is accessed at setup top-level outside onMounted. This causes SSR/hydration mismatches. Move browser-only code inside onMounted or a client-only plugin.`,
3325
+ source: "cross-file",
3326
+ recommendation: `Wrap "${obj.name}" access in onMounted(() => { /* code */ }) or use defineNuxtPlugin to run only on the client.`
3327
+ });
3328
+ }
3329
+ //#endregion
3330
+ //#region src/template/parse-sfc.ts
3331
+ const cache = /* @__PURE__ */ new Map();
3332
+ async function parseSfc(absPath) {
3333
+ if (cache.has(absPath)) return cache.get(absPath) ?? null;
3334
+ let source;
3335
+ try {
3336
+ source = await readFile(absPath, "utf8");
3337
+ } catch {
3338
+ cache.set(absPath, null);
3339
+ return null;
3340
+ }
3341
+ const { descriptor, errors } = parse(source, { filename: absPath });
3342
+ if (errors.length > 0 || !descriptor.template) {
3343
+ cache.set(absPath, null);
3344
+ return null;
3345
+ }
3346
+ cache.set(absPath, descriptor);
3347
+ return descriptor;
3348
+ }
3349
+ //#endregion
3350
+ //#region src/template/directive-helpers.ts
3351
+ const NODE_DIRECTIVE$4 = 7;
3352
+ function findDirective(el, name) {
3353
+ for (const prop of el.props) if (prop.type === NODE_DIRECTIVE$4 && prop.name === name) return prop;
3354
+ }
3355
+ function findBindAttr(el, attrName) {
3356
+ for (const prop of el.props) {
3357
+ if (prop.type !== NODE_DIRECTIVE$4) continue;
3358
+ const dir = prop;
3359
+ if (dir.name !== "bind") continue;
3360
+ if (dir.arg?.content === attrName) return dir;
3361
+ }
3362
+ }
3363
+ function findStaticAttr(el, attrName) {
3364
+ for (const prop of el.props) if (prop.type === 6 && prop.name === attrName) return prop;
3365
+ }
3366
+ //#endregion
3367
+ //#region src/template/rules/v-for-has-key.ts
3368
+ function check$17(ctx) {
3369
+ const diagnostics = [];
3370
+ walkElements(ctx.template, (el) => {
3371
+ if (!findDirective(el, "for")) return;
3372
+ if (findBindAttr(el, "key") ?? findStaticAttr(el, "key")) return;
3373
+ diagnostics.push({
3374
+ file: ctx.file,
3375
+ line: el.loc.start.line,
3376
+ column: el.loc.start.column,
3377
+ endLine: el.loc.end.line,
3378
+ endColumn: el.loc.end.column,
3379
+ ruleId: "vue-doctor/template/v-for-has-key",
3380
+ severity: "error",
3381
+ message: `<${el.tag}> uses v-for without :key. Vue cannot efficiently diff this list across renders.`,
3382
+ source: "template",
3383
+ recommendation: "Add :key with a stable, unique identifier from the iterated item."
3384
+ });
3385
+ });
3386
+ return { diagnostics };
3387
+ }
3388
+ //#endregion
3389
+ //#region src/template/rules/v-if-v-for-precedence.ts
3390
+ function check$16(ctx) {
3391
+ const diagnostics = [];
3392
+ walkElements(ctx.template, (el) => {
3393
+ const vIf = findDirective(el, "if");
3394
+ const vFor = findDirective(el, "for");
3395
+ if (!vIf || !vFor) return;
3396
+ diagnostics.push({
3397
+ file: ctx.file,
3398
+ line: el.loc.start.line,
3399
+ column: el.loc.start.column,
3400
+ endLine: el.loc.end.line,
3401
+ endColumn: el.loc.end.column,
3402
+ ruleId: "vue-doctor/template/v-if-v-for-precedence",
3403
+ severity: "error",
3404
+ message: `<${el.tag}> uses v-if and v-for on the same element. In Vue 3, v-if binds tighter than v-for, so the condition cannot reference loop variables.`,
3405
+ source: "template",
3406
+ recommendation: "Move the v-if to a <template v-for> wrapper or to a parent element, or filter the iterated source in a computed."
3407
+ });
3408
+ });
3409
+ return { diagnostics };
3410
+ }
3411
+ //#endregion
3412
+ //#region src/template/rules/v-memo-on-large-list.ts
3413
+ const ARRAY_LITERAL_THRESHOLD = 100;
3414
+ function findLargeArrayBindings(script) {
3415
+ const bindings = /* @__PURE__ */ new Map();
3416
+ try {
3417
+ const ast = babelParse(script, { sourceType: "module" });
3418
+ for (const node of ast.program.body) {
3419
+ if (node.type !== "VariableDeclaration") continue;
3420
+ if (node.kind !== "const" && node.kind !== "let") continue;
3421
+ for (const decl of node.declarations) {
3422
+ if (decl.id.type !== "Identifier") continue;
3423
+ const init = decl.init;
3424
+ if (!init || init.type !== "ArrayExpression") continue;
3425
+ if (!init.elements || init.elements.length <= ARRAY_LITERAL_THRESHOLD) continue;
3426
+ bindings.set(decl.id.name, init.elements.length);
3427
+ }
3428
+ }
3429
+ } catch {}
3430
+ return bindings;
3431
+ }
3432
+ function check$15(ctx) {
3433
+ const diagnostics = [];
3434
+ const largeArrays = ctx.script && ctx.script.length > 0 ? findLargeArrayBindings(ctx.script) : /* @__PURE__ */ new Map();
3435
+ walkElements(ctx.template, (el) => {
3436
+ const vFor = findDirective(el, "for");
3437
+ if (!vFor) return;
3438
+ if (findDirective(el, "memo")) return;
3439
+ const source = vFor.forParseResult?.source;
3440
+ if (!source) return;
3441
+ const sourceName = source.content;
3442
+ const largeArraySize = largeArrays.get(sourceName);
3443
+ if (largeArraySize !== void 0 && largeArraySize > ARRAY_LITERAL_THRESHOLD) diagnostics.push({
3444
+ file: ctx.file,
3445
+ line: el.loc.start.line,
3446
+ column: el.loc.start.column,
3447
+ endLine: el.loc.end.line,
3448
+ endColumn: el.loc.end.column,
3449
+ ruleId: "vue-doctor/template/v-memo-on-large-list",
3450
+ severity: "warn",
3451
+ message: `<${el.tag}> uses v-for over a large dataset (array literal with ${largeArraySize} items) without v-memo. Vue cannot skip diffing this list on every render.`,
3452
+ source: "template",
3453
+ recommendation: "Add v-memo with a meaningful memoization key so Vue can skip re-rendering unchanged items."
3454
+ });
3455
+ });
3456
+ return { diagnostics };
3457
+ }
3458
+ //#endregion
3459
+ //#region src/template/rules/no-inline-object-prop-in-list.ts
3460
+ const NODE_DIRECTIVE$3 = 7;
3461
+ function checkElement$2(el, inVForSubtree, ctx, diagnostics) {
3462
+ const isVFor = el.props.some((p) => p.type === NODE_DIRECTIVE$3 && p.name === "for");
3463
+ const effectiveInVFor = inVForSubtree || isVFor;
3464
+ if (effectiveInVFor) for (const prop of el.props) {
3465
+ if (prop.type !== NODE_DIRECTIVE$3) continue;
3466
+ const dir = prop;
3467
+ if (dir.name !== "bind") continue;
3468
+ const attrName = dir.arg?.content;
3469
+ if (!attrName || attrName === "key") continue;
3470
+ const astType = dir.exp?.ast?.type;
3471
+ if (astType !== "ObjectExpression" && astType !== "ArrayExpression") continue;
3472
+ diagnostics.push({
3473
+ file: ctx.file,
3474
+ line: dir.loc.start.line,
3475
+ column: dir.loc.start.column,
3476
+ endLine: dir.loc.end.line,
3477
+ endColumn: dir.loc.end.column,
3478
+ ruleId: "vue-doctor/template/no-inline-object-prop-in-list",
3479
+ severity: "warn",
3480
+ message: `<${el.tag}> has an inline ${attrName} prop with an object or array literal inside a v-for subtree. Inline objects and arrays create new references on every render, defeating v-for's ability to reuse DOM nodes.`,
3481
+ source: "template",
3482
+ recommendation: "Move the object or array to a computed property or a module-level constant so the reference remains stable across renders."
3483
+ });
3484
+ }
3485
+ for (const child of el.children) if (child.type === 1) checkElement$2(child, effectiveInVFor, ctx, diagnostics);
3486
+ }
3487
+ function check$14(ctx) {
3488
+ const diagnostics = [];
3489
+ function walk(node, inVForSubtree) {
3490
+ if (node.type === 1) checkElement$2(node, inVForSubtree, ctx, diagnostics);
3491
+ else if (node.type === 0) {
3492
+ const root = node;
3493
+ for (const child of root.children) walk(child, inVForSubtree);
3494
+ }
3495
+ }
3496
+ walk(ctx.template, false);
3497
+ return { diagnostics };
3498
+ }
3499
+ //#endregion
3500
+ //#region src/template/rules/no-computed-getter-in-template-loop.ts
3501
+ const NODE_ELEMENT$1 = 1;
3502
+ const NODE_INTERPOLATION = 5;
3503
+ const NODE_DIRECTIVE$2 = 7;
3504
+ function isValueDeref(node) {
3505
+ if (node.type !== "MemberExpression" || node.computed === true) return false;
3506
+ const { object, property } = node;
3507
+ return object.type === "Identifier" && property.name === "value";
3508
+ }
3509
+ function containsValueDeref(value) {
3510
+ if (value === null || typeof value !== "object") return false;
3511
+ if (Array.isArray(value)) return value.some((item) => containsValueDeref(item));
3512
+ const node = value;
3513
+ return isValueDeref(node) || Object.values(node).some((child) => containsValueDeref(child));
3514
+ }
3515
+ function pushDiagnostic(el, loc, ctx, diagnostics) {
3516
+ diagnostics.push({
3517
+ file: ctx.file,
3518
+ line: loc.start.line,
3519
+ column: loc.start.column,
3520
+ endLine: loc.end.line,
3521
+ endColumn: loc.end.column,
3522
+ ruleId: "vue-doctor/template/no-computed-getter-in-template-loop",
3874
3523
  severity: "warn",
3875
- category: "ai-slop",
3876
- source: "doctor",
3877
- recommended: true
3878
- },
3524
+ message: `<${el.tag}> reads a ref or computed via .value inside a v-for subtree. The getter re-runs on every item and every render, which is easy to hoist out of the loop.`,
3525
+ source: "template",
3526
+ recommendation: "Read .value once into a computed property or a local binding outside the loop so the getter runs a single time per render."
3527
+ });
3528
+ }
3529
+ function checkElement$1(el, inVForSubtree, ctx, diagnostics) {
3530
+ const isVFor = el.props.some((p) => p.type === NODE_DIRECTIVE$2 && p.name === "for");
3531
+ const effectiveInVFor = inVForSubtree || isVFor;
3532
+ if (effectiveInVFor) {
3533
+ for (const prop of el.props) {
3534
+ if (prop.type !== NODE_DIRECTIVE$2) continue;
3535
+ const dir = prop;
3536
+ if (dir.name !== "bind") continue;
3537
+ if (containsValueDeref(dir.exp?.ast)) pushDiagnostic(el, dir.loc, ctx, diagnostics);
3538
+ }
3539
+ for (const child of el.children) if (child.type === NODE_INTERPOLATION) {
3540
+ const interp = child;
3541
+ if (containsValueDeref(interp.content.ast)) pushDiagnostic(el, interp.loc, ctx, diagnostics);
3542
+ }
3543
+ }
3544
+ for (const child of el.children) if (child.type === NODE_ELEMENT$1) checkElement$1(child, effectiveInVFor, ctx, diagnostics);
3545
+ }
3546
+ function check$13(ctx) {
3547
+ const diagnostics = [];
3548
+ function walk(node, inVForSubtree) {
3549
+ if (node.type === NODE_ELEMENT$1) checkElement$1(node, inVForSubtree, ctx, diagnostics);
3550
+ else if (node.type === 0) {
3551
+ const root = node;
3552
+ for (const child of root.children) walk(child, inVForSubtree);
3553
+ }
3554
+ }
3555
+ walk(ctx.template, false);
3556
+ return { diagnostics };
3557
+ }
3558
+ //#endregion
3559
+ //#region src/template/rules/avoid-deep-v-bind-spread-in-list.ts
3560
+ const NODE_ELEMENT = 1;
3561
+ const NODE_DIRECTIVE$1 = 7;
3562
+ function isIdentifierSpread(dir) {
3563
+ return dir.name === "bind" && dir.arg === void 0 && dir.exp?.ast === null;
3564
+ }
3565
+ function checkElement(el, inVForSubtree, ctx, diagnostics) {
3566
+ const isVFor = el.props.some((p) => p.type === NODE_DIRECTIVE$1 && p.name === "for");
3567
+ const effectiveInVFor = inVForSubtree || isVFor;
3568
+ if (effectiveInVFor) for (const prop of el.props) {
3569
+ if (prop.type !== NODE_DIRECTIVE$1) continue;
3570
+ const dir = prop;
3571
+ if (!isIdentifierSpread(dir)) continue;
3572
+ diagnostics.push({
3573
+ file: ctx.file,
3574
+ line: dir.loc.start.line,
3575
+ column: dir.loc.start.column,
3576
+ endLine: dir.loc.end.line,
3577
+ endColumn: dir.loc.end.column,
3578
+ ruleId: "vue-doctor/template/avoid-deep-v-bind-spread-in-list",
3579
+ severity: "info",
3580
+ message: `<${el.tag}> spreads a whole object via v-bind inside a v-for subtree. Spreading a reactive object binds every property per item, which can churn props and defeat patching on large lists.`,
3581
+ source: "template",
3582
+ recommendation: "Bind only the props each item needs explicitly, or hoist a stable per-item object so Vue can patch instead of re-binding the full spread."
3583
+ });
3584
+ }
3585
+ for (const child of el.children) if (child.type === NODE_ELEMENT) checkElement(child, effectiveInVFor, ctx, diagnostics);
3586
+ }
3587
+ function check$12(ctx) {
3588
+ const diagnostics = [];
3589
+ function walk(node, inVForSubtree) {
3590
+ if (node.type === NODE_ELEMENT) checkElement(node, inVForSubtree, ctx, diagnostics);
3591
+ else if (node.type === 0) {
3592
+ const root = node;
3593
+ for (const child of root.children) walk(child, inVForSubtree);
3594
+ }
3595
+ }
3596
+ walk(ctx.template, false);
3597
+ return { diagnostics };
3598
+ }
3599
+ //#endregion
3600
+ //#region src/template/rules/no-random-key.ts
3601
+ const UNSTABLE_KEY_CALL = /\b(?:Math\.random|Date\.now|performance\.now|crypto\.randomUUID|uuid|uuidv4|nanoid|uniqueId|cuid|ulid)\s*\(/;
3602
+ function keyExpression(el) {
3603
+ const bind = findBindAttr(el, "key");
3604
+ if (!bind) return void 0;
3605
+ return bind;
3606
+ }
3607
+ function unstableReason(keyContent, indexVar) {
3608
+ if (UNSTABLE_KEY_CALL.test(keyContent)) return "a value that changes on every render (e.g. Math.random(), Date.now(), or a fresh uuid)";
3609
+ if (indexVar !== void 0 && keyContent.trim() === indexVar) return "the v-for index, which is unstable when the list is reordered, filtered, or spliced";
3610
+ }
3611
+ function check$11(ctx) {
3612
+ const diagnostics = [];
3613
+ walkElements(ctx.template, (el) => {
3614
+ const vFor = findDirective(el, "for");
3615
+ if (!vFor) return;
3616
+ const bind = keyExpression(el);
3617
+ const keyContent = bind?.exp?.content;
3618
+ if (!bind || keyContent === void 0) return;
3619
+ const reason = unstableReason(keyContent, vFor.forParseResult?.key?.content ?? void 0);
3620
+ if (reason === void 0) return;
3621
+ diagnostics.push({
3622
+ file: ctx.file,
3623
+ line: bind.loc.start.line,
3624
+ column: bind.loc.start.column,
3625
+ endLine: bind.loc.end.line,
3626
+ endColumn: bind.loc.end.column,
3627
+ ruleId: "vue-doctor/template/no-random-key",
3628
+ severity: "warn",
3629
+ message: `<${el.tag}> uses ${reason} as its v-for :key. Unstable keys defeat Vue's DOM reuse and force a full re-mount of each item, losing component state and hurting performance.`,
3630
+ source: "template",
3631
+ recommendation: "Use a stable, unique id from the item itself (e.g. :key=\"item.id\") so Vue can reuse DOM nodes across renders."
3632
+ });
3633
+ });
3634
+ return { diagnostics };
3635
+ }
3636
+ //#endregion
3637
+ //#region src/template/rules/no-v-memo-in-vapor.ts
3638
+ function check$10(ctx) {
3639
+ const diagnostics = [];
3640
+ walkElements(ctx.template, (el) => {
3641
+ const vMemo = findDirective(el, "memo");
3642
+ if (!vMemo) return;
3643
+ diagnostics.push({
3644
+ file: ctx.file,
3645
+ line: vMemo.loc.start.line,
3646
+ column: vMemo.loc.start.column,
3647
+ endLine: vMemo.loc.end.line,
3648
+ endColumn: vMemo.loc.end.column,
3649
+ ruleId: "vue-doctor/template/no-v-memo-in-vapor",
3650
+ severity: "warn",
3651
+ message: `<${el.tag}> uses v-memo in a Vapor-mode component. Vapor compiles to fine-grained DOM updates with no virtual-DOM diff, so v-memo is a no-op here and only adds noise.`,
3652
+ source: "template",
3653
+ recommendation: "Remove v-memo from this Vapor component; fine-grained reactivity already avoids re-rendering unchanged nodes."
3654
+ });
3655
+ });
3656
+ return { diagnostics };
3657
+ }
3658
+ //#endregion
3659
+ //#region src/template/rules/no-v-html.ts
3660
+ function check$9(ctx) {
3661
+ const diagnostics = [];
3662
+ walkElements(ctx.template, (el) => {
3663
+ const vHtml = findDirective(el, "html");
3664
+ if (!vHtml) return;
3665
+ diagnostics.push({
3666
+ file: ctx.file,
3667
+ line: vHtml.loc.start.line,
3668
+ column: vHtml.loc.start.column,
3669
+ endLine: vHtml.loc.end.line,
3670
+ endColumn: vHtml.loc.end.column,
3671
+ ruleId: "vue-doctor/security/no-v-html",
3672
+ severity: "error",
3673
+ message: `<${el.tag}> uses v-html, which renders raw HTML and bypasses Vue's escaping — the primary XSS sink in Vue apps.`,
3674
+ source: "template",
3675
+ recommendation: "Render text with {{ }} interpolation, or sanitize the HTML with DOMPurify in a computed before binding it."
3676
+ });
3677
+ });
3678
+ return { diagnostics };
3679
+ }
3680
+ //#endregion
3681
+ //#region src/template/rules/no-target-blank-without-rel.ts
3682
+ function unquote$1(raw) {
3683
+ const trimmed = raw.trim();
3684
+ const first = trimmed.charAt(0);
3685
+ if ((first === "\"" || first === "'") && trimmed.endsWith(first)) return trimmed.slice(1, -1);
3686
+ return trimmed;
3687
+ }
3688
+ function attrValue(el, name) {
3689
+ const staticAttr = findStaticAttr(el, name);
3690
+ if (staticAttr) return {
3691
+ value: staticAttr.value?.content ?? "",
3692
+ loc: staticAttr.loc
3693
+ };
3694
+ const bound = findBindAttr(el, name);
3695
+ if (bound?.exp && "content" in bound.exp) return {
3696
+ value: unquote$1(bound.exp.content),
3697
+ loc: bound.loc
3698
+ };
3699
+ }
3700
+ function check$8(ctx) {
3701
+ const diagnostics = [];
3702
+ walkElements(ctx.template, (el) => {
3703
+ if (el.tag !== "a") return;
3704
+ const target = attrValue(el, "target");
3705
+ if (!target || target.value !== "_blank") return;
3706
+ const rel = attrValue(el, "rel");
3707
+ if (rel && rel.value.includes("noopener")) return;
3708
+ diagnostics.push({
3709
+ file: ctx.file,
3710
+ line: target.loc.start.line,
3711
+ column: target.loc.start.column,
3712
+ endLine: target.loc.end.line,
3713
+ endColumn: target.loc.end.column,
3714
+ ruleId: "vue-doctor/security/no-target-blank-without-rel",
3715
+ severity: "warn",
3716
+ message: `<a target="_blank"> without rel="noopener noreferrer" exposes the opener window to reverse-tabnabbing.`,
3717
+ source: "template",
3718
+ recommendation: "Add rel=\"noopener noreferrer\" to anchors that open in a new tab."
3719
+ });
3720
+ });
3721
+ return { diagnostics };
3722
+ }
3723
+ //#endregion
3724
+ //#region src/template/rules/no-javascript-uri.ts
3725
+ const NODE_ATTRIBUTE$1 = 6;
3726
+ const URI_SINKS = new Set([
3727
+ "href",
3728
+ "src",
3729
+ "srcset",
3730
+ "action",
3731
+ "xlink:href",
3732
+ "formaction",
3733
+ "poster",
3734
+ "data"
3735
+ ]);
3736
+ const DANGEROUS_SCHEME = /^\s*(?:javascript:|vbscript:|data:text\/html)/i;
3737
+ function unquote(raw) {
3738
+ const trimmed = raw.trim();
3739
+ const first = trimmed.charAt(0);
3740
+ if ((first === "\"" || first === "'" || first === "`") && trimmed.endsWith(first)) return trimmed.slice(1, -1);
3741
+ return trimmed;
3742
+ }
3743
+ function resolveUriProp(prop) {
3744
+ if (prop.type === NODE_ATTRIBUTE$1) {
3745
+ const attr = prop;
3746
+ if (attr.value === void 0) return void 0;
3747
+ return {
3748
+ name: attr.name,
3749
+ value: attr.value.content,
3750
+ loc: attr.loc
3751
+ };
3752
+ }
3753
+ const dir = prop;
3754
+ if (dir.name !== "bind") return void 0;
3755
+ if (!dir.arg || !("content" in dir.arg)) return void 0;
3756
+ if (!dir.exp || !("content" in dir.exp)) return void 0;
3757
+ return {
3758
+ name: dir.arg.content,
3759
+ value: unquote(dir.exp.content),
3760
+ loc: dir.loc
3761
+ };
3762
+ }
3763
+ function check$7(ctx) {
3764
+ const diagnostics = [];
3765
+ walkElements(ctx.template, (el) => {
3766
+ for (const prop of el.props) {
3767
+ const resolved = resolveUriProp(prop);
3768
+ if (!resolved) continue;
3769
+ if (!URI_SINKS.has(resolved.name)) continue;
3770
+ if (!DANGEROUS_SCHEME.test(resolved.value)) continue;
3771
+ diagnostics.push({
3772
+ file: ctx.file,
3773
+ line: resolved.loc.start.line,
3774
+ column: resolved.loc.start.column,
3775
+ endLine: resolved.loc.end.line,
3776
+ endColumn: resolved.loc.end.column,
3777
+ ruleId: "vue-doctor/security/no-javascript-uri",
3778
+ severity: "error",
3779
+ message: `<${el.tag}> binds ${resolved.name} to a dangerous URI scheme (javascript:/vbscript:/data:text/html), which executes arbitrary code.`,
3780
+ source: "template",
3781
+ recommendation: "Use a safe http(s) or relative URL; never put a javascript:, vbscript:, or data:text/html value in a URL attribute."
3782
+ });
3783
+ }
3784
+ });
3785
+ return { diagnostics };
3786
+ }
3787
+ //#endregion
3788
+ //#region src/template/class-attr-helpers.ts
3789
+ const NODE_ATTRIBUTE = 6;
3790
+ const NODE_DIRECTIVE = 7;
3791
+ /**
3792
+ * Collect the scannable text of every `class` source on an element: the static
3793
+ * `class="..."` value and any bound `:class` expression source. Bound
3794
+ * expressions are returned verbatim so a regex can match string-literal class
3795
+ * fragments inside ternaries, arrays, and objects without a full JS parse.
3796
+ */
3797
+ function collectClassSources(el) {
3798
+ const sources = [];
3799
+ for (const prop of el.props) {
3800
+ if (prop.type === NODE_ATTRIBUTE) {
3801
+ const attr = prop;
3802
+ if (attr.name === "class" && attr.value) sources.push({
3803
+ text: attr.value.content,
3804
+ loc: attr.loc
3805
+ });
3806
+ }
3807
+ if (prop.type === NODE_DIRECTIVE) {
3808
+ const dir = prop;
3809
+ if (dir.name === "bind" && dir.arg && "content" in dir.arg && dir.arg.content === "class" && dir.exp && "content" in dir.exp) sources.push({
3810
+ text: dir.exp.content,
3811
+ loc: dir.loc
3812
+ });
3813
+ }
3814
+ }
3815
+ return sources;
3816
+ }
3817
+ /** Find a static `style="..."` attribute on an element, if present. */
3818
+ function findStaticStyle(el) {
3819
+ for (const prop of el.props) if (prop.type === NODE_ATTRIBUTE && prop.name === "style") return prop;
3820
+ }
3821
+ //#endregion
3822
+ //#region src/template/rules/no-arbitrary-tailwind-values.ts
3823
+ const ARBITRARY_VALUE = /\[[^\]]+\]/;
3824
+ function check$6(ctx) {
3825
+ const diagnostics = [];
3826
+ walkElements(ctx.template, (el) => {
3827
+ for (const source of collectClassSources(el)) {
3828
+ if (!ARBITRARY_VALUE.test(source.text)) continue;
3829
+ diagnostics.push({
3830
+ file: ctx.file,
3831
+ line: source.loc.start.line,
3832
+ column: source.loc.start.column,
3833
+ endLine: source.loc.end.line,
3834
+ endColumn: source.loc.end.column,
3835
+ ruleId: "vue-doctor/design/no-arbitrary-tailwind-values",
3836
+ severity: "warn",
3837
+ message: `<${el.tag}> uses a Tailwind arbitrary value (e.g. w-[412px]). Arbitrary values are design-system debt; repeated values should become @theme tokens.`,
3838
+ source: "template",
3839
+ recommendation: "Replace the arbitrary value with a design token defined in your @theme (e.g. w-container-lg)."
3840
+ });
3841
+ }
3842
+ });
3843
+ return { diagnostics };
3844
+ }
3845
+ //#endregion
3846
+ //#region src/template/rules/no-raw-hex-color.ts
3847
+ const HEX_COLOR = /#(?:[0-9a-fA-F]{3,4}|[0-9a-fA-F]{6}|[0-9a-fA-F]{8})\b/;
3848
+ const RULE_ID$1 = "vue-doctor/design/no-raw-hex-color";
3849
+ const MESSAGE$1 = "Raw hex color bypasses the token system and breaks theming. Use a semantic color token instead.";
3850
+ const RECOMMENDATION$1 = "Replace the hex color with a design token (e.g. bg-danger / text-success) defined in your @theme.";
3851
+ function check$5(ctx) {
3852
+ const diagnostics = [];
3853
+ walkElements(ctx.template, (el) => {
3854
+ for (const source of collectClassSources(el)) {
3855
+ if (!HEX_COLOR.test(source.text)) continue;
3856
+ diagnostics.push({
3857
+ file: ctx.file,
3858
+ line: source.loc.start.line,
3859
+ column: source.loc.start.column,
3860
+ endLine: source.loc.end.line,
3861
+ endColumn: source.loc.end.column,
3862
+ ruleId: RULE_ID$1,
3863
+ severity: "warn",
3864
+ message: `<${el.tag}> embeds a raw hex color in a class. ${MESSAGE$1}`,
3865
+ source: "template",
3866
+ recommendation: RECOMMENDATION$1
3867
+ });
3868
+ }
3869
+ const style = findStaticStyle(el);
3870
+ if (style?.value && HEX_COLOR.test(style.value.content)) diagnostics.push({
3871
+ file: ctx.file,
3872
+ line: style.loc.start.line,
3873
+ column: style.loc.start.column,
3874
+ endLine: style.loc.end.line,
3875
+ endColumn: style.loc.end.column,
3876
+ ruleId: RULE_ID$1,
3877
+ severity: "warn",
3878
+ message: `<${el.tag}> embeds a raw hex color in an inline style. ${MESSAGE$1}`,
3879
+ source: "template",
3880
+ recommendation: RECOMMENDATION$1
3881
+ });
3882
+ });
3883
+ return { diagnostics };
3884
+ }
3885
+ //#endregion
3886
+ //#region src/template/rules/no-default-tailwind-palette.ts
3887
+ const DEFAULT_PALETTE = /(?:^|[\s:'"`])(?:bg|text|border|ring|shadow|from|via|to|accent|caret|fill|stroke|outline|decoration|divide|ring-offset|placeholder)-(?:slate|gray|zinc|neutral|stone|red|orange|amber|yellow|lime|green|emerald|teal|cyan|sky|blue|indigo|violet|purple|fuchsia|pink|rose)-\d{2,3}\b/;
3888
+ function check$4(ctx) {
3889
+ const diagnostics = [];
3890
+ walkElements(ctx.template, (el) => {
3891
+ for (const source of collectClassSources(el)) {
3892
+ if (!DEFAULT_PALETTE.test(source.text)) continue;
3893
+ diagnostics.push({
3894
+ file: ctx.file,
3895
+ line: source.loc.start.line,
3896
+ column: source.loc.start.column,
3897
+ endLine: source.loc.end.line,
3898
+ endColumn: source.loc.end.column,
3899
+ ruleId: "vue-doctor/design/no-default-tailwind-palette",
3900
+ severity: "warn",
3901
+ message: `<${el.tag}> uses a default Tailwind palette utility (e.g. bg-blue-600). The default palette produces generic UI; map colors to brand tokens.`,
3902
+ source: "template",
3903
+ recommendation: "Map the color to a brand token (e.g. bg-primary-600) defined in your @theme instead of the default palette."
3904
+ });
3905
+ }
3906
+ });
3907
+ return { diagnostics };
3908
+ }
3909
+ //#endregion
3910
+ //#region src/template/rules/no-important-utility.ts
3911
+ const IMPORTANT_UTILITY = /(?:^|[\s:'"`])!-?[a-z][a-z0-9-]*/;
3912
+ function check$3(ctx) {
3913
+ const diagnostics = [];
3914
+ walkElements(ctx.template, (el) => {
3915
+ for (const source of collectClassSources(el)) {
3916
+ if (!IMPORTANT_UTILITY.test(source.text)) continue;
3917
+ diagnostics.push({
3918
+ file: ctx.file,
3919
+ line: source.loc.start.line,
3920
+ column: source.loc.start.column,
3921
+ endLine: source.loc.end.line,
3922
+ endColumn: source.loc.end.column,
3923
+ ruleId: "vue-doctor/design/no-important-utility",
3924
+ severity: "warn",
3925
+ message: `<${el.tag}> uses a !important Tailwind utility (e.g. !bg-red-500). !important is a specificity escape hatch that makes maintenance harder.`,
3926
+ source: "template",
3927
+ recommendation: "Remove the ! prefix and resolve the specificity conflict at its source instead of forcing !important."
3928
+ });
3929
+ }
3930
+ });
3931
+ return { diagnostics };
3932
+ }
3933
+ //#endregion
3934
+ //#region src/template/rules/no-hardcoded-inline-style.ts
3935
+ const HARDCODED_VALUE = /\d+px\b|#(?:[0-9a-fA-F]{3,4}|[0-9a-fA-F]{6}|[0-9a-fA-F]{8})\b/;
3936
+ function check$2(ctx) {
3937
+ const diagnostics = [];
3938
+ walkElements(ctx.template, (el) => {
3939
+ const style = findStaticStyle(el);
3940
+ if (!style?.value) return;
3941
+ if (!HARDCODED_VALUE.test(style.value.content)) return;
3942
+ diagnostics.push({
3943
+ file: ctx.file,
3944
+ line: style.loc.start.line,
3945
+ column: style.loc.start.column,
3946
+ endLine: style.loc.end.line,
3947
+ endColumn: style.loc.end.column,
3948
+ ruleId: "vue-doctor/design/no-hardcoded-inline-style",
3949
+ severity: "warn",
3950
+ message: `<${el.tag}> uses an inline style with hardcoded px or hex values. Inline styles bypass tokens, hurt caching, and create drift.`,
3951
+ source: "template",
3952
+ recommendation: "Move the styling to token-based utility classes (e.g. w-60 text-neutral-700) instead of an inline style."
3953
+ });
3954
+ });
3955
+ return { diagnostics };
3956
+ }
3957
+ //#endregion
3958
+ //#region src/template/rules/no-missing-alt.ts
3959
+ const IMAGE_TAGS = new Set([
3960
+ "img",
3961
+ "NuxtImg",
3962
+ "nuxt-img"
3963
+ ]);
3964
+ function check$1(ctx) {
3965
+ const diagnostics = [];
3966
+ walkElements(ctx.template, (el) => {
3967
+ if (!IMAGE_TAGS.has(el.tag)) return;
3968
+ if (findStaticAttr(el, "alt") ?? findBindAttr(el, "alt")) return;
3969
+ diagnostics.push({
3970
+ file: ctx.file,
3971
+ line: el.loc.start.line,
3972
+ column: el.loc.start.column,
3973
+ endLine: el.loc.end.line,
3974
+ endColumn: el.loc.end.column,
3975
+ ruleId: "vue-doctor/design/no-missing-alt",
3976
+ severity: "warn",
3977
+ message: `<${el.tag}> has no alt attribute. Screen readers and SEO depend on descriptive alt text.`,
3978
+ source: "template",
3979
+ recommendation: "Add an alt attribute describing the image, or alt=\"\" for purely decorative images."
3980
+ });
3981
+ });
3982
+ return { diagnostics };
3983
+ }
3984
+ //#endregion
3985
+ //#region src/template/rules/no-absurd-z-index.ts
3986
+ const ABSURD_THRESHOLD = 1e3;
3987
+ const ARBITRARY_Z = /(?:^|[\s:'"`])-?z-\[(\d+)\]/;
3988
+ const INLINE_Z = /z-index\s*:\s*(\d+)/;
3989
+ const RULE_ID = "vue-doctor/design/no-absurd-z-index";
3990
+ const MESSAGE = "huge z-index values usually paper over layering bugs instead of fixing stacking context.";
3991
+ const RECOMMENDATION = "Use a small, tokenized z-index scale (e.g. z-modal) and fix the underlying stacking context.";
3992
+ function check(ctx) {
3993
+ const diagnostics = [];
3994
+ walkElements(ctx.template, (el) => {
3995
+ for (const source of collectClassSources(el)) {
3996
+ const match = ARBITRARY_Z.exec(source.text);
3997
+ if (!match || Number(match[1]) < ABSURD_THRESHOLD) continue;
3998
+ diagnostics.push({
3999
+ file: ctx.file,
4000
+ line: source.loc.start.line,
4001
+ column: source.loc.start.column,
4002
+ endLine: source.loc.end.line,
4003
+ endColumn: source.loc.end.column,
4004
+ ruleId: RULE_ID,
4005
+ severity: "warn",
4006
+ message: `<${el.tag}> uses an absurd z-index class (>= ${ABSURD_THRESHOLD}); ${MESSAGE}`,
4007
+ source: "template",
4008
+ recommendation: RECOMMENDATION
4009
+ });
4010
+ }
4011
+ const style = findStaticStyle(el);
4012
+ if (style?.value) {
4013
+ const match = INLINE_Z.exec(style.value.content);
4014
+ if (match && Number(match[1]) >= ABSURD_THRESHOLD) diagnostics.push({
4015
+ file: ctx.file,
4016
+ line: style.loc.start.line,
4017
+ column: style.loc.start.column,
4018
+ endLine: style.loc.end.line,
4019
+ endColumn: style.loc.end.column,
4020
+ ruleId: RULE_ID,
4021
+ severity: "warn",
4022
+ message: `<${el.tag}> uses an absurd inline z-index (>= ${ABSURD_THRESHOLD}); ${MESSAGE}`,
4023
+ source: "template",
4024
+ recommendation: RECOMMENDATION
4025
+ });
4026
+ }
4027
+ });
4028
+ return { diagnostics };
4029
+ }
4030
+ //#endregion
4031
+ //#region src/template/rules/index.ts
4032
+ const TEMPLATE_RULES = [
3879
4033
  {
3880
- id: "nuxt-doctor/ai-slop/no-useState-for-server-data",
3881
- severity: "warn",
3882
- category: "ai-slop",
3883
- source: "doctor",
3884
- recommended: true
4034
+ id: "vue-doctor/template/v-for-has-key",
4035
+ check: check$17
3885
4036
  },
3886
4037
  {
3887
- id: "nuxt-doctor/ai-slop/no-fetch-in-setup",
3888
- severity: "warn",
3889
- category: "ai-slop",
3890
- source: "doctor",
3891
- recommended: true
4038
+ id: "vue-doctor/template/v-if-v-for-precedence",
4039
+ check: check$16
3892
4040
  },
3893
4041
  {
3894
- id: "nuxt-doctor/data-fetching/useAsyncData-key-required-in-loop",
3895
- severity: "error",
3896
- category: "data-fetching",
3897
- source: "doctor",
3898
- recommended: true
4042
+ id: "vue-doctor/template/v-memo-on-large-list",
4043
+ check: check$15,
4044
+ disabledBy: ["vue:vapor"]
3899
4045
  },
3900
4046
  {
3901
- id: "nuxt-doctor/server-routes/defineEventHandler-typed",
3902
- severity: "warn",
3903
- category: "server-routes",
3904
- source: "doctor",
3905
- recommended: true
4047
+ id: "vue-doctor/template/no-inline-object-prop-in-list",
4048
+ check: check$14
3906
4049
  },
3907
4050
  {
3908
- id: "nuxt-doctor/server-routes/validate-body-with-h3-v2",
3909
- severity: "warn",
3910
- category: "server-routes",
3911
- source: "doctor",
3912
- recommended: true
4051
+ id: "vue-doctor/template/no-computed-getter-in-template-loop",
4052
+ check: check$13
3913
4053
  },
3914
4054
  {
3915
- id: "nuxt-doctor/server-routes/createError-on-failure",
3916
- severity: "warn",
3917
- category: "server-routes",
3918
- source: "doctor",
3919
- recommended: true
4055
+ id: "vue-doctor/template/avoid-deep-v-bind-spread-in-list",
4056
+ check: check$12
3920
4057
  },
3921
4058
  {
3922
- id: "nuxt-doctor/hydration/no-document-in-setup",
3923
- severity: "error",
3924
- category: "hydration",
3925
- source: "doctor",
3926
- recommended: true
4059
+ id: "vue-doctor/template/no-random-key",
4060
+ check: check$11
3927
4061
  },
3928
4062
  {
3929
- id: "nuxt-doctor/hydration/clientOnly-for-browser-apis",
3930
- severity: "error",
3931
- category: "hydration",
3932
- source: "doctor",
3933
- recommended: true
4063
+ id: "vue-doctor/template/no-v-memo-in-vapor",
4064
+ check: check$10,
4065
+ requires: ["vue:vapor"]
3934
4066
  },
3935
4067
  {
3936
4068
  id: "vue-doctor/security/no-v-html",
3937
- severity: "error",
3938
- category: "security",
3939
- source: "doctor",
3940
- recommended: true
3941
- },
3942
- {
3943
- id: "vue-doctor/security/no-inner-html",
3944
- severity: "error",
3945
- category: "security",
3946
- source: "doctor",
3947
- recommended: true
3948
- },
3949
- {
3950
- id: "vue-doctor/security/no-eval-like",
3951
- severity: "error",
3952
- category: "security",
3953
- source: "doctor",
3954
- recommended: true
3955
- },
3956
- {
3957
- id: "vue-doctor/security/no-auth-token-in-web-storage",
3958
- severity: "warn",
3959
- category: "security",
3960
- source: "doctor",
3961
- recommended: true
3962
- },
3963
- {
3964
- id: "vue-doctor/security/no-secrets-in-source",
3965
- severity: "warn",
3966
- category: "security",
3967
- source: "doctor",
3968
- recommended: true
4069
+ check: check$9
3969
4070
  },
3970
4071
  {
3971
4072
  id: "vue-doctor/security/no-target-blank-without-rel",
3972
- severity: "warn",
3973
- category: "security",
3974
- source: "doctor",
3975
- recommended: true
4073
+ check: check$8
3976
4074
  },
3977
4075
  {
3978
4076
  id: "vue-doctor/security/no-javascript-uri",
3979
- severity: "error",
3980
- category: "security",
3981
- source: "doctor",
3982
- recommended: true
3983
- },
3984
- {
3985
- id: "nuxt-doctor/security/no-secret-in-public-runtime-config",
3986
- severity: "error",
3987
- category: "security",
3988
- source: "doctor",
3989
- recommended: true
3990
- },
3991
- {
3992
- id: "nuxt-doctor/security/no-user-input-in-fetch-url",
3993
- severity: "warn",
3994
- category: "security",
3995
- source: "doctor",
3996
- recommended: true
4077
+ check: check$7
3997
4078
  },
3998
4079
  {
3999
4080
  id: "vue-doctor/design/no-arbitrary-tailwind-values",
4000
- severity: "warn",
4001
- category: "design",
4002
- source: "doctor",
4003
- recommended: true
4081
+ check: check$6
4004
4082
  },
4005
4083
  {
4006
4084
  id: "vue-doctor/design/no-raw-hex-color",
4007
- severity: "warn",
4008
- category: "design",
4009
- source: "doctor",
4010
- recommended: true
4085
+ check: check$5
4011
4086
  },
4012
4087
  {
4013
4088
  id: "vue-doctor/design/no-default-tailwind-palette",
4014
- severity: "warn",
4015
- category: "design",
4016
- source: "doctor",
4017
- recommended: true
4089
+ check: check$4
4018
4090
  },
4019
4091
  {
4020
4092
  id: "vue-doctor/design/no-important-utility",
4021
- severity: "warn",
4022
- category: "design",
4023
- source: "doctor",
4024
- recommended: true
4093
+ check: check$3
4025
4094
  },
4026
4095
  {
4027
4096
  id: "vue-doctor/design/no-hardcoded-inline-style",
4028
- severity: "warn",
4029
- category: "design",
4030
- source: "doctor",
4031
- recommended: true
4097
+ check: check$2
4032
4098
  },
4033
4099
  {
4034
4100
  id: "vue-doctor/design/no-missing-alt",
4035
- severity: "warn",
4036
- category: "design",
4037
- source: "doctor",
4038
- recommended: true
4101
+ check: check$1
4039
4102
  },
4040
4103
  {
4041
4104
  id: "vue-doctor/design/no-absurd-z-index",
4042
- severity: "warn",
4043
- category: "design",
4044
- source: "doctor",
4045
- recommended: true
4105
+ check
4106
+ }
4107
+ ];
4108
+ //#endregion
4109
+ //#region src/template/vapor.ts
4110
+ const VAPOR_CAPABILITY = "vue:vapor";
4111
+ /**
4112
+ * Detect whether an SFC opts into Vue Vapor mode. Vapor is enabled per-block
4113
+ * via a boolean `vapor` attribute on `<script setup vapor>` or `<template vapor>`
4114
+ * (Vue 3.6+). A string value (e.g. `vapor="false"`) does not enable it.
4115
+ */
4116
+ function isVaporSfc(descriptor) {
4117
+ if (descriptor.scriptSetup?.attrs?.["vapor"] === true) return true;
4118
+ if (descriptor.template?.attrs?.["vapor"] === true) return true;
4119
+ return false;
4120
+ }
4121
+ //#endregion
4122
+ //#region src/template/run.ts
4123
+ function capabilityGated(rule, caps) {
4124
+ if (rule.disabledBy?.some((c) => caps.has(c))) return true;
4125
+ if (rule.requires?.some((c) => !caps.has(c))) return true;
4126
+ return false;
4127
+ }
4128
+ async function runTemplatePass(opts) {
4129
+ const all = [];
4130
+ for (const file of opts.files) {
4131
+ if (!file.endsWith(".vue")) continue;
4132
+ const descriptor = await parseSfc(file);
4133
+ if (!descriptor?.template?.ast) continue;
4134
+ const caps = /* @__PURE__ */ new Set();
4135
+ if (isVaporSfc(descriptor)) caps.add(VAPOR_CAPABILITY);
4136
+ for (const rule of TEMPLATE_RULES) {
4137
+ const override = opts.ruleOverrides?.[rule.id];
4138
+ if (override === "off") continue;
4139
+ if (capabilityGated(rule, caps)) continue;
4140
+ const { diagnostics } = rule.check({
4141
+ file,
4142
+ template: descriptor.template.ast,
4143
+ script: descriptor.scriptSetup?.content ?? descriptor.script?.content
4144
+ });
4145
+ for (const d of diagnostics) all.push(override ? {
4146
+ ...d,
4147
+ severity: override
4148
+ } : d);
4149
+ }
4046
4150
  }
4151
+ return all;
4152
+ }
4153
+ //#endregion
4154
+ //#region src/audit.ts
4155
+ const DEFAULT_INCLUDE = [
4156
+ "**/*.vue",
4157
+ "**/*.ts",
4158
+ "**/*.tsx",
4159
+ "**/*.js",
4160
+ "**/*.jsx"
4047
4161
  ];
4048
- function listRules(filter = {}) {
4049
- let rules = [...RULE_REGISTRY];
4050
- if (filter.preset === "recommended") rules = rules.filter((r) => r.recommended);
4051
- if (filter.category) rules = rules.filter((r) => r.category === filter.category);
4052
- if (filter.source) rules = rules.filter((r) => r.source === filter.source);
4053
- if (filter.severity) rules = rules.filter((r) => r.severity === filter.severity);
4054
- return rules.sort((a, b) => a.id.localeCompare(b.id));
4162
+ const DEFAULT_EXCLUDE = [
4163
+ "node_modules",
4164
+ "dist",
4165
+ ".nuxt",
4166
+ ".output",
4167
+ "coverage"
4168
+ ];
4169
+ function countRuleCounts(diagnostics) {
4170
+ const counts = {};
4171
+ for (const d of diagnostics) counts[d.ruleId] = (counts[d.ruleId] ?? 0) + 1;
4172
+ return counts;
4173
+ }
4174
+ async function audit(config = {}) {
4175
+ const rootDir = resolve(config.rootDir ?? process.cwd());
4176
+ const include = config.include ?? DEFAULT_INCLUDE;
4177
+ const exclude = config.exclude ?? DEFAULT_EXCLUDE;
4178
+ const failOn = config.failOn ?? "error";
4179
+ const threshold = config.threshold ?? 0;
4180
+ const lintEnabled = config.lint !== false;
4181
+ const files = await listSourceFiles({
4182
+ rootDir,
4183
+ include,
4184
+ exclude
4185
+ });
4186
+ const overallStart = performance.now();
4187
+ const project = await detectProject(rootDir);
4188
+ const templateStart = performance.now();
4189
+ const templateDiagnostics = lintEnabled ? await runTemplatePass({
4190
+ files,
4191
+ ruleOverrides: config.rules
4192
+ }) : [];
4193
+ const templateElapsed = performance.now() - templateStart;
4194
+ const sfcStart = performance.now();
4195
+ const sfcDiagnostics = lintEnabled ? await runSfcPass({
4196
+ files,
4197
+ ruleOverrides: config.rules,
4198
+ projectInfo: project
4199
+ }) : [];
4200
+ const sfcElapsed = performance.now() - sfcStart;
4201
+ let scriptDiagnostics = [];
4202
+ let oxlintStderr = "";
4203
+ const scriptStart = performance.now();
4204
+ if (lintEnabled) try {
4205
+ const result = await runScriptPass({
4206
+ rootDir,
4207
+ targetPath: rootDir,
4208
+ ruleOverrides: config.rules,
4209
+ framework: project.framework === "nuxt" ? "nuxt" : "vue",
4210
+ fix: config.fix === true && config.scopeFiles === void 0,
4211
+ fixExcludes: config.fixExcludes,
4212
+ exclude
4213
+ });
4214
+ scriptDiagnostics = result.diagnostics;
4215
+ oxlintStderr = result.stderr;
4216
+ } catch (err) {
4217
+ oxlintStderr = err instanceof Error ? err.message : String(err);
4218
+ if (process.env.DOCTOR_DEBUG) process.stderr.write(`[doctor-core] script pass failed: ${oxlintStderr}\n`);
4219
+ }
4220
+ const scriptElapsed = performance.now() - scriptStart;
4221
+ let deadCodeDiagnostics = [];
4222
+ let deadCodeElapsed = 0;
4223
+ if (config.deadCode !== false) {
4224
+ const deadCodeStart = performance.now();
4225
+ try {
4226
+ const { loadDoctorConfig } = await Promise.resolve().then(() => load_exports);
4227
+ deadCodeDiagnostics = dedupeDeadCodeAgainstLint(await checkDeadCode({
4228
+ projectInfo: project,
4229
+ doctorConfig: await loadDoctorConfig(rootDir),
4230
+ enabled: true
4231
+ }), [
4232
+ ...templateDiagnostics,
4233
+ ...sfcDiagnostics,
4234
+ ...scriptDiagnostics
4235
+ ]).filter((d) => config.rules?.[d.ruleId] !== "off");
4236
+ } catch (err) {
4237
+ if (process.env.DOCTOR_DEBUG) process.stderr.write(`[doctor-core] dead-code pass failed: ${String(err)}\n`);
4238
+ }
4239
+ deadCodeElapsed = performance.now() - deadCodeStart;
4240
+ }
4241
+ let buildQualityDiagnostics = [];
4242
+ try {
4243
+ buildQualityDiagnostics = (await checkBuildQuality(project)).filter((d) => config.rules?.[d.ruleId] !== "off").map((d) => {
4244
+ const override = config.rules?.[d.ruleId];
4245
+ return override ? {
4246
+ ...d,
4247
+ severity: override
4248
+ } : d;
4249
+ });
4250
+ } catch {}
4251
+ let depsDiagnostics = [];
4252
+ try {
4253
+ depsDiagnostics = (await checkDeps(project)).filter((d) => config.rules?.[d.ruleId] !== "off").map((d) => {
4254
+ const override = config.rules?.[d.ruleId];
4255
+ return override ? {
4256
+ ...d,
4257
+ severity: override
4258
+ } : d;
4259
+ });
4260
+ } catch {}
4261
+ let nuxtProjectDiagnostics = [];
4262
+ if (project.framework === "nuxt") try {
4263
+ nuxtProjectDiagnostics = (await checkNuxtProject(project)).filter((d) => config.rules?.[d.ruleId] !== "off").map((d) => {
4264
+ const override = config.rules?.[d.ruleId];
4265
+ return override ? {
4266
+ ...d,
4267
+ severity: override
4268
+ } : d;
4269
+ });
4270
+ } catch {}
4271
+ let crossFileDiagnostics = [];
4272
+ if (project.framework === "nuxt") try {
4273
+ crossFileDiagnostics = (await runCrossFilePass({
4274
+ files,
4275
+ projectInfo: project
4276
+ })).filter((d) => config.rules?.[d.ruleId] !== "off").map((d) => {
4277
+ const override = config.rules?.[d.ruleId];
4278
+ return override ? {
4279
+ ...d,
4280
+ severity: override
4281
+ } : d;
4282
+ });
4283
+ } catch {}
4284
+ const elapsedMs = performance.now() - overallStart;
4285
+ const timings = {
4286
+ template: templateElapsed,
4287
+ sfc: sfcElapsed,
4288
+ script: scriptElapsed,
4289
+ deadCode: deadCodeElapsed,
4290
+ total: elapsedMs
4291
+ };
4292
+ let afterDisables = applyInlineDisables(mergeDiagnostics(templateDiagnostics, sfcDiagnostics, scriptDiagnostics, deadCodeDiagnostics, buildQualityDiagnostics, depsDiagnostics, nuxtProjectDiagnostics, crossFileDiagnostics), { respect: config.respectInlineDisables !== false });
4293
+ if (config.scopeFiles) {
4294
+ const scope = new Set(config.scopeFiles.map((f) => resolve(rootDir, f)));
4295
+ afterDisables = afterDisables.filter((d) => scope.has(resolve(rootDir, d.file)));
4296
+ }
4297
+ const diagnostics = await attachCodeSnippets(afterDisables);
4298
+ const scored = scoreDiagnostics(diagnostics, { threshold });
4299
+ const ruleCounts = countRuleCounts(diagnostics);
4300
+ const projectInfo = {
4301
+ framework: project.framework,
4302
+ vueVersion: project.vueVersion,
4303
+ nuxtVersion: project.nuxtVersion,
4304
+ capabilities: [...project.capabilities].sort(),
4305
+ rootDirectory: project.rootDirectory
4306
+ };
4307
+ let exitCode = 0;
4308
+ if (oxlintStderr && scriptDiagnostics.length === 0 && oxlintStderr.includes("Failed")) exitCode = 2;
4309
+ else if (failOn !== "none") {
4310
+ if ((failOn === "warn" ? scored.errorCount + scored.warnCount : scored.errorCount) > 0) exitCode = 1;
4311
+ }
4312
+ return {
4313
+ rootDir,
4314
+ filesScanned: files.length,
4315
+ diagnostics,
4316
+ score: scored.score,
4317
+ errorCount: scored.errorCount,
4318
+ warnCount: scored.warnCount,
4319
+ infoCount: scored.infoCount,
4320
+ exitCode,
4321
+ scoreResult: scored,
4322
+ projectInfo,
4323
+ elapsedMs,
4324
+ timings,
4325
+ ruleCounts
4326
+ };
4327
+ }
4328
+ //#endregion
4329
+ //#region src/config/built-in.ts
4330
+ const BUILT_IN_RECOMMENDED = {
4331
+ include: [
4332
+ "**/*.vue",
4333
+ "**/*.ts",
4334
+ "**/*.tsx",
4335
+ "**/*.js",
4336
+ "**/*.jsx"
4337
+ ],
4338
+ exclude: [
4339
+ "node_modules",
4340
+ "dist",
4341
+ ".nuxt",
4342
+ ".output",
4343
+ "coverage"
4344
+ ],
4345
+ failOn: "error",
4346
+ threshold: 0,
4347
+ rules: {}
4348
+ };
4349
+ //#endregion
4350
+ //#region src/config/errors.ts
4351
+ var ConfigFileNotFoundError = class extends Error {
4352
+ name = "ConfigFileNotFoundError";
4353
+ constructor(path) {
4354
+ super(`Config file not found: ${path}`);
4355
+ }
4356
+ };
4357
+ var ConfigCycleError = class extends Error {
4358
+ name = "ConfigCycleError";
4359
+ constructor(chain) {
4360
+ super(`Config extends cycle detected: ${chain.join(" -> ")}`);
4361
+ }
4362
+ };
4363
+ var InvalidConfigError = class extends Error {
4364
+ name = "InvalidConfigError";
4365
+ issues;
4366
+ constructor(message, issues = []) {
4367
+ super(message);
4368
+ this.issues = issues;
4369
+ }
4370
+ };
4371
+ //#endregion
4372
+ //#region src/config/define-config.ts
4373
+ function defineConfig(config) {
4374
+ return config;
4055
4375
  }
4056
4376
  //#endregion
4057
4377
  //#region src/config/presets.ts
@@ -4802,7 +5122,14 @@ function buildDoctorReport(input) {
4802
5122
  occurrences: entry.occurrences,
4803
5123
  weightPerOccurrence: entry.weightPerOccurrence,
4804
5124
  penalty: entry.penalty
4805
- }))
5125
+ })),
5126
+ byDimension: Object.fromEntries(Object.entries(input.score.byDimension).map(([dim, d]) => [dim, {
5127
+ score: d.score,
5128
+ totalFindings: d.totalFindings,
5129
+ error: d.errorCount,
5130
+ warn: d.warnCount,
5131
+ info: d.infoCount
5132
+ }]))
4806
5133
  },
4807
5134
  diagnostics: input.diagnostics.map((d) => ({
4808
5135
  file: relative(input.rootDirectory, d.file).replaceAll("\\", "/"),
@@ -5147,6 +5474,44 @@ function filterReportByRules(report, allowedRuleIds, failOn = "error", oxlintStd
5147
5474
  };
5148
5475
  }
5149
5476
  //#endregion
5477
+ //#region src/config/category-filter.ts
5478
+ const VALID_CATEGORIES = new Set(RULE_REGISTRY.map((r) => r.category));
5479
+ /**
5480
+ * Resolve `--category` + `--dimension` inputs to the concrete set of
5481
+ * RuleCategory values they cover. Returns undefined when no scope was
5482
+ * requested (run everything). Throws on an unknown category/dimension so the
5483
+ * CLI can exit 2 rather than silently ignore a typo.
5484
+ */
5485
+ function resolveCategoryScope(input) {
5486
+ const cats = input.categories ?? [];
5487
+ const dims = input.dimensions ?? [];
5488
+ if (cats.length === 0 && dims.length === 0) return void 0;
5489
+ const out = /* @__PURE__ */ new Set();
5490
+ for (const cat of cats) {
5491
+ if (!VALID_CATEGORIES.has(cat)) throw new Error(`unknown --category '${cat}'`);
5492
+ out.add(cat);
5493
+ }
5494
+ for (const dim of dims) {
5495
+ if (!isScoreDimension(dim)) throw new Error(`unknown --dimension '${dim}'`);
5496
+ for (const cat of categoriesForDimension(dim)) out.add(cat);
5497
+ }
5498
+ return out;
5499
+ }
5500
+ /**
5501
+ * Narrow `ruleIds` to those whose registered category is in `scope`. RuleIds
5502
+ * not in the registry are dropped (a category scope is an explicit allowlist).
5503
+ */
5504
+ function filterRuleIdsByCategory(ruleIds, scope) {
5505
+ const categoryById = /* @__PURE__ */ new Map();
5506
+ for (const rule of RULE_REGISTRY) categoryById.set(rule.id, rule.category);
5507
+ const out = /* @__PURE__ */ new Set();
5508
+ for (const id of ruleIds) {
5509
+ const cat = categoryById.get(id);
5510
+ if (cat !== void 0 && scope.has(cat)) out.add(id);
5511
+ }
5512
+ return out;
5513
+ }
5514
+ //#endregion
5150
5515
  //#region src/push.ts
5151
5516
  const PUSH_TIMEOUT_MS = 5e3;
5152
5517
  const categoryByRuleId = new Map(RULE_REGISTRY.map((r) => [r.id, r.category]));
@@ -5262,6 +5627,6 @@ async function pushFindings(opts) {
5262
5627
  }
5263
5628
  }
5264
5629
  //#endregion
5265
- export { BUILT_IN_RECOMMENDED, ConfigCycleError, ConfigFileNotFoundError, DOCTOR_REPORT_SCHEMA_VERSION, DeadCodeImportFailed, DeadCodeTimeoutError, DoctorUserConfigSchema, InvalidConfigError, LevelSchema, OxlintOutputTooLarge, OxlintSpawnFailed, RULE_REGISTRY, RuleEntrySchema, agentReport, applyInlineDisables, audit, buildDoctorReport, buildJsonSchema, buildPushPayload, checkBuildQuality, checkDeadCode, checkDeps, checkNuxtProject, dedupeDeadCodeAgainstLint, defineConfig, detectProject, detectSummary, docsUrl, encodeAnnotation, encodeAnnotations, filterReportByRules, findMonorepoRoot, format, isNuxtLayoutFile, isNuxtPageFile, isNuxtServerFile, jsonCompactReport, jsonReport, listChangedFiles, listRules, listWorkspacePackages, loadAllRuleDocs, loadDoctorConfig, loadRuleDoc, mergeCliOverrides, normalizeInitAnswers, parseDirectives, parseExcludeList, planInit, prCommentReport, prettyReport, pushFindings, renderAgentPlaybook, renderDetectSummary, renderRulePrompt, renderVerboseTrace, runCrossFilePass, sarifReport, scoreDiagnostics, stripFindings, validateConfig };
5630
+ export { BUILT_IN_RECOMMENDED, ConfigCycleError, ConfigFileNotFoundError, DOCTOR_REPORT_SCHEMA_VERSION, DeadCodeImportFailed, DeadCodeTimeoutError, DoctorUserConfigSchema, InvalidConfigError, LevelSchema, OxlintOutputTooLarge, OxlintSpawnFailed, RULE_REGISTRY, RuleEntrySchema, SCORE_DIMENSIONS, agentReport, applyInlineDisables, audit, buildDoctorReport, buildJsonSchema, buildPushPayload, categoriesForDimension, checkBuildQuality, checkDeadCode, checkDeps, checkNuxtProject, dedupeDeadCodeAgainstLint, defineConfig, detectProject, detectSummary, dimensionForCategory, docsUrl, encodeAnnotation, encodeAnnotations, filterReportByRules, filterRuleIdsByCategory, findMonorepoRoot, format, isNuxtLayoutFile, isNuxtPageFile, isNuxtServerFile, isScoreDimension, jsonCompactReport, jsonReport, listChangedFiles, listRules, listWorkspacePackages, loadAllRuleDocs, loadDoctorConfig, loadRuleDoc, mergeCliOverrides, normalizeInitAnswers, parseDirectives, parseExcludeList, planInit, prCommentReport, prettyReport, pushFindings, renderAgentPlaybook, renderDetectSummary, renderRulePrompt, renderVerboseTrace, resolveCategoryScope, runCrossFilePass, sarifReport, scoreDiagnostics, stripFindings, validateConfig };
5266
5631
 
5267
5632
  //# sourceMappingURL=index.js.map