@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.d.ts +65 -20
- package/dist/index.js +2302 -1937
- package/dist/index.js.map +1 -1
- package/package.json +5 -5
- package/schema.json +28 -0
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
|
|
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/
|
|
1836
|
-
const
|
|
1837
|
-
|
|
1838
|
-
|
|
1839
|
-
|
|
1840
|
-
|
|
1841
|
-
|
|
1842
|
-
|
|
1843
|
-
|
|
1844
|
-
|
|
1845
|
-
|
|
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
|
-
|
|
2081
|
-
source: "
|
|
2082
|
-
|
|
2083
|
-
}
|
|
2084
|
-
|
|
2085
|
-
|
|
2086
|
-
|
|
2087
|
-
|
|
2088
|
-
|
|
2089
|
-
|
|
2090
|
-
|
|
2091
|
-
|
|
2092
|
-
|
|
2093
|
-
|
|
2094
|
-
|
|
2095
|
-
|
|
2096
|
-
|
|
2097
|
-
|
|
2098
|
-
|
|
2099
|
-
|
|
2100
|
-
|
|
2101
|
-
|
|
2102
|
-
|
|
2103
|
-
|
|
2104
|
-
|
|
2105
|
-
|
|
2106
|
-
|
|
2107
|
-
|
|
2108
|
-
|
|
2109
|
-
|
|
2110
|
-
|
|
2111
|
-
|
|
2112
|
-
|
|
2113
|
-
|
|
2114
|
-
|
|
2115
|
-
|
|
2116
|
-
|
|
2117
|
-
|
|
2118
|
-
|
|
2119
|
-
|
|
2120
|
-
|
|
2121
|
-
|
|
2122
|
-
|
|
2123
|
-
|
|
2124
|
-
|
|
2125
|
-
|
|
2126
|
-
|
|
2127
|
-
|
|
2128
|
-
|
|
2129
|
-
|
|
2130
|
-
|
|
2131
|
-
|
|
2132
|
-
|
|
2133
|
-
|
|
2134
|
-
|
|
2135
|
-
|
|
2136
|
-
|
|
2137
|
-
|
|
2138
|
-
|
|
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
|
-
|
|
2141
|
-
source: "
|
|
2142
|
-
|
|
2143
|
-
}
|
|
2144
|
-
|
|
2145
|
-
|
|
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
|
-
|
|
2186
|
-
source: "
|
|
2187
|
-
|
|
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
|
|
2195
|
-
|
|
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: "
|
|
2199
|
-
|
|
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: "
|
|
2203
|
-
|
|
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: "
|
|
2207
|
-
|
|
2208
|
-
|
|
2209
|
-
|
|
2210
|
-
|
|
2211
|
-
|
|
2212
|
-
|
|
2213
|
-
|
|
2214
|
-
|
|
2215
|
-
|
|
2216
|
-
|
|
2217
|
-
|
|
2218
|
-
|
|
2219
|
-
|
|
2220
|
-
|
|
2221
|
-
|
|
2222
|
-
|
|
2223
|
-
|
|
2224
|
-
|
|
2225
|
-
|
|
2226
|
-
|
|
2227
|
-
|
|
2228
|
-
|
|
2229
|
-
|
|
2230
|
-
|
|
2231
|
-
|
|
2232
|
-
|
|
2233
|
-
|
|
2234
|
-
|
|
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
|
-
|
|
2398
|
-
source: "
|
|
2399
|
-
|
|
2400
|
-
}
|
|
2401
|
-
|
|
2402
|
-
|
|
2403
|
-
|
|
2404
|
-
|
|
2405
|
-
|
|
2406
|
-
|
|
2407
|
-
|
|
2408
|
-
|
|
2409
|
-
|
|
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
|
-
|
|
2412
|
-
source: "
|
|
2413
|
-
|
|
2414
|
-
}
|
|
2415
|
-
|
|
2416
|
-
|
|
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
|
-
|
|
2612
|
-
source: "
|
|
2613
|
-
|
|
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/
|
|
3377
|
-
severity: "
|
|
3378
|
-
category: "
|
|
3379
|
-
source: "
|
|
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/
|
|
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: "
|
|
3386
|
-
source: "
|
|
2143
|
+
category: "reactivity",
|
|
2144
|
+
source: "doctor",
|
|
3387
2145
|
recommended: true
|
|
3388
2146
|
},
|
|
3389
2147
|
{
|
|
3390
|
-
id: "vue/
|
|
3391
|
-
severity: "
|
|
3392
|
-
category: "
|
|
3393
|
-
source: "
|
|
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/
|
|
3398
|
-
severity: "
|
|
3399
|
-
category: "
|
|
3400
|
-
source: "
|
|
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-
|
|
3405
|
-
severity: "
|
|
3406
|
-
category: "
|
|
3407
|
-
source: "
|
|
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/
|
|
3412
|
-
severity: "
|
|
3413
|
-
category: "
|
|
3414
|
-
source: "
|
|
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/
|
|
2197
|
+
id: "vue-doctor/template/v-for-has-key",
|
|
3419
2198
|
severity: "error",
|
|
3420
|
-
category: "
|
|
3421
|
-
source: "
|
|
2199
|
+
category: "template",
|
|
2200
|
+
source: "doctor",
|
|
3422
2201
|
recommended: true
|
|
3423
2202
|
},
|
|
3424
2203
|
{
|
|
3425
|
-
id: "vue/
|
|
2204
|
+
id: "vue-doctor/template/v-if-v-for-precedence",
|
|
3426
2205
|
severity: "error",
|
|
3427
|
-
category: "
|
|
3428
|
-
source: "
|
|
2206
|
+
category: "template",
|
|
2207
|
+
source: "doctor",
|
|
3429
2208
|
recommended: true
|
|
3430
2209
|
},
|
|
3431
2210
|
{
|
|
3432
|
-
id: "vue/
|
|
3433
|
-
severity: "
|
|
3434
|
-
category: "
|
|
3435
|
-
source: "
|
|
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-
|
|
3440
|
-
severity: "
|
|
3441
|
-
category: "
|
|
3442
|
-
source: "
|
|
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-
|
|
3447
|
-
severity: "
|
|
3448
|
-
category: "
|
|
3449
|
-
source: "
|
|
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/
|
|
3454
|
-
severity: "
|
|
3455
|
-
category: "
|
|
3456
|
-
source: "
|
|
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/
|
|
3461
|
-
severity: "
|
|
3462
|
-
category: "
|
|
3463
|
-
source: "
|
|
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/
|
|
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: "
|
|
3470
|
-
source: "
|
|
2290
|
+
category: "deps",
|
|
2291
|
+
source: "doctor",
|
|
3471
2292
|
recommended: true
|
|
3472
2293
|
},
|
|
3473
2294
|
{
|
|
3474
|
-
id: "vue/
|
|
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: "
|
|
3477
|
-
source: "
|
|
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: "
|
|
2330
|
+
id: "dead-code/unused-dependency",
|
|
3482
2331
|
severity: "warn",
|
|
3483
|
-
category: "
|
|
3484
|
-
source: "
|
|
2332
|
+
category: "dead-code",
|
|
2333
|
+
source: "doctor",
|
|
3485
2334
|
recommended: true
|
|
3486
2335
|
},
|
|
3487
2336
|
{
|
|
3488
|
-
id: "
|
|
3489
|
-
severity: "
|
|
3490
|
-
category: "
|
|
3491
|
-
source: "
|
|
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: "
|
|
2344
|
+
id: "dead-code/duplicate-export",
|
|
3496
2345
|
severity: "warn",
|
|
3497
|
-
category: "
|
|
3498
|
-
source: "
|
|
2346
|
+
category: "dead-code",
|
|
2347
|
+
source: "doctor",
|
|
3499
2348
|
recommended: true
|
|
3500
2349
|
},
|
|
3501
2350
|
{
|
|
3502
|
-
id: "
|
|
2351
|
+
id: "nuxt-doctor/structure/uses-app-directory",
|
|
3503
2352
|
severity: "warn",
|
|
3504
|
-
category: "
|
|
3505
|
-
source: "
|
|
2353
|
+
category: "structure",
|
|
2354
|
+
source: "doctor",
|
|
3506
2355
|
recommended: true
|
|
3507
2356
|
},
|
|
3508
2357
|
{
|
|
3509
|
-
id: "
|
|
2358
|
+
id: "nuxt-doctor/structure/nuxt-major-current",
|
|
3510
2359
|
severity: "info",
|
|
3511
|
-
category: "
|
|
3512
|
-
source: "
|
|
2360
|
+
category: "structure",
|
|
2361
|
+
source: "doctor",
|
|
3513
2362
|
recommended: false
|
|
3514
2363
|
},
|
|
3515
2364
|
{
|
|
3516
|
-
id: "
|
|
3517
|
-
severity: "
|
|
3518
|
-
category: "
|
|
3519
|
-
source: "
|
|
3520
|
-
recommended:
|
|
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: "
|
|
2372
|
+
id: "nuxt-doctor/modules-deps/recommended-modules-installed",
|
|
3524
2373
|
severity: "info",
|
|
3525
|
-
category: "
|
|
3526
|
-
source: "
|
|
2374
|
+
category: "modules-deps",
|
|
2375
|
+
source: "doctor",
|
|
3527
2376
|
recommended: false
|
|
3528
2377
|
},
|
|
3529
2378
|
{
|
|
3530
|
-
id: "
|
|
3531
|
-
severity: "
|
|
3532
|
-
category: "
|
|
3533
|
-
source: "
|
|
3534
|
-
recommended:
|
|
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: "
|
|
2386
|
+
id: "nuxt-doctor/nitro/preset-defined-for-deploy-target",
|
|
3538
2387
|
severity: "warn",
|
|
3539
|
-
category: "
|
|
2388
|
+
category: "nitro",
|
|
3540
2389
|
source: "doctor",
|
|
3541
2390
|
recommended: true
|
|
3542
2391
|
},
|
|
3543
2392
|
{
|
|
3544
|
-
id: "
|
|
3545
|
-
severity: "
|
|
3546
|
-
category: "
|
|
2393
|
+
id: "nuxt-doctor/nitro/runtime-config-typed",
|
|
2394
|
+
severity: "info",
|
|
2395
|
+
category: "nitro",
|
|
3547
2396
|
source: "doctor",
|
|
3548
|
-
recommended:
|
|
2397
|
+
recommended: false
|
|
3549
2398
|
},
|
|
3550
2399
|
{
|
|
3551
|
-
id: "
|
|
3552
|
-
severity: "
|
|
3553
|
-
category: "
|
|
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: "
|
|
2407
|
+
id: "nuxt-doctor/seo/useSeoMeta-on-public-page",
|
|
3559
2408
|
severity: "warn",
|
|
3560
|
-
category: "
|
|
2409
|
+
category: "seo",
|
|
3561
2410
|
source: "doctor",
|
|
3562
2411
|
recommended: true
|
|
3563
2412
|
},
|
|
3564
2413
|
{
|
|
3565
|
-
id: "
|
|
2414
|
+
id: "nuxt-doctor/seo/og-image-defined",
|
|
3566
2415
|
severity: "warn",
|
|
3567
|
-
category: "
|
|
2416
|
+
category: "seo",
|
|
3568
2417
|
source: "doctor",
|
|
3569
2418
|
recommended: true
|
|
3570
2419
|
},
|
|
3571
2420
|
{
|
|
3572
|
-
id: "
|
|
2421
|
+
id: "nuxt-doctor/cloudflare/nitro-cloudflare-preset",
|
|
3573
2422
|
severity: "warn",
|
|
3574
|
-
category: "
|
|
2423
|
+
category: "cloudflare",
|
|
3575
2424
|
source: "doctor",
|
|
3576
2425
|
recommended: true
|
|
3577
2426
|
},
|
|
3578
2427
|
{
|
|
3579
|
-
id: "
|
|
2428
|
+
id: "nuxt-doctor/cloudflare/og-image-via-satori",
|
|
3580
2429
|
severity: "info",
|
|
3581
|
-
category: "
|
|
2430
|
+
category: "cloudflare",
|
|
3582
2431
|
source: "doctor",
|
|
3583
2432
|
recommended: false
|
|
3584
2433
|
},
|
|
3585
2434
|
{
|
|
3586
|
-
id: "
|
|
3587
|
-
severity: "
|
|
3588
|
-
category: "
|
|
2435
|
+
id: "nuxt-doctor/cloudflare/no-node-only-modules",
|
|
2436
|
+
severity: "warn",
|
|
2437
|
+
category: "cloudflare",
|
|
3589
2438
|
source: "doctor",
|
|
3590
|
-
recommended:
|
|
2439
|
+
recommended: true
|
|
3591
2440
|
},
|
|
3592
2441
|
{
|
|
3593
|
-
id: "
|
|
2442
|
+
id: "nuxt-doctor/data-fetching/no-shared-key-across-pages",
|
|
3594
2443
|
severity: "warn",
|
|
3595
|
-
category: "
|
|
2444
|
+
category: "data-fetching",
|
|
3596
2445
|
source: "doctor",
|
|
3597
2446
|
recommended: true
|
|
3598
2447
|
},
|
|
3599
2448
|
{
|
|
3600
|
-
id: "
|
|
2449
|
+
id: "nuxt-doctor/data-fetching/ssr-safe-onMounted-only-for-client",
|
|
3601
2450
|
severity: "warn",
|
|
3602
|
-
category: "
|
|
2451
|
+
category: "data-fetching",
|
|
3603
2452
|
source: "doctor",
|
|
3604
2453
|
recommended: true
|
|
3605
2454
|
},
|
|
3606
2455
|
{
|
|
3607
|
-
id: "
|
|
3608
|
-
severity: "
|
|
3609
|
-
category: "
|
|
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:
|
|
2460
|
+
recommended: true
|
|
3612
2461
|
},
|
|
3613
2462
|
{
|
|
3614
|
-
id: "
|
|
2463
|
+
id: "nuxt-doctor/ai-slop/no-process-client-server",
|
|
3615
2464
|
severity: "error",
|
|
3616
|
-
category: "
|
|
2465
|
+
category: "ai-slop",
|
|
3617
2466
|
source: "doctor",
|
|
3618
2467
|
recommended: true
|
|
3619
2468
|
},
|
|
3620
2469
|
{
|
|
3621
|
-
id: "
|
|
3622
|
-
severity: "
|
|
3623
|
-
category: "
|
|
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: "
|
|
2477
|
+
id: "nuxt-doctor/ai-slop/no-useState-for-server-data",
|
|
3629
2478
|
severity: "warn",
|
|
3630
|
-
category: "
|
|
2479
|
+
category: "ai-slop",
|
|
3631
2480
|
source: "doctor",
|
|
3632
2481
|
recommended: true
|
|
3633
2482
|
},
|
|
3634
2483
|
{
|
|
3635
|
-
id: "
|
|
2484
|
+
id: "nuxt-doctor/ai-slop/no-fetch-in-setup",
|
|
3636
2485
|
severity: "warn",
|
|
3637
|
-
category: "
|
|
2486
|
+
category: "ai-slop",
|
|
3638
2487
|
source: "doctor",
|
|
3639
2488
|
recommended: true
|
|
3640
2489
|
},
|
|
3641
2490
|
{
|
|
3642
|
-
id: "
|
|
3643
|
-
severity: "
|
|
3644
|
-
category: "
|
|
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: "
|
|
3650
|
-
severity: "
|
|
3651
|
-
category: "
|
|
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: "
|
|
2505
|
+
id: "nuxt-doctor/server-routes/validate-body-with-h3-v2",
|
|
3657
2506
|
severity: "warn",
|
|
3658
|
-
category: "
|
|
2507
|
+
category: "server-routes",
|
|
3659
2508
|
source: "doctor",
|
|
3660
2509
|
recommended: true
|
|
3661
2510
|
},
|
|
3662
2511
|
{
|
|
3663
|
-
id: "
|
|
2512
|
+
id: "nuxt-doctor/server-routes/createError-on-failure",
|
|
3664
2513
|
severity: "warn",
|
|
3665
|
-
category: "
|
|
2514
|
+
category: "server-routes",
|
|
3666
2515
|
source: "doctor",
|
|
3667
2516
|
recommended: true
|
|
3668
2517
|
},
|
|
3669
2518
|
{
|
|
3670
|
-
id: "
|
|
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: "
|
|
2556
|
+
category: "security",
|
|
3673
2557
|
source: "doctor",
|
|
3674
2558
|
recommended: true
|
|
3675
2559
|
},
|
|
3676
2560
|
{
|
|
3677
|
-
id: "vue-doctor/
|
|
2561
|
+
id: "vue-doctor/security/no-secrets-in-source",
|
|
3678
2562
|
severity: "warn",
|
|
3679
|
-
category: "
|
|
2563
|
+
category: "security",
|
|
3680
2564
|
source: "doctor",
|
|
3681
2565
|
recommended: true
|
|
3682
2566
|
},
|
|
3683
2567
|
{
|
|
3684
|
-
id: "vue-doctor/
|
|
3685
|
-
severity: "
|
|
3686
|
-
category: "
|
|
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/
|
|
2575
|
+
id: "vue-doctor/security/no-javascript-uri",
|
|
3692
2576
|
severity: "error",
|
|
3693
|
-
category: "
|
|
2577
|
+
category: "security",
|
|
3694
2578
|
source: "doctor",
|
|
3695
2579
|
recommended: true
|
|
3696
2580
|
},
|
|
3697
2581
|
{
|
|
3698
|
-
id: "
|
|
3699
|
-
severity: "
|
|
3700
|
-
category: "
|
|
2582
|
+
id: "nuxt-doctor/security/no-secret-in-public-runtime-config",
|
|
2583
|
+
severity: "error",
|
|
2584
|
+
category: "security",
|
|
3701
2585
|
source: "doctor",
|
|
3702
|
-
recommended:
|
|
2586
|
+
recommended: true
|
|
3703
2587
|
},
|
|
3704
2588
|
{
|
|
3705
|
-
id: "
|
|
2589
|
+
id: "nuxt-doctor/security/no-user-input-in-fetch-url",
|
|
3706
2590
|
severity: "warn",
|
|
3707
|
-
category: "
|
|
2591
|
+
category: "security",
|
|
3708
2592
|
source: "doctor",
|
|
3709
2593
|
recommended: true
|
|
3710
2594
|
},
|
|
3711
2595
|
{
|
|
3712
|
-
id: "
|
|
2596
|
+
id: "vue-doctor/design/no-arbitrary-tailwind-values",
|
|
3713
2597
|
severity: "warn",
|
|
3714
|
-
category: "
|
|
2598
|
+
category: "design",
|
|
3715
2599
|
source: "doctor",
|
|
3716
2600
|
recommended: true
|
|
3717
2601
|
},
|
|
3718
2602
|
{
|
|
3719
|
-
id: "
|
|
3720
|
-
severity: "
|
|
3721
|
-
category: "
|
|
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: "
|
|
3727
|
-
severity: "
|
|
3728
|
-
category: "
|
|
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: "
|
|
2617
|
+
id: "vue-doctor/design/no-important-utility",
|
|
3734
2618
|
severity: "warn",
|
|
3735
|
-
category: "
|
|
2619
|
+
category: "design",
|
|
3736
2620
|
source: "doctor",
|
|
3737
2621
|
recommended: true
|
|
3738
2622
|
},
|
|
3739
2623
|
{
|
|
3740
|
-
id: "
|
|
3741
|
-
severity: "
|
|
3742
|
-
category: "
|
|
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: "
|
|
2631
|
+
id: "vue-doctor/design/no-missing-alt",
|
|
3748
2632
|
severity: "warn",
|
|
3749
|
-
category: "
|
|
2633
|
+
category: "design",
|
|
3750
2634
|
source: "doctor",
|
|
3751
2635
|
recommended: true
|
|
3752
2636
|
},
|
|
3753
2637
|
{
|
|
3754
|
-
id: "
|
|
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
|
-
|
|
3757
|
-
source: "
|
|
3758
|
-
|
|
3759
|
-
}
|
|
3760
|
-
|
|
3761
|
-
|
|
3762
|
-
|
|
3763
|
-
|
|
3764
|
-
|
|
3765
|
-
|
|
3766
|
-
|
|
3767
|
-
{
|
|
3768
|
-
|
|
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
|
-
|
|
3771
|
-
source: "
|
|
3772
|
-
|
|
3773
|
-
}
|
|
3774
|
-
|
|
3775
|
-
|
|
3776
|
-
|
|
3777
|
-
|
|
3778
|
-
|
|
3779
|
-
|
|
3780
|
-
|
|
3781
|
-
{
|
|
3782
|
-
|
|
3783
|
-
|
|
3784
|
-
|
|
3785
|
-
|
|
3786
|
-
|
|
3787
|
-
|
|
3788
|
-
|
|
3789
|
-
|
|
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
|
-
|
|
3792
|
-
source: "
|
|
3793
|
-
|
|
3794
|
-
}
|
|
3795
|
-
|
|
3796
|
-
|
|
3797
|
-
|
|
3798
|
-
|
|
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: "
|
|
3804
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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/
|
|
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
|
-
|
|
3855
|
-
source: "
|
|
3856
|
-
|
|
3857
|
-
}
|
|
3858
|
-
|
|
3859
|
-
|
|
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
|
-
|
|
3862
|
-
source: "
|
|
3863
|
-
|
|
3864
|
-
}
|
|
3865
|
-
|
|
3866
|
-
|
|
3867
|
-
|
|
3868
|
-
|
|
3869
|
-
|
|
3870
|
-
|
|
3871
|
-
|
|
3872
|
-
{
|
|
3873
|
-
|
|
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
|
-
|
|
3876
|
-
source: "
|
|
3877
|
-
|
|
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: "
|
|
3881
|
-
|
|
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: "
|
|
3888
|
-
|
|
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: "
|
|
3895
|
-
|
|
3896
|
-
|
|
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: "
|
|
3902
|
-
|
|
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: "
|
|
3909
|
-
|
|
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: "
|
|
3916
|
-
|
|
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: "
|
|
3923
|
-
|
|
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: "
|
|
3930
|
-
|
|
3931
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
4043
|
-
|
|
4044
|
-
|
|
4045
|
-
|
|
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
|
-
|
|
4049
|
-
|
|
4050
|
-
|
|
4051
|
-
|
|
4052
|
-
|
|
4053
|
-
|
|
4054
|
-
|
|
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
|