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