@geoql/doctor-core 0.1.0-alpha.0 → 0.1.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 +381 -11
- package/dist/index.js +2895 -198
- package/dist/index.js.map +1 -1
- package/dist/load-BGjurxpY.js +629 -0
- package/dist/load-BGjurxpY.js.map +1 -0
- package/package.json +14 -5
package/dist/index.js
CHANGED
|
@@ -1,13 +1,1285 @@
|
|
|
1
|
+
import { a as listRules, c as InvalidConfigError, i as RULE_REGISTRY, l as BUILT_IN_RECOMMENDED, o as ConfigCycleError, r as validateConfig, s as ConfigFileNotFoundError, t as loadDoctorConfig } from "./load-BGjurxpY.js";
|
|
1
2
|
import { dirname, join, relative, resolve } from "node:path";
|
|
3
|
+
import { performance } from "node:perf_hooks";
|
|
4
|
+
import { access, mkdir, mkdtemp, readFile, readdir, rm, writeFile } from "node:fs/promises";
|
|
5
|
+
import { createRequire } from "node:module";
|
|
6
|
+
import { execFile, spawn } from "node:child_process";
|
|
7
|
+
import { parseSync } from "oxc-parser";
|
|
8
|
+
import { existsSync, readFileSync, statSync } from "node:fs";
|
|
9
|
+
import { coerce, gte, major, minor } from "semver";
|
|
2
10
|
import { glob } from "tinyglobby";
|
|
3
|
-
import { mkdtemp, readFile, writeFile } from "node:fs/promises";
|
|
4
11
|
import { tmpdir } from "node:os";
|
|
5
|
-
import {
|
|
6
|
-
import {
|
|
7
|
-
import {
|
|
8
|
-
import
|
|
9
|
-
import
|
|
10
|
-
|
|
12
|
+
import { fileURLToPath } from "node:url";
|
|
13
|
+
import { babelParse, parse } from "@vue/compiler-sfc";
|
|
14
|
+
import { promisify } from "node:util";
|
|
15
|
+
import process$1 from "node:process";
|
|
16
|
+
import pc from "picocolors";
|
|
17
|
+
//#region src/annotations.ts
|
|
18
|
+
function escapeProperty(value) {
|
|
19
|
+
return value.replace(/%/g, "%25").replace(/\r/g, "%0D").replace(/\n/g, "%0A").replace(/:/g, "%3A").replace(/,/g, "%2C");
|
|
20
|
+
}
|
|
21
|
+
function escapeData(value) {
|
|
22
|
+
return value.replace(/%/g, "%25").replace(/\r/g, "%0D").replace(/\n/g, "%0A");
|
|
23
|
+
}
|
|
24
|
+
function encodeAnnotation(diagnostic) {
|
|
25
|
+
return `::${diagnostic.severity === "error" ? "error" : "warning"} ${[
|
|
26
|
+
`file=${escapeProperty(diagnostic.file)}`,
|
|
27
|
+
`line=${diagnostic.line}`,
|
|
28
|
+
`col=${diagnostic.column}`,
|
|
29
|
+
`title=${escapeProperty(diagnostic.ruleId)}`
|
|
30
|
+
].join(",")}::${escapeData(diagnostic.message)}`;
|
|
31
|
+
}
|
|
32
|
+
function encodeAnnotations(diagnostics) {
|
|
33
|
+
return diagnostics.map(encodeAnnotation).join("\n");
|
|
34
|
+
}
|
|
35
|
+
//#endregion
|
|
36
|
+
//#region src/project-info/read-package-json.ts
|
|
37
|
+
async function readPackageJson(dir) {
|
|
38
|
+
try {
|
|
39
|
+
const source = await readFile(join(dir, "package.json"), "utf8");
|
|
40
|
+
return JSON.parse(source);
|
|
41
|
+
} catch {
|
|
42
|
+
return null;
|
|
43
|
+
}
|
|
44
|
+
}
|
|
45
|
+
//#endregion
|
|
46
|
+
//#region src/build-quality/read-deps.ts
|
|
47
|
+
async function readDeps(packageJsonPath) {
|
|
48
|
+
const pkg = await readPackageJson(dirname(packageJsonPath));
|
|
49
|
+
if (pkg === null) return null;
|
|
50
|
+
return {
|
|
51
|
+
dependencies: pkg.dependencies ?? {},
|
|
52
|
+
devDependencies: pkg.devDependencies ?? {}
|
|
53
|
+
};
|
|
54
|
+
}
|
|
55
|
+
//#endregion
|
|
56
|
+
//#region src/build-quality/check-eslint-plugin-vue.ts
|
|
57
|
+
const RULE_ID$20 = "vue-doctor/build-quality/eslint-plugin-vue-installed";
|
|
58
|
+
const DOCS$3 = "https://eslint.vuejs.org/";
|
|
59
|
+
async function checkEslintPluginVue(projectInfo) {
|
|
60
|
+
if (projectInfo.packageJsonPath === null) return [];
|
|
61
|
+
if (projectInfo.framework !== "vue" && projectInfo.framework !== "nuxt") return [];
|
|
62
|
+
const deps = await readDeps(projectInfo.packageJsonPath);
|
|
63
|
+
if (deps === null) return [];
|
|
64
|
+
if (deps.devDependencies["eslint-plugin-vue"]) return [];
|
|
65
|
+
return [{
|
|
66
|
+
ruleId: RULE_ID$20,
|
|
67
|
+
file: projectInfo.packageJsonPath,
|
|
68
|
+
line: 1,
|
|
69
|
+
column: 1,
|
|
70
|
+
severity: "info",
|
|
71
|
+
message: `eslint-plugin-vue is not in devDependencies; it catches many Vue-specific issues. See ${DOCS$3}`,
|
|
72
|
+
recommendation: "Add eslint-plugin-vue to devDependencies."
|
|
73
|
+
}];
|
|
74
|
+
}
|
|
75
|
+
//#endregion
|
|
76
|
+
//#region src/build-quality/check-no-vue-cli.ts
|
|
77
|
+
const RULE_ID$19 = "vue-doctor/build-quality/no-vue-cli";
|
|
78
|
+
const DOCS$2 = "https://cli.vuejs.org/migrations/migrate-from-v4.html";
|
|
79
|
+
function isOffendingKey(key) {
|
|
80
|
+
return key === "@vue/cli-service" || key.startsWith("vue-cli-plugin-");
|
|
81
|
+
}
|
|
82
|
+
async function checkNoVueCli(projectInfo) {
|
|
83
|
+
if (projectInfo.packageJsonPath === null) return [];
|
|
84
|
+
const deps = await readDeps(projectInfo.packageJsonPath);
|
|
85
|
+
if (deps === null) return [];
|
|
86
|
+
const offenders = [];
|
|
87
|
+
for (const key of [...Object.keys(deps.dependencies), ...Object.keys(deps.devDependencies)]) if (isOffendingKey(key) && !offenders.includes(key)) offenders.push(key);
|
|
88
|
+
return offenders.map((key, index) => ({
|
|
89
|
+
ruleId: RULE_ID$19,
|
|
90
|
+
file: projectInfo.packageJsonPath,
|
|
91
|
+
line: index + 1,
|
|
92
|
+
column: 1,
|
|
93
|
+
severity: "warn",
|
|
94
|
+
message: `${key} indicates Vue CLI, which is deprecated; migrate to Vite. See ${DOCS$2}`,
|
|
95
|
+
recommendation: `Remove ${key} and migrate the build to Vite.`
|
|
96
|
+
}));
|
|
97
|
+
}
|
|
98
|
+
//#endregion
|
|
99
|
+
//#region src/build-quality/strip-json-comments.ts
|
|
100
|
+
function stripJsonComments(input) {
|
|
101
|
+
let out = "";
|
|
102
|
+
let inString = false;
|
|
103
|
+
let inLineComment = false;
|
|
104
|
+
let inBlockComment = false;
|
|
105
|
+
let escaped = false;
|
|
106
|
+
for (let i = 0; i < input.length; i++) {
|
|
107
|
+
const char = input[i];
|
|
108
|
+
const next = input[i + 1];
|
|
109
|
+
if (inLineComment) {
|
|
110
|
+
if (char === "\n") {
|
|
111
|
+
inLineComment = false;
|
|
112
|
+
out += char;
|
|
113
|
+
}
|
|
114
|
+
continue;
|
|
115
|
+
}
|
|
116
|
+
if (inBlockComment) {
|
|
117
|
+
if (char === "*" && next === "/") {
|
|
118
|
+
inBlockComment = false;
|
|
119
|
+
i++;
|
|
120
|
+
}
|
|
121
|
+
continue;
|
|
122
|
+
}
|
|
123
|
+
if (inString) {
|
|
124
|
+
out += char;
|
|
125
|
+
if (escaped) escaped = false;
|
|
126
|
+
else if (char === "\\") escaped = true;
|
|
127
|
+
else if (char === "\"") inString = false;
|
|
128
|
+
continue;
|
|
129
|
+
}
|
|
130
|
+
if (char === "\"") {
|
|
131
|
+
inString = true;
|
|
132
|
+
out += char;
|
|
133
|
+
continue;
|
|
134
|
+
}
|
|
135
|
+
if (char === "/" && next === "/") {
|
|
136
|
+
inLineComment = true;
|
|
137
|
+
i++;
|
|
138
|
+
continue;
|
|
139
|
+
}
|
|
140
|
+
if (char === "/" && next === "*") {
|
|
141
|
+
inBlockComment = true;
|
|
142
|
+
i++;
|
|
143
|
+
continue;
|
|
144
|
+
}
|
|
145
|
+
out += char;
|
|
146
|
+
}
|
|
147
|
+
return out;
|
|
148
|
+
}
|
|
149
|
+
//#endregion
|
|
150
|
+
//#region src/build-quality/check-tsconfig-strict.ts
|
|
151
|
+
const RULE_ID$18 = "vue-doctor/build-quality/tsconfig-strict-required";
|
|
152
|
+
const DOCS$1 = "https://www.typescriptlang.org/tsconfig#strict";
|
|
153
|
+
async function checkTsconfigStrict(projectInfo) {
|
|
154
|
+
if (projectInfo.packageJsonPath === null) return [];
|
|
155
|
+
const tsconfigPath = join(dirname(projectInfo.packageJsonPath), "tsconfig.json");
|
|
156
|
+
let parsed;
|
|
157
|
+
try {
|
|
158
|
+
const source = await readFile(tsconfigPath, "utf8");
|
|
159
|
+
parsed = JSON.parse(stripJsonComments(source));
|
|
160
|
+
} catch {
|
|
161
|
+
return [];
|
|
162
|
+
}
|
|
163
|
+
if (parsed.compilerOptions?.strict === true) return [];
|
|
164
|
+
return [{
|
|
165
|
+
ruleId: RULE_ID$18,
|
|
166
|
+
file: tsconfigPath,
|
|
167
|
+
line: 1,
|
|
168
|
+
column: 1,
|
|
169
|
+
severity: "warn",
|
|
170
|
+
message: `tsconfig.json should set compilerOptions.strict to true for full type safety. See ${DOCS$1}`,
|
|
171
|
+
recommendation: "Set \"strict\": true in tsconfig.json compilerOptions."
|
|
172
|
+
}];
|
|
173
|
+
}
|
|
174
|
+
//#endregion
|
|
175
|
+
//#region src/build-quality/check-vue-tsc.ts
|
|
176
|
+
const RULE_ID$17 = "vue-doctor/build-quality/vue-tsc-in-devDeps";
|
|
177
|
+
const DOCS = "https://github.com/vuejs/language-tools/tree/master/packages/tsc";
|
|
178
|
+
async function checkVueTsc(projectInfo) {
|
|
179
|
+
if (projectInfo.packageJsonPath === null) return [];
|
|
180
|
+
if (projectInfo.framework !== "vue" && projectInfo.framework !== "nuxt") return [];
|
|
181
|
+
const deps = await readDeps(projectInfo.packageJsonPath);
|
|
182
|
+
if (deps === null) return [];
|
|
183
|
+
if (deps.devDependencies["vue-tsc"]) return [];
|
|
184
|
+
return [{
|
|
185
|
+
ruleId: RULE_ID$17,
|
|
186
|
+
file: projectInfo.packageJsonPath,
|
|
187
|
+
line: 1,
|
|
188
|
+
column: 1,
|
|
189
|
+
severity: "warn",
|
|
190
|
+
message: `Vue projects should type-check with vue-tsc, but it is missing from devDependencies. See ${DOCS}`,
|
|
191
|
+
recommendation: "Add vue-tsc to devDependencies."
|
|
192
|
+
}];
|
|
193
|
+
}
|
|
194
|
+
//#endregion
|
|
195
|
+
//#region src/check-build-quality.ts
|
|
196
|
+
async function checkBuildQuality(projectInfo) {
|
|
197
|
+
if (projectInfo.packageJsonPath === null) return [];
|
|
198
|
+
return (await Promise.all([
|
|
199
|
+
checkTsconfigStrict(projectInfo),
|
|
200
|
+
checkVueTsc(projectInfo),
|
|
201
|
+
checkNoVueCli(projectInfo),
|
|
202
|
+
checkEslintPluginVue(projectInfo)
|
|
203
|
+
])).flat().map((issue) => ({
|
|
204
|
+
file: issue.file,
|
|
205
|
+
line: issue.line,
|
|
206
|
+
column: issue.column,
|
|
207
|
+
ruleId: issue.ruleId,
|
|
208
|
+
severity: issue.severity,
|
|
209
|
+
message: issue.message,
|
|
210
|
+
source: "project",
|
|
211
|
+
recommendation: issue.recommendation
|
|
212
|
+
}));
|
|
213
|
+
}
|
|
214
|
+
//#endregion
|
|
215
|
+
//#region src/dead-code/build-knip-config.ts
|
|
216
|
+
function buildKnipConfig(projectInfo, doctorConfig) {
|
|
217
|
+
const isNuxt = projectInfo.framework === "nuxt";
|
|
218
|
+
const entry = isNuxt ? [
|
|
219
|
+
"app/**/*.vue",
|
|
220
|
+
"app/**/*.ts",
|
|
221
|
+
"server/**/*.ts",
|
|
222
|
+
"nuxt.config.{ts,js,mjs}"
|
|
223
|
+
] : [
|
|
224
|
+
"src/main.{ts,js}",
|
|
225
|
+
"src/App.vue",
|
|
226
|
+
"index.html",
|
|
227
|
+
"vite.config.{ts,js}"
|
|
228
|
+
];
|
|
229
|
+
const config = {
|
|
230
|
+
cwd: projectInfo.rootDirectory,
|
|
231
|
+
entry,
|
|
232
|
+
project: [
|
|
233
|
+
"**/*.{ts,vue}",
|
|
234
|
+
"!**/node_modules/**",
|
|
235
|
+
"!**/dist/**",
|
|
236
|
+
"!**/.nuxt/**",
|
|
237
|
+
"!**/knip.config.mjs"
|
|
238
|
+
],
|
|
239
|
+
ignoreFiles: [...doctorConfig.exclude, "knip.config.mjs"],
|
|
240
|
+
ignoreDependencies: [
|
|
241
|
+
"vite-plus",
|
|
242
|
+
"@geoql/vue-doctor",
|
|
243
|
+
"@geoql/nuxt-doctor"
|
|
244
|
+
]
|
|
245
|
+
};
|
|
246
|
+
if (isNuxt) config.compilers = { nuxt: true };
|
|
247
|
+
return config;
|
|
248
|
+
}
|
|
249
|
+
//#endregion
|
|
250
|
+
//#region src/dead-code/dedupe.ts
|
|
251
|
+
function dedupeDeadCodeAgainstLint(deadCode, lint) {
|
|
252
|
+
const lintFiles = new Set(lint.map((d) => d.file));
|
|
253
|
+
return deadCode.filter((d) => !(d.ruleId === "dead-code/unused-file" && lintFiles.has(d.file)));
|
|
254
|
+
}
|
|
255
|
+
//#endregion
|
|
256
|
+
//#region src/dead-code/errors.ts
|
|
257
|
+
var DeadCodeTimeoutError = class extends Error {
|
|
258
|
+
name = "DeadCodeTimeoutError";
|
|
259
|
+
constructor(timeoutMs) {
|
|
260
|
+
super(`Dead-code analysis timed out after ${timeoutMs}ms`);
|
|
261
|
+
}
|
|
262
|
+
};
|
|
263
|
+
var DeadCodeImportFailed = class extends Error {
|
|
264
|
+
name = "DeadCodeImportFailed";
|
|
265
|
+
constructor(cause) {
|
|
266
|
+
const message = cause instanceof Error ? `Failed to import knip: ${cause.message}` : "Failed to import knip";
|
|
267
|
+
super(message, { cause });
|
|
268
|
+
}
|
|
269
|
+
};
|
|
270
|
+
//#endregion
|
|
271
|
+
//#region src/dead-code/map-knip-diagnostic.ts
|
|
272
|
+
const KIND_MAP = {
|
|
273
|
+
files: {
|
|
274
|
+
ruleId: "dead-code/unused-file",
|
|
275
|
+
severity: "warn",
|
|
276
|
+
message: "Unused file"
|
|
277
|
+
},
|
|
278
|
+
exports: {
|
|
279
|
+
ruleId: "dead-code/unused-export",
|
|
280
|
+
severity: "warn",
|
|
281
|
+
message: "Unused export"
|
|
282
|
+
},
|
|
283
|
+
types: {
|
|
284
|
+
ruleId: "dead-code/unused-type-export",
|
|
285
|
+
severity: "info",
|
|
286
|
+
message: "Unused type export"
|
|
287
|
+
},
|
|
288
|
+
enumMembers: {
|
|
289
|
+
ruleId: "dead-code/unused-member",
|
|
290
|
+
severity: "info",
|
|
291
|
+
message: "Unused enum member"
|
|
292
|
+
},
|
|
293
|
+
namespaceMembers: {
|
|
294
|
+
ruleId: "dead-code/unused-member",
|
|
295
|
+
severity: "info",
|
|
296
|
+
message: "Unused namespace member"
|
|
297
|
+
},
|
|
298
|
+
deps: {
|
|
299
|
+
ruleId: "dead-code/unused-dependency",
|
|
300
|
+
severity: "warn",
|
|
301
|
+
message: "Unused dependency"
|
|
302
|
+
},
|
|
303
|
+
devDependencies: {
|
|
304
|
+
ruleId: "dead-code/unused-dependency",
|
|
305
|
+
severity: "warn",
|
|
306
|
+
message: "Unused devDependency"
|
|
307
|
+
},
|
|
308
|
+
unlisted: {
|
|
309
|
+
ruleId: "dead-code/unlisted-dependency",
|
|
310
|
+
severity: "error",
|
|
311
|
+
message: "Unlisted dependency"
|
|
312
|
+
},
|
|
313
|
+
duplicates: {
|
|
314
|
+
ruleId: "dead-code/duplicate-export",
|
|
315
|
+
severity: "warn",
|
|
316
|
+
message: "Duplicate export"
|
|
317
|
+
},
|
|
318
|
+
nsExports: null,
|
|
319
|
+
nsTypes: null,
|
|
320
|
+
optionalPeerDependencies: null,
|
|
321
|
+
binaries: null,
|
|
322
|
+
unresolved: null,
|
|
323
|
+
catalog: null
|
|
324
|
+
};
|
|
325
|
+
function mapKnipDiagnostic(rootDirectory, issue) {
|
|
326
|
+
const mapping = KIND_MAP[issue.kind];
|
|
327
|
+
if (!mapping) return null;
|
|
328
|
+
return {
|
|
329
|
+
file: resolve(rootDirectory, issue.file),
|
|
330
|
+
line: issue.line ?? 1,
|
|
331
|
+
column: issue.col ?? 1,
|
|
332
|
+
ruleId: mapping.ruleId,
|
|
333
|
+
severity: mapping.severity,
|
|
334
|
+
message: issue.symbol ? `${mapping.message}: ${issue.symbol}` : mapping.message,
|
|
335
|
+
source: "dead-code"
|
|
336
|
+
};
|
|
337
|
+
}
|
|
338
|
+
//#endregion
|
|
339
|
+
//#region src/check-dead-code.ts
|
|
340
|
+
const MAPPED_KINDS = [
|
|
341
|
+
"files",
|
|
342
|
+
"exports",
|
|
343
|
+
"types",
|
|
344
|
+
"deps",
|
|
345
|
+
"devDependencies",
|
|
346
|
+
"unlisted",
|
|
347
|
+
"duplicates",
|
|
348
|
+
"enumMembers",
|
|
349
|
+
"namespaceMembers"
|
|
350
|
+
];
|
|
351
|
+
function flattenKnipIssues(issues) {
|
|
352
|
+
const out = [];
|
|
353
|
+
for (const kind of MAPPED_KINDS) {
|
|
354
|
+
const records = issues[kind];
|
|
355
|
+
if (!records) continue;
|
|
356
|
+
for (const filePath of Object.keys(records)) {
|
|
357
|
+
const symbols = records[filePath];
|
|
358
|
+
for (const symbolName of Object.keys(symbols)) {
|
|
359
|
+
const entry = symbols[symbolName];
|
|
360
|
+
out.push({
|
|
361
|
+
file: entry.filePath,
|
|
362
|
+
symbol: entry.symbol,
|
|
363
|
+
line: entry.line,
|
|
364
|
+
col: entry.col,
|
|
365
|
+
kind
|
|
366
|
+
});
|
|
367
|
+
}
|
|
368
|
+
}
|
|
369
|
+
}
|
|
370
|
+
return out;
|
|
371
|
+
}
|
|
372
|
+
const _knipLoader = { load: async () => {
|
|
373
|
+
const knipDir = createRequire(import.meta.url).resolve("knip").replace(/\/dist\/index\.js$/, "");
|
|
374
|
+
const { pathToFileURL } = await import("node:url");
|
|
375
|
+
const createOptionsUrl = pathToFileURL(join(knipDir, "dist", "util", "create-options.js")).href;
|
|
376
|
+
const runUrl = pathToFileURL(join(knipDir, "dist", "run.js")).href;
|
|
377
|
+
const createOptionsModule = await import(createOptionsUrl);
|
|
378
|
+
const runModule = await import(runUrl);
|
|
379
|
+
return {
|
|
380
|
+
createOptions: createOptionsModule.createOptions,
|
|
381
|
+
run: runModule.run
|
|
382
|
+
};
|
|
383
|
+
} };
|
|
384
|
+
async function checkDeadCode(options) {
|
|
385
|
+
if (!options.enabled) return [];
|
|
386
|
+
const config = buildKnipConfig(options.projectInfo, options.doctorConfig);
|
|
387
|
+
const timeoutMs = options.timeoutMs ?? 3e4;
|
|
388
|
+
let createOptions;
|
|
389
|
+
let run;
|
|
390
|
+
try {
|
|
391
|
+
const internals = await _knipLoader.load();
|
|
392
|
+
createOptions = internals.createOptions;
|
|
393
|
+
run = internals.run;
|
|
394
|
+
} catch (err) {
|
|
395
|
+
throw new DeadCodeImportFailed(err);
|
|
396
|
+
}
|
|
397
|
+
const knipOptions = await createOptions({
|
|
398
|
+
cwd: options.projectInfo.rootDirectory,
|
|
399
|
+
entry: config.entry,
|
|
400
|
+
project: config.project,
|
|
401
|
+
ignoreFiles: config.ignoreFiles,
|
|
402
|
+
ignoreDependencies: config.ignoreDependencies,
|
|
403
|
+
...config.compilers ? { compilers: config.compilers } : {}
|
|
404
|
+
});
|
|
405
|
+
const diagnostics = flattenKnipIssues((await Promise.race([run(knipOptions), new Promise((_, reject) => setTimeout(() => reject(new DeadCodeTimeoutError(timeoutMs)), timeoutMs))])).results.issues).map((issue) => mapKnipDiagnostic(options.projectInfo.rootDirectory, issue)).filter((d) => d !== null);
|
|
406
|
+
diagnostics.sort((a, b) => {
|
|
407
|
+
if (a.file !== b.file) return a.file < b.file ? -1 : 1;
|
|
408
|
+
if (a.line !== b.line) return a.line - b.line;
|
|
409
|
+
return a.ruleId.localeCompare(b.ruleId);
|
|
410
|
+
});
|
|
411
|
+
return diagnostics;
|
|
412
|
+
}
|
|
413
|
+
//#endregion
|
|
414
|
+
//#region src/deps/exec-list.ts
|
|
415
|
+
function collectVersions(entry, versions) {
|
|
416
|
+
if (entry.dependencies) {
|
|
417
|
+
for (const [name, info] of Object.entries(entry.dependencies)) if (name === "vue" && info.version) versions.add(info.version);
|
|
418
|
+
}
|
|
419
|
+
if (entry.devDependencies) {
|
|
420
|
+
for (const [name, info] of Object.entries(entry.devDependencies)) if (name === "vue" && info.version) versions.add(info.version);
|
|
421
|
+
}
|
|
422
|
+
if (entry.peers) {
|
|
423
|
+
for (const peer of entry.peers) if (peer.name === "vue" && peer.version) versions.add(peer.version);
|
|
424
|
+
}
|
|
425
|
+
}
|
|
426
|
+
async function runPnpmList(rootDir) {
|
|
427
|
+
return new Promise((resolve) => {
|
|
428
|
+
const timeout = setTimeout(() => {
|
|
429
|
+
resolve({
|
|
430
|
+
versions: [],
|
|
431
|
+
error: /* @__PURE__ */ new Error("timeout")
|
|
432
|
+
});
|
|
433
|
+
}, 1e4);
|
|
434
|
+
execFile("pnpm", [
|
|
435
|
+
"list",
|
|
436
|
+
"vue",
|
|
437
|
+
"--depth",
|
|
438
|
+
"Infinity",
|
|
439
|
+
"--json"
|
|
440
|
+
], {
|
|
441
|
+
cwd: rootDir,
|
|
442
|
+
timeout: 1e4
|
|
443
|
+
}, (error, stdout, stderr) => {
|
|
444
|
+
clearTimeout(timeout);
|
|
445
|
+
if (error || stderr) {
|
|
446
|
+
resolve({
|
|
447
|
+
versions: [],
|
|
448
|
+
error: error ?? new Error(stderr)
|
|
449
|
+
});
|
|
450
|
+
return;
|
|
451
|
+
}
|
|
452
|
+
try {
|
|
453
|
+
const parsed = JSON.parse(stdout);
|
|
454
|
+
const versions = /* @__PURE__ */ new Set();
|
|
455
|
+
for (const entry of parsed) collectVersions(entry, versions);
|
|
456
|
+
resolve({
|
|
457
|
+
versions: [...versions],
|
|
458
|
+
error: null
|
|
459
|
+
});
|
|
460
|
+
} catch {
|
|
461
|
+
resolve({
|
|
462
|
+
versions: [],
|
|
463
|
+
error: /* @__PURE__ */ new Error("parse error")
|
|
464
|
+
});
|
|
465
|
+
}
|
|
466
|
+
});
|
|
467
|
+
});
|
|
468
|
+
}
|
|
469
|
+
async function runNpmList(rootDir) {
|
|
470
|
+
return new Promise((resolve) => {
|
|
471
|
+
const timeout = setTimeout(() => {
|
|
472
|
+
resolve({
|
|
473
|
+
versions: [],
|
|
474
|
+
error: /* @__PURE__ */ new Error("timeout")
|
|
475
|
+
});
|
|
476
|
+
}, 1e4);
|
|
477
|
+
execFile("npm", [
|
|
478
|
+
"ls",
|
|
479
|
+
"vue",
|
|
480
|
+
"--all",
|
|
481
|
+
"--json"
|
|
482
|
+
], {
|
|
483
|
+
cwd: rootDir,
|
|
484
|
+
timeout: 1e4
|
|
485
|
+
}, (error, stdout, stderr) => {
|
|
486
|
+
clearTimeout(timeout);
|
|
487
|
+
if (error || stderr) {
|
|
488
|
+
resolve({
|
|
489
|
+
versions: [],
|
|
490
|
+
error: error ?? new Error(stderr)
|
|
491
|
+
});
|
|
492
|
+
return;
|
|
493
|
+
}
|
|
494
|
+
try {
|
|
495
|
+
const parsed = JSON.parse(stdout);
|
|
496
|
+
const versions = /* @__PURE__ */ new Set();
|
|
497
|
+
collectVersions(parsed, versions);
|
|
498
|
+
resolve({
|
|
499
|
+
versions: [...versions],
|
|
500
|
+
error: null
|
|
501
|
+
});
|
|
502
|
+
} catch {
|
|
503
|
+
resolve({
|
|
504
|
+
versions: [],
|
|
505
|
+
error: /* @__PURE__ */ new Error("parse error")
|
|
506
|
+
});
|
|
507
|
+
}
|
|
508
|
+
});
|
|
509
|
+
});
|
|
510
|
+
}
|
|
511
|
+
//#endregion
|
|
512
|
+
//#region src/deps/list-vue-resolutions.ts
|
|
513
|
+
async function listVueResolutions(packageJsonPath) {
|
|
514
|
+
const rootDir = dirname(packageJsonPath);
|
|
515
|
+
const pnpmResult = await runPnpmList(rootDir);
|
|
516
|
+
if (pnpmResult.error === null) return pnpmResult.versions;
|
|
517
|
+
const npmResult = await runNpmList(rootDir);
|
|
518
|
+
if (npmResult.error === null) return npmResult.versions;
|
|
519
|
+
return [];
|
|
520
|
+
}
|
|
521
|
+
//#endregion
|
|
522
|
+
//#region src/deps/check-duplicate-vue.ts
|
|
523
|
+
const RULE_ID$16 = "vue-doctor/deps/duplicate-vue-versions";
|
|
524
|
+
async function checkDuplicateVue(projectInfo) {
|
|
525
|
+
if (projectInfo.packageJsonPath === null) return [];
|
|
526
|
+
const versions = await listVueResolutions(projectInfo.packageJsonPath);
|
|
527
|
+
const uniqueVersions = [...new Set(versions)];
|
|
528
|
+
if (uniqueVersions.length <= 1) return [];
|
|
529
|
+
return [{
|
|
530
|
+
ruleId: RULE_ID$16,
|
|
531
|
+
file: projectInfo.packageJsonPath,
|
|
532
|
+
line: 1,
|
|
533
|
+
column: 1,
|
|
534
|
+
severity: "error",
|
|
535
|
+
message: `Multiple versions of vue detected in node_modules: ${uniqueVersions.join(", ")}. Vue's reactivity system requires a single instance.`,
|
|
536
|
+
recommendation: "Add \"vue\": \"^3.5.0\" to pnpm.overrides (or npm overrides) to deduplicate. Run pnpm dedupe.",
|
|
537
|
+
versions: uniqueVersions
|
|
538
|
+
}];
|
|
539
|
+
}
|
|
540
|
+
//#endregion
|
|
541
|
+
//#region src/deps/check-vue-major-current.ts
|
|
542
|
+
const RULE_ID$15 = "vue-doctor/deps/vue-major-current";
|
|
543
|
+
const DOCTOR_BUNDLED_VUE_FLOOR_MAJOR = 3;
|
|
544
|
+
const DOCTOR_BUNDLED_VUE_FLOOR_MINOR = 5;
|
|
545
|
+
function parseMajorMinor$1(version) {
|
|
546
|
+
const match = /^v?(\d+)\.(\d+)/.exec(version.trim());
|
|
547
|
+
if (!match) return null;
|
|
548
|
+
return {
|
|
549
|
+
major: Number(match[1]),
|
|
550
|
+
minor: Number(match[2])
|
|
551
|
+
};
|
|
552
|
+
}
|
|
553
|
+
function checkVueMajorCurrent(projectInfo) {
|
|
554
|
+
if (projectInfo.packageJsonPath === null) return [];
|
|
555
|
+
if (projectInfo.vueVersion === null) return [];
|
|
556
|
+
const parsed = parseMajorMinor$1(projectInfo.vueVersion);
|
|
557
|
+
if (parsed === null) return [];
|
|
558
|
+
if (parsed.major !== DOCTOR_BUNDLED_VUE_FLOOR_MAJOR) return [];
|
|
559
|
+
if (parsed.minor >= DOCTOR_BUNDLED_VUE_FLOOR_MINOR) return [];
|
|
560
|
+
return [{
|
|
561
|
+
ruleId: RULE_ID$15,
|
|
562
|
+
file: projectInfo.packageJsonPath,
|
|
563
|
+
line: 1,
|
|
564
|
+
column: 1,
|
|
565
|
+
severity: "info",
|
|
566
|
+
message: `Vue ${projectInfo.vueVersion} is older than the doctor-bundled floor (^${DOCTOR_BUNDLED_VUE_FLOOR_MAJOR}.${DOCTOR_BUNDLED_VUE_FLOOR_MINOR}.0). Newer minors often ship rule-relevant features (e.g. 3.4 reactive props destructure, 3.5 useTemplateRef).`,
|
|
567
|
+
recommendation: `Bump "vue" to ^${DOCTOR_BUNDLED_VUE_FLOOR_MAJOR}.${DOCTOR_BUNDLED_VUE_FLOOR_MINOR}.0 or later in package.json.`
|
|
568
|
+
}];
|
|
569
|
+
}
|
|
570
|
+
//#endregion
|
|
571
|
+
//#region src/check-deps.ts
|
|
572
|
+
async function checkDeps(projectInfo) {
|
|
573
|
+
if (projectInfo.packageJsonPath === null) return [];
|
|
574
|
+
return (await Promise.all([checkDuplicateVue(projectInfo), Promise.resolve(checkVueMajorCurrent(projectInfo))])).flat().map((issue) => ({
|
|
575
|
+
file: issue.file,
|
|
576
|
+
line: issue.line,
|
|
577
|
+
column: issue.column,
|
|
578
|
+
ruleId: issue.ruleId,
|
|
579
|
+
severity: issue.severity,
|
|
580
|
+
message: issue.message,
|
|
581
|
+
source: "deps",
|
|
582
|
+
recommendation: issue.recommendation
|
|
583
|
+
}));
|
|
584
|
+
}
|
|
585
|
+
//#endregion
|
|
586
|
+
//#region src/project-info/parse-nuxt-config.ts
|
|
587
|
+
const CONFIG_FILES = [
|
|
588
|
+
"nuxt.config.ts",
|
|
589
|
+
"nuxt.config.js",
|
|
590
|
+
"nuxt.config.mjs"
|
|
591
|
+
];
|
|
592
|
+
async function readConfigSource(dir) {
|
|
593
|
+
for (const name of CONFIG_FILES) try {
|
|
594
|
+
return await readFile(join(dir, name), "utf8");
|
|
595
|
+
} catch {
|
|
596
|
+
continue;
|
|
597
|
+
}
|
|
598
|
+
return null;
|
|
599
|
+
}
|
|
600
|
+
function* objectEntries(obj) {
|
|
601
|
+
for (const prop of obj.properties) {
|
|
602
|
+
if (prop.type !== "Property") continue;
|
|
603
|
+
const key = prop.key;
|
|
604
|
+
if (key.type !== "Identifier") continue;
|
|
605
|
+
yield [key.name, prop.value];
|
|
606
|
+
}
|
|
607
|
+
}
|
|
608
|
+
function findEntry(obj, name) {
|
|
609
|
+
for (const [key, value] of objectEntries(obj)) if (key === name) return value;
|
|
610
|
+
}
|
|
611
|
+
function stringLiteral(node) {
|
|
612
|
+
return node.type === "Literal" && typeof node.value === "string" ? node.value : void 0;
|
|
613
|
+
}
|
|
614
|
+
function unwrapDefault(decl) {
|
|
615
|
+
if (decl.type !== "CallExpression") return decl;
|
|
616
|
+
const callee = decl.callee;
|
|
617
|
+
if (callee.type !== "Identifier") return null;
|
|
618
|
+
if (callee.name !== "defineNuxtConfig") return null;
|
|
619
|
+
return decl.arguments[0] ?? null;
|
|
620
|
+
}
|
|
621
|
+
function extractConfigObject(source) {
|
|
622
|
+
const exported = parseSync("nuxt.config.ts", source, {
|
|
623
|
+
sourceType: "module",
|
|
624
|
+
lang: "ts"
|
|
625
|
+
}).program.body.find((n) => n.type === "ExportDefaultDeclaration");
|
|
626
|
+
if (!exported) return null;
|
|
627
|
+
const node = unwrapDefault(exported.declaration);
|
|
628
|
+
if (!node) return null;
|
|
629
|
+
return node.type === "ObjectExpression" ? node : null;
|
|
630
|
+
}
|
|
631
|
+
function readNitroPreset(value) {
|
|
632
|
+
if (value.type !== "ObjectExpression") return void 0;
|
|
633
|
+
const preset = findEntry(value, "preset");
|
|
634
|
+
return preset ? stringLiteral(preset) : void 0;
|
|
635
|
+
}
|
|
636
|
+
function readImportsAutoImport(value) {
|
|
637
|
+
if (value.type !== "ObjectExpression") return void 0;
|
|
638
|
+
const node = findEntry(value, "autoImport");
|
|
639
|
+
if (node?.type === "Literal" && typeof node.value === "boolean") return node.value;
|
|
640
|
+
}
|
|
641
|
+
function readModules(value) {
|
|
642
|
+
if (value.type !== "ArrayExpression") return void 0;
|
|
643
|
+
const out = [];
|
|
644
|
+
for (const element of value.elements) {
|
|
645
|
+
if (!element) continue;
|
|
646
|
+
const str = stringLiteral(element);
|
|
647
|
+
if (str !== void 0) out.push(str);
|
|
648
|
+
}
|
|
649
|
+
return out;
|
|
650
|
+
}
|
|
651
|
+
function readCompatibility(value) {
|
|
652
|
+
return value.type === "Literal" && typeof value.value === "number" ? value.value : void 0;
|
|
653
|
+
}
|
|
654
|
+
function readHtmlLang(value) {
|
|
655
|
+
if (value.type !== "ObjectExpression") return void 0;
|
|
656
|
+
const head = findEntry(value, "head");
|
|
657
|
+
if (head?.type !== "ObjectExpression") return void 0;
|
|
658
|
+
const htmlAttrs = findEntry(head, "htmlAttrs");
|
|
659
|
+
if (htmlAttrs?.type !== "ObjectExpression") return void 0;
|
|
660
|
+
const lang = findEntry(htmlAttrs, "lang");
|
|
661
|
+
return lang ? stringLiteral(lang) : void 0;
|
|
662
|
+
}
|
|
663
|
+
async function parseNuxtConfig(dir) {
|
|
664
|
+
const source = await readConfigSource(dir);
|
|
665
|
+
if (source === null) return null;
|
|
666
|
+
const info = {};
|
|
667
|
+
const config = extractConfigObject(source);
|
|
668
|
+
if (!config) return info;
|
|
669
|
+
for (const [key, value] of objectEntries(config)) if (key === "compatibilityVersion") {
|
|
670
|
+
const compat = readCompatibility(value);
|
|
671
|
+
if (compat !== void 0) info.compatibilityVersion = compat;
|
|
672
|
+
} else if (key === "compatibilityDate") {
|
|
673
|
+
const date = stringLiteral(value);
|
|
674
|
+
if (date !== void 0) info.compatibilityDate = date;
|
|
675
|
+
} else if (key === "nitro") {
|
|
676
|
+
const preset = readNitroPreset(value);
|
|
677
|
+
if (preset !== void 0) info.nitroPreset = preset;
|
|
678
|
+
} else if (key === "modules") {
|
|
679
|
+
const modules = readModules(value);
|
|
680
|
+
if (modules !== void 0) info.modules = modules;
|
|
681
|
+
} else if (key === "imports") {
|
|
682
|
+
const autoImport = readImportsAutoImport(value);
|
|
683
|
+
if (autoImport !== void 0) info.importsAutoImport = autoImport;
|
|
684
|
+
} else if (key === "runtimeConfig") info.hasRuntimeConfig = true;
|
|
685
|
+
else if (key === "app") {
|
|
686
|
+
const lang = readHtmlLang(value);
|
|
687
|
+
if (lang !== void 0) info.htmlLang = lang;
|
|
688
|
+
}
|
|
689
|
+
return info;
|
|
690
|
+
}
|
|
691
|
+
//#endregion
|
|
692
|
+
//#region src/nuxt/post-checks/compatibility-date-set.ts
|
|
693
|
+
const RULE_ID$14 = "nuxt-doctor/nitro/compatibilityDate-set";
|
|
694
|
+
function checkCompatibilityDateSet(projectInfo, nuxtConfig) {
|
|
695
|
+
if (projectInfo.packageJsonPath === null) return [];
|
|
696
|
+
if (nuxtConfig?.compatibilityDate !== void 0) return [];
|
|
697
|
+
return [{
|
|
698
|
+
ruleId: RULE_ID$14,
|
|
699
|
+
file: projectInfo.nuxtConfigPath ?? projectInfo.packageJsonPath,
|
|
700
|
+
line: 1,
|
|
701
|
+
column: 1,
|
|
702
|
+
severity: "error",
|
|
703
|
+
message: "No compatibilityDate is set. Nuxt 4 and Nitro require a compatibilityDate to pin deployment behaviour.",
|
|
704
|
+
recommendation: "Add compatibilityDate: 'YYYY-MM-DD' to defineNuxtConfig in nuxt.config."
|
|
705
|
+
}];
|
|
706
|
+
}
|
|
707
|
+
//#endregion
|
|
708
|
+
//#region src/nuxt/post-checks/lang-on-html.ts
|
|
709
|
+
const RULE_ID$13 = "nuxt-doctor/seo/lang-on-html";
|
|
710
|
+
function checkLangOnHtml(projectInfo, nuxtConfig) {
|
|
711
|
+
if (projectInfo.packageJsonPath === null) return [];
|
|
712
|
+
if (nuxtConfig?.htmlLang !== void 0) return [];
|
|
713
|
+
return [{
|
|
714
|
+
ruleId: RULE_ID$13,
|
|
715
|
+
file: projectInfo.nuxtConfigPath ?? projectInfo.packageJsonPath,
|
|
716
|
+
line: 1,
|
|
717
|
+
column: 1,
|
|
718
|
+
severity: "warn",
|
|
719
|
+
message: "No lang attribute is set on the html element. Screen readers and search engines rely on it.",
|
|
720
|
+
recommendation: "Set app.head.htmlAttrs.lang (e.g. 'en') in defineNuxtConfig."
|
|
721
|
+
}];
|
|
722
|
+
}
|
|
723
|
+
//#endregion
|
|
724
|
+
//#region src/nuxt/post-checks/nitro-cloudflare-preset.ts
|
|
725
|
+
const RULE_ID$12 = "nuxt-doctor/cloudflare/nitro-cloudflare-preset";
|
|
726
|
+
const CLOUDFLARE_PRESETS = new Set([
|
|
727
|
+
"cloudflare-pages",
|
|
728
|
+
"cloudflare-module",
|
|
729
|
+
"cloudflare"
|
|
730
|
+
]);
|
|
731
|
+
function checkNitroCloudflarePreset(projectInfo) {
|
|
732
|
+
if (projectInfo.packageJsonPath === null) return [];
|
|
733
|
+
if (!projectInfo.hasWranglerConfig) return [];
|
|
734
|
+
if (projectInfo.nitroPreset === null) return [];
|
|
735
|
+
if (CLOUDFLARE_PRESETS.has(projectInfo.nitroPreset)) return [];
|
|
736
|
+
return [{
|
|
737
|
+
ruleId: RULE_ID$12,
|
|
738
|
+
file: projectInfo.nuxtConfigPath ?? projectInfo.packageJsonPath,
|
|
739
|
+
line: 1,
|
|
740
|
+
column: 1,
|
|
741
|
+
severity: "warn",
|
|
742
|
+
message: `A wrangler config is present but the Nitro preset is '${projectInfo.nitroPreset}', not a Cloudflare preset.`,
|
|
743
|
+
recommendation: "Set nitro.preset to 'cloudflare-module' (or 'cloudflare-pages') to deploy on Cloudflare."
|
|
744
|
+
}];
|
|
745
|
+
}
|
|
746
|
+
//#endregion
|
|
747
|
+
//#region src/nuxt/post-checks/no-modules-incompatible-with-nuxt-4.ts
|
|
748
|
+
const RULE_ID$11 = "nuxt-doctor/modules-deps/no-modules-incompatible-with-nuxt-4";
|
|
749
|
+
const INCOMPATIBLE_MODULES = new Set([
|
|
750
|
+
"@nuxtjs/composition-api",
|
|
751
|
+
"@nuxt/bridge",
|
|
752
|
+
"nuxt-property-decorator"
|
|
753
|
+
]);
|
|
754
|
+
async function checkNoModulesIncompatibleWithNuxt4(projectInfo) {
|
|
755
|
+
if (projectInfo.packageJsonPath === null) return [];
|
|
756
|
+
const deps = await readDeps(projectInfo.packageJsonPath);
|
|
757
|
+
if (deps === null) return [];
|
|
758
|
+
const offenders = [];
|
|
759
|
+
for (const key of [...Object.keys(deps.dependencies), ...Object.keys(deps.devDependencies)]) if (INCOMPATIBLE_MODULES.has(key) && !offenders.includes(key)) offenders.push(key);
|
|
760
|
+
return offenders.map((name, index) => ({
|
|
761
|
+
ruleId: RULE_ID$11,
|
|
762
|
+
file: projectInfo.packageJsonPath,
|
|
763
|
+
line: index + 1,
|
|
764
|
+
column: 1,
|
|
765
|
+
severity: "warn",
|
|
766
|
+
message: `${name} is incompatible with Nuxt 4 and is no longer maintained.`,
|
|
767
|
+
recommendation: `Remove ${name} and adopt the Nuxt 4 native equivalent.`
|
|
768
|
+
}));
|
|
769
|
+
}
|
|
770
|
+
//#endregion
|
|
771
|
+
//#region src/nuxt/post-checks/no-node-only-modules.ts
|
|
772
|
+
const RULE_ID$10 = "nuxt-doctor/cloudflare/no-node-only-modules";
|
|
773
|
+
const NODE_ONLY_MODULES = new Set(["fs-extra", "sharp"]);
|
|
774
|
+
async function checkNoNodeOnlyModules(projectInfo) {
|
|
775
|
+
if (projectInfo.packageJsonPath === null) return [];
|
|
776
|
+
if (!projectInfo.hasWranglerConfig) return [];
|
|
777
|
+
const deps = await readDeps(projectInfo.packageJsonPath);
|
|
778
|
+
if (deps === null) return [];
|
|
779
|
+
const offenders = [];
|
|
780
|
+
for (const key of [...Object.keys(deps.dependencies), ...Object.keys(deps.devDependencies)]) if (NODE_ONLY_MODULES.has(key) && !offenders.includes(key)) offenders.push(key);
|
|
781
|
+
return offenders.map((name, index) => ({
|
|
782
|
+
ruleId: RULE_ID$10,
|
|
783
|
+
file: projectInfo.packageJsonPath,
|
|
784
|
+
line: index + 1,
|
|
785
|
+
column: 1,
|
|
786
|
+
severity: "warn",
|
|
787
|
+
message: `${name} is a Node-only module and does not run on the Cloudflare Workers runtime.`,
|
|
788
|
+
recommendation: `Replace ${name} with a Workers-compatible alternative or move it to a build-only step.`
|
|
789
|
+
}));
|
|
790
|
+
}
|
|
791
|
+
//#endregion
|
|
792
|
+
//#region src/nuxt/post-checks/nuxt-major-current.ts
|
|
793
|
+
const RULE_ID$9 = "nuxt-doctor/structure/nuxt-major-current";
|
|
794
|
+
const DOCTOR_BUNDLED_NUXT_FLOOR_MAJOR = 4;
|
|
795
|
+
const DOCTOR_BUNDLED_NUXT_FLOOR_MINOR = 4;
|
|
796
|
+
function parseMajorMinor(version) {
|
|
797
|
+
const match = /^v?(\d+)\.(\d+)/.exec(version.trim());
|
|
798
|
+
if (!match) return null;
|
|
799
|
+
return {
|
|
800
|
+
major: Number(match[1]),
|
|
801
|
+
minor: Number(match[2])
|
|
802
|
+
};
|
|
803
|
+
}
|
|
804
|
+
function checkNuxtMajorCurrent(projectInfo) {
|
|
805
|
+
if (projectInfo.packageJsonPath === null) return [];
|
|
806
|
+
if (projectInfo.nuxtVersion === null) return [];
|
|
807
|
+
const parsed = parseMajorMinor(projectInfo.nuxtVersion);
|
|
808
|
+
if (parsed === null) return [];
|
|
809
|
+
if (parsed.major !== DOCTOR_BUNDLED_NUXT_FLOOR_MAJOR) return [];
|
|
810
|
+
if (parsed.minor >= DOCTOR_BUNDLED_NUXT_FLOOR_MINOR) return [];
|
|
811
|
+
return [{
|
|
812
|
+
ruleId: RULE_ID$9,
|
|
813
|
+
file: projectInfo.packageJsonPath,
|
|
814
|
+
line: 1,
|
|
815
|
+
column: 1,
|
|
816
|
+
severity: "info",
|
|
817
|
+
message: `Nuxt ${projectInfo.nuxtVersion} is older than the doctor-bundled floor (^${DOCTOR_BUNDLED_NUXT_FLOOR_MAJOR}.${DOCTOR_BUNDLED_NUXT_FLOOR_MINOR}.0). Newer minors ship rule-relevant fixes and Nitro improvements.`,
|
|
818
|
+
recommendation: `Bump "nuxt" to ^${DOCTOR_BUNDLED_NUXT_FLOOR_MAJOR}.${DOCTOR_BUNDLED_NUXT_FLOOR_MINOR}.0 or later in package.json.`
|
|
819
|
+
}];
|
|
820
|
+
}
|
|
821
|
+
//#endregion
|
|
822
|
+
//#region src/nuxt/post-checks/og-image-via-satori.ts
|
|
823
|
+
const RULE_ID$8 = "nuxt-doctor/cloudflare/og-image-via-satori";
|
|
824
|
+
const OG_IMAGE_MODULES = ["@nuxtjs/og-image", "nuxt-og-image"];
|
|
825
|
+
async function checkOgImageViaSatori(projectInfo) {
|
|
826
|
+
if (projectInfo.packageJsonPath === null) return [];
|
|
827
|
+
if (!projectInfo.hasWranglerConfig) return [];
|
|
828
|
+
const deps = await readDeps(projectInfo.packageJsonPath);
|
|
829
|
+
if (deps === null) return [];
|
|
830
|
+
const installed = new Set([...Object.keys(deps.dependencies), ...Object.keys(deps.devDependencies)]);
|
|
831
|
+
if (OG_IMAGE_MODULES.some((name) => installed.has(name))) return [];
|
|
832
|
+
return [{
|
|
833
|
+
ruleId: RULE_ID$8,
|
|
834
|
+
file: projectInfo.packageJsonPath,
|
|
835
|
+
line: 1,
|
|
836
|
+
column: 1,
|
|
837
|
+
severity: "info",
|
|
838
|
+
message: "Running on Cloudflare with no OG image module installed; dynamic social images are unconfigured.",
|
|
839
|
+
recommendation: "Add @nuxtjs/og-image to generate Satori-based OG images that run on Cloudflare Workers."
|
|
840
|
+
}];
|
|
841
|
+
}
|
|
842
|
+
//#endregion
|
|
843
|
+
//#region src/nuxt/post-checks/preset-defined-for-deploy-target.ts
|
|
844
|
+
const RULE_ID$7 = "nuxt-doctor/nitro/preset-defined-for-deploy-target";
|
|
845
|
+
function checkPresetDefinedForDeployTarget(projectInfo) {
|
|
846
|
+
if (projectInfo.packageJsonPath === null) return [];
|
|
847
|
+
if (!projectInfo.hasWranglerConfig) return [];
|
|
848
|
+
if (projectInfo.nitroPreset !== null) return [];
|
|
849
|
+
return [{
|
|
850
|
+
ruleId: RULE_ID$7,
|
|
851
|
+
file: projectInfo.nuxtConfigPath ?? projectInfo.packageJsonPath,
|
|
852
|
+
line: 1,
|
|
853
|
+
column: 1,
|
|
854
|
+
severity: "warn",
|
|
855
|
+
message: "A wrangler config is present but no Nitro preset is defined for the deploy target.",
|
|
856
|
+
recommendation: "Set nitro.preset in nuxt.config (e.g. 'cloudflare-module') to match your deploy target."
|
|
857
|
+
}];
|
|
858
|
+
}
|
|
859
|
+
//#endregion
|
|
860
|
+
//#region src/nuxt/post-checks/recommended-modules-installed.ts
|
|
861
|
+
const RULE_ID$6 = "nuxt-doctor/modules-deps/recommended-modules-installed";
|
|
862
|
+
const RECOMMENDED_GROUPS = [
|
|
863
|
+
{
|
|
864
|
+
label: "@nuxt/image",
|
|
865
|
+
candidates: ["@nuxt/image"]
|
|
866
|
+
},
|
|
867
|
+
{
|
|
868
|
+
label: "@nuxtjs/seo",
|
|
869
|
+
candidates: ["@nuxtjs/seo", "@nuxtjs/sitemap"]
|
|
870
|
+
},
|
|
871
|
+
{
|
|
872
|
+
label: "@nuxt/fonts",
|
|
873
|
+
candidates: ["@nuxt/fonts"]
|
|
874
|
+
}
|
|
875
|
+
];
|
|
876
|
+
async function checkRecommendedModulesInstalled(projectInfo) {
|
|
877
|
+
if (projectInfo.packageJsonPath === null) return [];
|
|
878
|
+
const deps = await readDeps(projectInfo.packageJsonPath);
|
|
879
|
+
if (deps === null) return [];
|
|
880
|
+
const installed = new Set([...Object.keys(deps.dependencies), ...Object.keys(deps.devDependencies)]);
|
|
881
|
+
return RECOMMENDED_GROUPS.filter((group) => !group.candidates.some((name) => installed.has(name))).map((group, index) => ({
|
|
882
|
+
ruleId: RULE_ID$6,
|
|
883
|
+
file: projectInfo.packageJsonPath,
|
|
884
|
+
line: index + 1,
|
|
885
|
+
column: 1,
|
|
886
|
+
severity: "info",
|
|
887
|
+
message: `Recommended Nuxt 4 module ${group.label} is not installed.`,
|
|
888
|
+
recommendation: `Add ${group.label} to dependencies to improve image, SEO, or font handling.`
|
|
889
|
+
}));
|
|
890
|
+
}
|
|
891
|
+
//#endregion
|
|
892
|
+
//#region src/nuxt/post-checks/runtime-config-typed.ts
|
|
893
|
+
const RULE_ID$5 = "nuxt-doctor/nitro/runtime-config-typed";
|
|
894
|
+
async function hasRuntimeConfigAugmentation(dir) {
|
|
895
|
+
const entries = await readdir(dir);
|
|
896
|
+
for (const name of entries) {
|
|
897
|
+
if (!name.endsWith(".d.ts")) continue;
|
|
898
|
+
if ((await readFile(join(dir, name), "utf8")).includes("RuntimeConfig")) return true;
|
|
899
|
+
}
|
|
900
|
+
return false;
|
|
901
|
+
}
|
|
902
|
+
async function checkRuntimeConfigTyped(projectInfo, nuxtConfig) {
|
|
903
|
+
if (projectInfo.packageJsonPath === null) return [];
|
|
904
|
+
if (nuxtConfig?.hasRuntimeConfig !== true) return [];
|
|
905
|
+
if (await hasRuntimeConfigAugmentation(projectInfo.rootDirectory)) return [];
|
|
906
|
+
return [{
|
|
907
|
+
ruleId: RULE_ID$5,
|
|
908
|
+
file: projectInfo.packageJsonPath,
|
|
909
|
+
line: 1,
|
|
910
|
+
column: 1,
|
|
911
|
+
severity: "info",
|
|
912
|
+
message: "runtimeConfig is defined but no RuntimeConfig type augmentation was found, so runtime config access is untyped.",
|
|
913
|
+
recommendation: "Augment 'nuxt/schema' with a RuntimeConfig interface in a .d.ts file to type useRuntimeConfig()."
|
|
914
|
+
}];
|
|
915
|
+
}
|
|
916
|
+
//#endregion
|
|
917
|
+
//#region src/nuxt/post-checks/uses-app-directory.ts
|
|
918
|
+
const RULE_ID$4 = "nuxt-doctor/structure/uses-app-directory";
|
|
919
|
+
function checkUsesAppDirectory(projectInfo) {
|
|
920
|
+
if (projectInfo.packageJsonPath === null) return [];
|
|
921
|
+
if (projectInfo.hasAppDir) return [];
|
|
922
|
+
return [{
|
|
923
|
+
ruleId: RULE_ID$4,
|
|
924
|
+
file: projectInfo.packageJsonPath,
|
|
925
|
+
line: 1,
|
|
926
|
+
column: 1,
|
|
927
|
+
severity: "warn",
|
|
928
|
+
message: "No app/ directory found. Nuxt 4 expects source under app/ (app/pages, app/components, app/app.vue).",
|
|
929
|
+
recommendation: "Create an app/ directory and move pages, components, layouts, and app.vue into it."
|
|
930
|
+
}];
|
|
931
|
+
}
|
|
932
|
+
//#endregion
|
|
933
|
+
//#region src/check-nuxt-project.ts
|
|
934
|
+
async function checkNuxtProject(projectInfo) {
|
|
935
|
+
if (projectInfo.packageJsonPath === null) return [];
|
|
936
|
+
if (projectInfo.framework !== "nuxt") return [];
|
|
937
|
+
const nuxtConfig = await parseNuxtConfig(projectInfo.rootDirectory);
|
|
938
|
+
return (await Promise.all([
|
|
939
|
+
Promise.resolve(checkUsesAppDirectory(projectInfo)),
|
|
940
|
+
Promise.resolve(checkNuxtMajorCurrent(projectInfo)),
|
|
941
|
+
checkNoModulesIncompatibleWithNuxt4(projectInfo),
|
|
942
|
+
checkRecommendedModulesInstalled(projectInfo),
|
|
943
|
+
Promise.resolve(checkCompatibilityDateSet(projectInfo, nuxtConfig)),
|
|
944
|
+
Promise.resolve(checkPresetDefinedForDeployTarget(projectInfo)),
|
|
945
|
+
checkRuntimeConfigTyped(projectInfo, nuxtConfig),
|
|
946
|
+
Promise.resolve(checkLangOnHtml(projectInfo, nuxtConfig)),
|
|
947
|
+
Promise.resolve(checkNitroCloudflarePreset(projectInfo)),
|
|
948
|
+
checkOgImageViaSatori(projectInfo),
|
|
949
|
+
checkNoNodeOnlyModules(projectInfo)
|
|
950
|
+
])).flat().map((issue) => ({
|
|
951
|
+
file: issue.file,
|
|
952
|
+
line: issue.line,
|
|
953
|
+
column: issue.column,
|
|
954
|
+
ruleId: issue.ruleId,
|
|
955
|
+
severity: issue.severity,
|
|
956
|
+
message: issue.message,
|
|
957
|
+
source: "project",
|
|
958
|
+
recommendation: issue.recommendation
|
|
959
|
+
}));
|
|
960
|
+
}
|
|
961
|
+
//#endregion
|
|
962
|
+
//#region src/disables/parse-directives.ts
|
|
963
|
+
const DIRECTIVE = /doctor-(disable-next-line|disable-line|disable|enable)\b(.*)/;
|
|
964
|
+
function parseRuleList(raw) {
|
|
965
|
+
return raw.replace(/-->\s*$/, "").split(",").map((token) => token.trim()).filter((token) => token.length > 0);
|
|
966
|
+
}
|
|
967
|
+
function parseDirectives(text) {
|
|
968
|
+
const blocks = [];
|
|
969
|
+
const nextLine = [];
|
|
970
|
+
const sameLine = [];
|
|
971
|
+
const lines = text.split("\n");
|
|
972
|
+
let open = null;
|
|
973
|
+
for (let index = 0; index < lines.length; index += 1) {
|
|
974
|
+
const match = DIRECTIVE.exec(lines[index]);
|
|
975
|
+
if (!match) continue;
|
|
976
|
+
const keyword = match[1];
|
|
977
|
+
const rules = parseRuleList(match[2]);
|
|
978
|
+
const lineNumber = index + 1;
|
|
979
|
+
if (keyword === "disable-next-line") nextLine.push({
|
|
980
|
+
line: lineNumber + 1,
|
|
981
|
+
rules
|
|
982
|
+
});
|
|
983
|
+
else if (keyword === "disable-line") sameLine.push({
|
|
984
|
+
line: lineNumber,
|
|
985
|
+
rules
|
|
986
|
+
});
|
|
987
|
+
else if (keyword === "disable") {
|
|
988
|
+
if (!open) open = {
|
|
989
|
+
start: lineNumber,
|
|
990
|
+
rules
|
|
991
|
+
};
|
|
992
|
+
} else if (open) {
|
|
993
|
+
blocks.push({
|
|
994
|
+
start: open.start,
|
|
995
|
+
end: lineNumber,
|
|
996
|
+
rules: open.rules
|
|
997
|
+
});
|
|
998
|
+
open = null;
|
|
999
|
+
}
|
|
1000
|
+
}
|
|
1001
|
+
if (open) blocks.push({
|
|
1002
|
+
start: open.start,
|
|
1003
|
+
end: lines.length,
|
|
1004
|
+
rules: open.rules
|
|
1005
|
+
});
|
|
1006
|
+
return {
|
|
1007
|
+
blocks,
|
|
1008
|
+
nextLine,
|
|
1009
|
+
sameLine
|
|
1010
|
+
};
|
|
1011
|
+
}
|
|
1012
|
+
//#endregion
|
|
1013
|
+
//#region src/disables/apply.ts
|
|
1014
|
+
function ruleMatches(ruleId, rules) {
|
|
1015
|
+
if (rules.length === 0) return true;
|
|
1016
|
+
return rules.some((token) => ruleId === token || ruleId.endsWith(`/${token}`));
|
|
1017
|
+
}
|
|
1018
|
+
function isSuppressed(set, diagnostic) {
|
|
1019
|
+
const { line, ruleId } = diagnostic;
|
|
1020
|
+
for (const block of set.blocks) if (line >= block.start && line <= block.end && ruleMatches(ruleId, block.rules)) return true;
|
|
1021
|
+
for (const target of set.nextLine) if (target.line === line && ruleMatches(ruleId, target.rules)) return true;
|
|
1022
|
+
for (const target of set.sameLine) if (target.line === line && ruleMatches(ruleId, target.rules)) return true;
|
|
1023
|
+
return false;
|
|
1024
|
+
}
|
|
1025
|
+
function loadDirectives(file, cache) {
|
|
1026
|
+
const cached = cache.get(file);
|
|
1027
|
+
if (cached !== void 0) return cached;
|
|
1028
|
+
let set;
|
|
1029
|
+
try {
|
|
1030
|
+
set = parseDirectives(readFileSync(file, "utf-8"));
|
|
1031
|
+
} catch {
|
|
1032
|
+
set = null;
|
|
1033
|
+
}
|
|
1034
|
+
cache.set(file, set);
|
|
1035
|
+
return set;
|
|
1036
|
+
}
|
|
1037
|
+
function applyInlineDisables(diags, opts) {
|
|
1038
|
+
if (!opts.respect) return diags;
|
|
1039
|
+
const cache = /* @__PURE__ */ new Map();
|
|
1040
|
+
return diags.filter((diagnostic) => {
|
|
1041
|
+
const set = loadDirectives(diagnostic.file, cache);
|
|
1042
|
+
return set === null || !isSuppressed(set, diagnostic);
|
|
1043
|
+
});
|
|
1044
|
+
}
|
|
1045
|
+
//#endregion
|
|
1046
|
+
//#region src/code-snippet.ts
|
|
1047
|
+
async function readLines(file) {
|
|
1048
|
+
try {
|
|
1049
|
+
return (await readFile(file, "utf-8")).split("\n");
|
|
1050
|
+
} catch {
|
|
1051
|
+
return [];
|
|
1052
|
+
}
|
|
1053
|
+
}
|
|
1054
|
+
async function attachCodeSnippets(diagnostics) {
|
|
1055
|
+
const cache = /* @__PURE__ */ new Map();
|
|
1056
|
+
const out = [];
|
|
1057
|
+
for (const d of diagnostics) {
|
|
1058
|
+
let lines = cache.get(d.file);
|
|
1059
|
+
if (lines === void 0) {
|
|
1060
|
+
lines = await readLines(d.file);
|
|
1061
|
+
cache.set(d.file, lines);
|
|
1062
|
+
}
|
|
1063
|
+
const raw = lines[d.line - 1];
|
|
1064
|
+
if (raw === void 0) out.push(d);
|
|
1065
|
+
else out.push({
|
|
1066
|
+
...d,
|
|
1067
|
+
codeSnippet: raw.trim()
|
|
1068
|
+
});
|
|
1069
|
+
}
|
|
1070
|
+
return out;
|
|
1071
|
+
}
|
|
1072
|
+
//#endregion
|
|
1073
|
+
//#region src/project-info/fs-checks.ts
|
|
1074
|
+
/**
|
|
1075
|
+
* Synchronously reports whether `target` exists and is a directory. Missing
|
|
1076
|
+
* paths and non-directory entries both return false rather than throwing.
|
|
1077
|
+
*/
|
|
1078
|
+
function directoryExists(target) {
|
|
1079
|
+
try {
|
|
1080
|
+
return statSync(target).isDirectory();
|
|
1081
|
+
} catch {
|
|
1082
|
+
return false;
|
|
1083
|
+
}
|
|
1084
|
+
}
|
|
1085
|
+
/**
|
|
1086
|
+
* Returns the absolute path of the first existing candidate file (resolved
|
|
1087
|
+
* against `dir`), or null when none of the candidates exist.
|
|
1088
|
+
*/
|
|
1089
|
+
function resolveExistingFile(dir, candidates) {
|
|
1090
|
+
for (const name of candidates) {
|
|
1091
|
+
const full = join(dir, name);
|
|
1092
|
+
if (existsSync(full)) return full;
|
|
1093
|
+
}
|
|
1094
|
+
return null;
|
|
1095
|
+
}
|
|
1096
|
+
//#endregion
|
|
1097
|
+
//#region src/project-info/path-exists.ts
|
|
1098
|
+
async function pathExists(target) {
|
|
1099
|
+
try {
|
|
1100
|
+
await access(target);
|
|
1101
|
+
return true;
|
|
1102
|
+
} catch {
|
|
1103
|
+
return false;
|
|
1104
|
+
}
|
|
1105
|
+
}
|
|
1106
|
+
//#endregion
|
|
1107
|
+
//#region src/project-info/find-monorepo-root.ts
|
|
1108
|
+
async function detectKind(dir) {
|
|
1109
|
+
if (await pathExists(join(dir, "pnpm-workspace.yaml"))) return "pnpm";
|
|
1110
|
+
if ((await readPackageJson(dir))?.workspaces) {
|
|
1111
|
+
if (await pathExists(join(dir, "yarn.lock"))) return "yarn";
|
|
1112
|
+
if (await pathExists(join(dir, "package-lock.json"))) return "npm";
|
|
1113
|
+
}
|
|
1114
|
+
if (await pathExists(join(dir, "turbo.json"))) return "turbo";
|
|
1115
|
+
return null;
|
|
1116
|
+
}
|
|
1117
|
+
async function findMonorepoRoot(rootDirectory) {
|
|
1118
|
+
let current = rootDirectory;
|
|
1119
|
+
for (;;) {
|
|
1120
|
+
const kind = await detectKind(current);
|
|
1121
|
+
if (kind) return {
|
|
1122
|
+
root: current,
|
|
1123
|
+
kind
|
|
1124
|
+
};
|
|
1125
|
+
const parent = dirname(current);
|
|
1126
|
+
if (parent === current) break;
|
|
1127
|
+
current = parent;
|
|
1128
|
+
}
|
|
1129
|
+
return {
|
|
1130
|
+
root: rootDirectory,
|
|
1131
|
+
kind: null
|
|
1132
|
+
};
|
|
1133
|
+
}
|
|
1134
|
+
//#endregion
|
|
1135
|
+
//#region src/project-info/resolve-dep-version.ts
|
|
1136
|
+
async function readInstalledVersion(base, dep) {
|
|
1137
|
+
try {
|
|
1138
|
+
const source = await readFile(join(base, "node_modules", dep, "package.json"), "utf8");
|
|
1139
|
+
return JSON.parse(source).version ?? null;
|
|
1140
|
+
} catch {
|
|
1141
|
+
return null;
|
|
1142
|
+
}
|
|
1143
|
+
}
|
|
1144
|
+
function declaredRange(dep, pkg) {
|
|
1145
|
+
return pkg?.dependencies?.[dep] ?? pkg?.devDependencies?.[dep] ?? null;
|
|
1146
|
+
}
|
|
1147
|
+
async function resolveDepVersion(dep, rootDirectory, monorepoRoot, pkg) {
|
|
1148
|
+
const installed = await readInstalledVersion(rootDirectory, dep) ?? await readInstalledVersion(monorepoRoot, dep);
|
|
1149
|
+
if (installed) return installed;
|
|
1150
|
+
const range = declaredRange(dep, pkg);
|
|
1151
|
+
if (!range) return null;
|
|
1152
|
+
return coerce(range)?.version ?? null;
|
|
1153
|
+
}
|
|
1154
|
+
//#endregion
|
|
1155
|
+
//#region src/project-info/parse-nuxt-version.ts
|
|
1156
|
+
function parseNuxtVersion(rootDirectory, monorepoRoot, pkg) {
|
|
1157
|
+
return resolveDepVersion("nuxt", rootDirectory, monorepoRoot, pkg);
|
|
1158
|
+
}
|
|
1159
|
+
//#endregion
|
|
1160
|
+
//#region src/project-info/parse-vue-version.ts
|
|
1161
|
+
function parseVueVersion(rootDirectory, monorepoRoot, pkg) {
|
|
1162
|
+
return resolveDepVersion("vue", rootDirectory, monorepoRoot, pkg);
|
|
1163
|
+
}
|
|
1164
|
+
//#endregion
|
|
1165
|
+
//#region src/detect-project.ts
|
|
1166
|
+
function hasDependency(pkg, name) {
|
|
1167
|
+
return Boolean(pkg?.dependencies?.[name] ?? pkg?.devDependencies?.[name]);
|
|
1168
|
+
}
|
|
1169
|
+
function resolveFramework(pkg) {
|
|
1170
|
+
if (hasDependency(pkg, "nuxt")) return "nuxt";
|
|
1171
|
+
if (hasDependency(pkg, "vue")) return "vue";
|
|
1172
|
+
return "unknown";
|
|
1173
|
+
}
|
|
1174
|
+
function resolveCompatibility(nuxtVersion, compatibilityVersion) {
|
|
1175
|
+
const nuxtMajor = nuxtVersion ? major(nuxtVersion) : 0;
|
|
1176
|
+
if (compatibilityVersion === 4 || nuxtMajor >= 4) return 4;
|
|
1177
|
+
if (compatibilityVersion === 3) return 3;
|
|
1178
|
+
return null;
|
|
1179
|
+
}
|
|
1180
|
+
function buildCapabilities(input) {
|
|
1181
|
+
const caps = /* @__PURE__ */ new Set();
|
|
1182
|
+
if (input.framework === "unknown") return caps;
|
|
1183
|
+
if (input.vueVersion && major(input.vueVersion) === 3) {
|
|
1184
|
+
caps.add("vue:3");
|
|
1185
|
+
if (minor(input.vueVersion) >= 4) caps.add("vue:3.4");
|
|
1186
|
+
if (minor(input.vueVersion) >= 5) caps.add("vue:3.5");
|
|
1187
|
+
}
|
|
1188
|
+
if (input.nuxtCompatibilityVersion === 4) caps.add("nuxt:4");
|
|
1189
|
+
if (input.nuxtVersion && gte(input.nuxtVersion, "4.4.0")) caps.add("nuxt:4.4");
|
|
1190
|
+
if (input.isNuxtMajor4) caps.add("nuxt4");
|
|
1191
|
+
if (input.hasNuxtConfig) caps.add("nuxt-config");
|
|
1192
|
+
if (input.hasAppDir) caps.add("app-dir");
|
|
1193
|
+
if (input.hasServerDir) caps.add("server-dir");
|
|
1194
|
+
if (input.hasPagesDir) caps.add("pages-dir");
|
|
1195
|
+
if (input.hasWrangler) caps.add("wrangler");
|
|
1196
|
+
if (input.hasAutoImports) caps.add("auto-imports:vue");
|
|
1197
|
+
if (input.hasComponentsAutoImport) caps.add("components:auto");
|
|
1198
|
+
if (input.hasPinia) caps.add("pinia");
|
|
1199
|
+
if (input.hasVueRouter) caps.add("vue-router");
|
|
1200
|
+
if (input.hasTypescript) caps.add("typescript");
|
|
1201
|
+
if (input.typescriptVersion && major(input.typescriptVersion) >= 6) caps.add("typescript:6");
|
|
1202
|
+
if (input.monorepoKind === "pnpm" || input.monorepoKind === "yarn" || input.monorepoKind === "npm") caps.add(`monorepo:${input.monorepoKind}`);
|
|
1203
|
+
if (input.hasWrangler || input.nitroPreset === "cloudflare-pages") caps.add("cf-pages:enabled");
|
|
1204
|
+
if (input.nitroPreset === "node-server") caps.add("nitro:node-server");
|
|
1205
|
+
return caps;
|
|
1206
|
+
}
|
|
1207
|
+
async function detectProject(rootDirectory) {
|
|
1208
|
+
const pkg = await readPackageJson(rootDirectory);
|
|
1209
|
+
const packageJsonPath = pkg ? join(rootDirectory, "package.json") : null;
|
|
1210
|
+
const { root: monorepoRoot, kind: monorepoKind } = await findMonorepoRoot(rootDirectory);
|
|
1211
|
+
const framework = resolveFramework(pkg);
|
|
1212
|
+
const vueVersion = await parseVueVersion(rootDirectory, monorepoRoot, pkg);
|
|
1213
|
+
const nuxtVersion = await parseNuxtVersion(rootDirectory, monorepoRoot, pkg);
|
|
1214
|
+
const typescriptVersion = await resolveDepVersion("typescript", rootDirectory, monorepoRoot, pkg);
|
|
1215
|
+
const nuxtConfig = framework === "nuxt" ? await parseNuxtConfig(rootDirectory) : null;
|
|
1216
|
+
const nitroPreset = nuxtConfig?.nitroPreset ?? null;
|
|
1217
|
+
const nuxtCompatibilityVersion = resolveCompatibility(nuxtVersion, nuxtConfig?.compatibilityVersion);
|
|
1218
|
+
const isNuxt = framework === "nuxt";
|
|
1219
|
+
const hasAutoImports = isNuxt ? nuxtConfig?.importsAutoImport !== false : hasDependency(pkg, "unplugin-auto-import");
|
|
1220
|
+
const hasComponentsAutoImport = isNuxt || hasDependency(pkg, "unplugin-vue-components");
|
|
1221
|
+
const hasPinia = hasDependency(pkg, "pinia");
|
|
1222
|
+
const hasVueRouter = hasDependency(pkg, "vue-router") && !isNuxt;
|
|
1223
|
+
const tsconfigExists = await pathExists(join(rootDirectory, "tsconfig.json"));
|
|
1224
|
+
const hasTypescript = hasDependency(pkg, "typescript") || tsconfigExists;
|
|
1225
|
+
const hasWranglerConfig = resolveExistingFile(rootDirectory, [
|
|
1226
|
+
"wrangler.toml",
|
|
1227
|
+
"wrangler.jsonc",
|
|
1228
|
+
"wrangler.json"
|
|
1229
|
+
]) !== null;
|
|
1230
|
+
const nuxtConfigPath = resolveExistingFile(rootDirectory, [
|
|
1231
|
+
"nuxt.config.ts",
|
|
1232
|
+
"nuxt.config.js",
|
|
1233
|
+
"nuxt.config.mjs"
|
|
1234
|
+
]);
|
|
1235
|
+
const appDirPath = join(rootDirectory, "app");
|
|
1236
|
+
const hasAppDir = directoryExists(appDirPath);
|
|
1237
|
+
const hasServerDir = directoryExists(join(rootDirectory, "server"));
|
|
1238
|
+
const hasPagesDir = hasAppDir && directoryExists(join(appDirPath, "pages")) || directoryExists(join(rootDirectory, "pages"));
|
|
1239
|
+
const capabilities = buildCapabilities({
|
|
1240
|
+
framework,
|
|
1241
|
+
vueVersion,
|
|
1242
|
+
nuxtVersion,
|
|
1243
|
+
typescriptVersion,
|
|
1244
|
+
hasTypescript,
|
|
1245
|
+
hasAutoImports,
|
|
1246
|
+
hasComponentsAutoImport,
|
|
1247
|
+
hasPinia,
|
|
1248
|
+
hasVueRouter,
|
|
1249
|
+
nitroPreset,
|
|
1250
|
+
nuxtCompatibilityVersion,
|
|
1251
|
+
monorepoKind,
|
|
1252
|
+
hasWrangler: hasWranglerConfig,
|
|
1253
|
+
isNuxtMajor4: nuxtVersion !== null && major(nuxtVersion) >= 4,
|
|
1254
|
+
hasNuxtConfig: nuxtConfigPath !== null,
|
|
1255
|
+
hasAppDir,
|
|
1256
|
+
hasServerDir,
|
|
1257
|
+
hasPagesDir
|
|
1258
|
+
});
|
|
1259
|
+
return {
|
|
1260
|
+
framework,
|
|
1261
|
+
rootDirectory,
|
|
1262
|
+
packageJsonPath,
|
|
1263
|
+
vueVersion,
|
|
1264
|
+
nuxtVersion,
|
|
1265
|
+
typescriptVersion,
|
|
1266
|
+
hasAutoImports,
|
|
1267
|
+
hasComponentsAutoImport,
|
|
1268
|
+
hasPinia,
|
|
1269
|
+
hasVueRouter,
|
|
1270
|
+
nitroPreset,
|
|
1271
|
+
nuxtCompatibilityVersion,
|
|
1272
|
+
monorepoKind,
|
|
1273
|
+
nuxtConfigPath,
|
|
1274
|
+
hasAppDir,
|
|
1275
|
+
appDirPath: hasAppDir ? appDirPath : null,
|
|
1276
|
+
hasServerDir,
|
|
1277
|
+
hasPagesDir,
|
|
1278
|
+
hasWranglerConfig,
|
|
1279
|
+
capabilities
|
|
1280
|
+
};
|
|
1281
|
+
}
|
|
1282
|
+
//#endregion
|
|
11
1283
|
//#region src/file-scan.ts
|
|
12
1284
|
async function listSourceFiles(opts) {
|
|
13
1285
|
return (await glob(opts.include, {
|
|
@@ -39,7 +1311,7 @@ function mergeDiagnostics(...batches) {
|
|
|
39
1311
|
}
|
|
40
1312
|
//#endregion
|
|
41
1313
|
//#region src/oxlint/diagnostic.ts
|
|
42
|
-
const OXLINT_CODE_PATTERN = /^([a-z0-9_-]+)\(([a-z0-
|
|
1314
|
+
const OXLINT_CODE_PATTERN = /^([a-z0-9_-]+)\(([a-z0-9/_-]+)\)$/i;
|
|
43
1315
|
function normalizeOxlintRuleId(raw) {
|
|
44
1316
|
if (raw.code) {
|
|
45
1317
|
const match = OXLINT_CODE_PATTERN.exec(raw.code);
|
|
@@ -51,7 +1323,7 @@ function normalizeOxlintRuleId(raw) {
|
|
|
51
1323
|
}
|
|
52
1324
|
function toCanonicalDiagnostic(raw, rootDir) {
|
|
53
1325
|
const ruleId = normalizeOxlintRuleId(raw);
|
|
54
|
-
const severity = raw.severity === "warning" ? "
|
|
1326
|
+
const severity = raw.severity === "warning" ? "warn" : "error";
|
|
55
1327
|
const primary = raw.labels?.[0]?.span;
|
|
56
1328
|
const line = primary?.line ?? raw.start_line ?? 1;
|
|
57
1329
|
const column = primary?.column ?? raw.start_column ?? 1;
|
|
@@ -72,31 +1344,110 @@ function toCanonicalDiagnostics(raws, rootDir) {
|
|
|
72
1344
|
}
|
|
73
1345
|
//#endregion
|
|
74
1346
|
//#region src/oxlint/generate-config.ts
|
|
75
|
-
const
|
|
1347
|
+
const VUE_DEFAULT_RULES = {
|
|
76
1348
|
"vue/no-export-in-script-setup": "error",
|
|
77
|
-
"vue/require-typed-ref": "
|
|
78
|
-
"vue-doctor/no-em-dash-in-string": "
|
|
1349
|
+
"vue/require-typed-ref": "warn",
|
|
1350
|
+
"vue-doctor/no-em-dash-in-string": "warn",
|
|
1351
|
+
"vue-doctor/no-non-null-assertion-on-ref-value": "warn",
|
|
1352
|
+
"vue-doctor/no-imports-from-vue-when-auto-imported": "warn",
|
|
1353
|
+
"vue-doctor/reactivity/watch-without-cleanup": "warn",
|
|
1354
|
+
"vue-doctor/composition/prefer-script-setup-for-new-files": "warn",
|
|
1355
|
+
"vue-doctor/composition/defineProps-typed": "warn"
|
|
1356
|
+
};
|
|
1357
|
+
const NUXT_PLUGIN_RULES = {
|
|
1358
|
+
"nuxt-doctor/ai-slop/no-process-client-server": "error",
|
|
1359
|
+
"nuxt-doctor/ai-slop/no-explicit-imports-of-auto-imported": "warn",
|
|
1360
|
+
"nuxt-doctor/ai-slop/no-useState-for-server-data": "warn",
|
|
1361
|
+
"nuxt-doctor/ai-slop/no-fetch-in-setup": "warn",
|
|
1362
|
+
"nuxt-doctor/data-fetching/useAsyncData-key-required-in-loop": "error",
|
|
1363
|
+
"nuxt-doctor/server-routes/defineEventHandler-typed": "warn",
|
|
1364
|
+
"nuxt-doctor/server-routes/validate-body-with-h3-v2": "warn",
|
|
1365
|
+
"nuxt-doctor/server-routes/createError-on-failure": "warn",
|
|
1366
|
+
"nuxt-doctor/hydration/no-document-in-setup": "error",
|
|
1367
|
+
"nuxt-doctor/hydration/clientOnly-for-browser-apis": "error"
|
|
79
1368
|
};
|
|
1369
|
+
const VUE_OXLINT_RULE_IDS = new Set([
|
|
1370
|
+
"vue/no-export-in-script-setup",
|
|
1371
|
+
"vue/require-typed-ref",
|
|
1372
|
+
"vue-doctor/no-em-dash-in-string",
|
|
1373
|
+
"vue-doctor/no-destructure-props-without-to-refs",
|
|
1374
|
+
"vue-doctor/no-destructure-reactive-without-to-refs",
|
|
1375
|
+
"vue-doctor/no-non-null-assertion-on-ref-value",
|
|
1376
|
+
"vue-doctor/no-imports-from-vue-when-auto-imported",
|
|
1377
|
+
"vue-doctor/reactivity/watch-without-cleanup",
|
|
1378
|
+
"vue-doctor/reactivity/prefer-shallowRef-for-large-data",
|
|
1379
|
+
"vue-doctor/reactivity/prefer-readonly-for-injected",
|
|
1380
|
+
"vue-doctor/composition/prefer-script-setup-for-new-files",
|
|
1381
|
+
"vue-doctor/composition/defineProps-typed",
|
|
1382
|
+
"vue-doctor/performance/prefer-defineAsyncComponent-on-route"
|
|
1383
|
+
]);
|
|
1384
|
+
const NUXT_OXLINT_RULE_IDS = new Set(Object.keys(NUXT_PLUGIN_RULES));
|
|
1385
|
+
function oxlintRuleAllowlist(framework) {
|
|
1386
|
+
if (framework !== "nuxt") return VUE_OXLINT_RULE_IDS;
|
|
1387
|
+
return new Set([...VUE_OXLINT_RULE_IDS, ...NUXT_OXLINT_RULE_IDS]);
|
|
1388
|
+
}
|
|
80
1389
|
function toOxlintSeverity(s) {
|
|
81
|
-
if (s === "
|
|
82
|
-
return
|
|
1390
|
+
if (s === "error") return "error";
|
|
1391
|
+
return "warn";
|
|
1392
|
+
}
|
|
1393
|
+
async function resolveCacheDir(rootDir) {
|
|
1394
|
+
if (rootDir && existsSync(join(rootDir, "node_modules"))) {
|
|
1395
|
+
const dir = join(rootDir, "node_modules", ".cache", "doctor");
|
|
1396
|
+
await mkdir(dir, { recursive: true });
|
|
1397
|
+
return {
|
|
1398
|
+
dir,
|
|
1399
|
+
removeDir: false
|
|
1400
|
+
};
|
|
1401
|
+
}
|
|
1402
|
+
return {
|
|
1403
|
+
dir: await mkdtemp(join(tmpdir(), "geoql-doctor-")),
|
|
1404
|
+
removeDir: true
|
|
1405
|
+
};
|
|
1406
|
+
}
|
|
1407
|
+
function resolveUserConfig(rootDir) {
|
|
1408
|
+
if (!rootDir) return void 0;
|
|
1409
|
+
for (const name of [".oxlintrc.json", ".oxlintrc"]) {
|
|
1410
|
+
const candidate = join(rootDir, name);
|
|
1411
|
+
if (existsSync(candidate)) return candidate;
|
|
1412
|
+
}
|
|
83
1413
|
}
|
|
84
1414
|
async function generateOxlintConfig(input) {
|
|
85
|
-
const dir = await
|
|
86
|
-
const
|
|
87
|
-
|
|
88
|
-
|
|
1415
|
+
const { dir, removeDir } = await resolveCacheDir(input.rootDir);
|
|
1416
|
+
const framework = input.framework === "nuxt" ? "nuxt" : "vue";
|
|
1417
|
+
const allowlist = oxlintRuleAllowlist(framework);
|
|
1418
|
+
let defaults = { ...VUE_DEFAULT_RULES };
|
|
1419
|
+
if (framework === "nuxt") defaults = {
|
|
1420
|
+
...defaults,
|
|
1421
|
+
...NUXT_PLUGIN_RULES
|
|
1422
|
+
};
|
|
1423
|
+
const merged = { ...defaults };
|
|
1424
|
+
if (input.ruleOverrides) for (const [id, sev] of Object.entries(input.ruleOverrides)) {
|
|
1425
|
+
if (!allowlist.has(id)) continue;
|
|
1426
|
+
if (sev === "off") delete merged[id];
|
|
1427
|
+
else merged[id] = sev;
|
|
1428
|
+
}
|
|
89
1429
|
const rules = {};
|
|
90
1430
|
for (const [id, sev] of Object.entries(merged)) rules[id] = toOxlintSeverity(sev);
|
|
1431
|
+
const userConfig = resolveUserConfig(input.rootDir);
|
|
91
1432
|
const config = {
|
|
92
1433
|
$schema: "https://raw.githubusercontent.com/oxc-project/oxc/main/npm/oxlint/configuration_schema.json",
|
|
1434
|
+
...userConfig ? { extends: [userConfig] } : {},
|
|
93
1435
|
plugins: ["vue"],
|
|
94
|
-
jsPlugins:
|
|
1436
|
+
jsPlugins: input.pluginPaths,
|
|
95
1437
|
rules
|
|
96
1438
|
};
|
|
97
1439
|
const configPath = join(dir, ".oxlintrc.json");
|
|
98
1440
|
await writeFile(configPath, JSON.stringify(config, null, 2));
|
|
99
|
-
|
|
1441
|
+
const cleanup = async () => {
|
|
1442
|
+
await rm(removeDir ? dir : configPath, {
|
|
1443
|
+
recursive: true,
|
|
1444
|
+
force: true
|
|
1445
|
+
});
|
|
1446
|
+
};
|
|
1447
|
+
return {
|
|
1448
|
+
configPath,
|
|
1449
|
+
cleanup
|
|
1450
|
+
};
|
|
100
1451
|
}
|
|
101
1452
|
//#endregion
|
|
102
1453
|
//#region src/oxlint/resolve-plugin.ts
|
|
@@ -124,31 +1475,68 @@ function lookUpwards(fromDir, relPath) {
|
|
|
124
1475
|
current = parent;
|
|
125
1476
|
}
|
|
126
1477
|
}
|
|
1478
|
+
function resolveFromSelf(specifier) {
|
|
1479
|
+
try {
|
|
1480
|
+
return fileURLToPath(import.meta.resolve(specifier));
|
|
1481
|
+
} catch {
|
|
1482
|
+
return;
|
|
1483
|
+
}
|
|
1484
|
+
}
|
|
127
1485
|
function resolveVueDoctorPluginPath(fromDir) {
|
|
128
1486
|
const pkgJson = lookUpwards(fromDir, "node_modules/@geoql/oxlint-plugin-vue-doctor/package.json");
|
|
129
1487
|
if (pkgJson) {
|
|
130
1488
|
const main = readPkgMain(pkgJson) ?? "./dist/index.js";
|
|
131
1489
|
return resolve(dirname(pkgJson), main);
|
|
132
1490
|
}
|
|
133
|
-
|
|
134
|
-
|
|
135
|
-
|
|
136
|
-
|
|
137
|
-
|
|
1491
|
+
const selfMain = resolveFromSelf("@geoql/oxlint-plugin-vue-doctor");
|
|
1492
|
+
if (selfMain) return selfMain;
|
|
1493
|
+
return throwVueResolveError(fromDir);
|
|
1494
|
+
}
|
|
1495
|
+
function resolveNuxtDoctorPluginPath(fromDir) {
|
|
1496
|
+
const pkgJson = lookUpwards(fromDir, "node_modules/@geoql/oxlint-plugin-nuxt-doctor/package.json");
|
|
1497
|
+
if (pkgJson) {
|
|
1498
|
+
const main = readPkgMain(pkgJson) ?? "./dist/index.js";
|
|
1499
|
+
return resolve(dirname(pkgJson), main);
|
|
138
1500
|
}
|
|
139
|
-
|
|
1501
|
+
const selfMain = resolveFromSelf("@geoql/oxlint-plugin-nuxt-doctor");
|
|
1502
|
+
if (selfMain) return selfMain;
|
|
1503
|
+
return throwNuxtResolveError(fromDir);
|
|
140
1504
|
}
|
|
141
|
-
function
|
|
1505
|
+
function throwVueResolveError(fromDir) {
|
|
142
1506
|
throw new Error(`Failed to resolve @geoql/oxlint-plugin-vue-doctor from ${fromDir}. Install it as a dependency of your project or use the bundled @geoql/vue-doctor CLI.`);
|
|
143
1507
|
}
|
|
1508
|
+
function throwNuxtResolveError(fromDir) {
|
|
1509
|
+
throw new Error(`Failed to resolve @geoql/oxlint-plugin-nuxt-doctor from ${fromDir}. Install it as a dependency of your project or use the bundled @geoql/nuxt-doctor CLI.`);
|
|
1510
|
+
}
|
|
144
1511
|
function resolveOxlintBin(fromDir) {
|
|
145
1512
|
const pkgJson = lookUpwards(fromDir, "node_modules/oxlint/package.json");
|
|
146
1513
|
if (pkgJson) return resolve(dirname(pkgJson), "bin/oxlint");
|
|
1514
|
+
const selfPkgJson = resolveFromSelf("oxlint/package.json");
|
|
1515
|
+
if (selfPkgJson) return resolve(dirname(selfPkgJson), "bin/oxlint");
|
|
147
1516
|
throw new Error(`Failed to resolve oxlint from ${fromDir}. Install oxlint as a dependency of your project.`);
|
|
148
1517
|
}
|
|
149
1518
|
//#endregion
|
|
150
|
-
//#region src/oxlint/
|
|
151
|
-
const
|
|
1519
|
+
//#region src/oxlint/errors.ts
|
|
1520
|
+
const STDERR_TAIL_LIMIT = 2e3;
|
|
1521
|
+
var OxlintSpawnFailed = class extends Error {
|
|
1522
|
+
name = "OxlintSpawnFailed";
|
|
1523
|
+
constructor(exitCode, stderr) {
|
|
1524
|
+
const tail = stderr.slice(-STDERR_TAIL_LIMIT);
|
|
1525
|
+
super(`oxlint subprocess exited with code ${exitCode}: ${tail}`);
|
|
1526
|
+
}
|
|
1527
|
+
};
|
|
1528
|
+
var OxlintOutputTooLarge = class extends Error {
|
|
1529
|
+
name = "OxlintOutputTooLarge";
|
|
1530
|
+
constructor(maxBytes) {
|
|
1531
|
+
super(`oxlint subprocess output exceeded ${maxBytes} bytes`);
|
|
1532
|
+
}
|
|
1533
|
+
};
|
|
1534
|
+
function sanitizedEnv() {
|
|
1535
|
+
const env = { ...process.env };
|
|
1536
|
+
delete env.NODE_OPTIONS;
|
|
1537
|
+
for (const key of Object.keys(env)) if (key.startsWith("npm_config_")) delete env[key];
|
|
1538
|
+
return env;
|
|
1539
|
+
}
|
|
152
1540
|
async function runOxlint(opts) {
|
|
153
1541
|
return new Promise((resolve, reject) => {
|
|
154
1542
|
const args = [
|
|
@@ -164,95 +1552,693 @@ async function runOxlint(opts) {
|
|
|
164
1552
|
"ignore",
|
|
165
1553
|
"pipe",
|
|
166
1554
|
"pipe"
|
|
167
|
-
]
|
|
1555
|
+
],
|
|
1556
|
+
env: sanitizedEnv()
|
|
168
1557
|
});
|
|
169
1558
|
const stdoutChunks = [];
|
|
170
1559
|
const stderrChunks = [];
|
|
1560
|
+
let stdoutBytes = 0;
|
|
1561
|
+
let settled = false;
|
|
171
1562
|
let timer;
|
|
172
|
-
const timeoutMs = opts.timeoutMs ??
|
|
1563
|
+
const timeoutMs = opts.timeoutMs ?? 6e4;
|
|
1564
|
+
const maxOutputBytes = opts.maxOutputBytes ?? 33554432;
|
|
1565
|
+
const finish = (fn) => {
|
|
1566
|
+
if (settled) return;
|
|
1567
|
+
settled = true;
|
|
1568
|
+
if (timer) clearTimeout(timer);
|
|
1569
|
+
fn();
|
|
1570
|
+
};
|
|
173
1571
|
if (timeoutMs > 0) timer = setTimeout(() => {
|
|
174
1572
|
child.kill("SIGKILL");
|
|
175
|
-
reject(/* @__PURE__ */ new Error(`oxlint subprocess timed out after ${timeoutMs}ms`));
|
|
1573
|
+
finish(() => reject(/* @__PURE__ */ new Error(`oxlint subprocess timed out after ${timeoutMs}ms`)));
|
|
176
1574
|
}, timeoutMs);
|
|
177
|
-
child.stdout.on("data", (chunk) =>
|
|
1575
|
+
child.stdout.on("data", (chunk) => {
|
|
1576
|
+
stdoutBytes += chunk.length;
|
|
1577
|
+
if (stdoutBytes > maxOutputBytes) {
|
|
1578
|
+
child.kill("SIGKILL");
|
|
1579
|
+
finish(() => reject(new OxlintOutputTooLarge(maxOutputBytes)));
|
|
1580
|
+
return;
|
|
1581
|
+
}
|
|
1582
|
+
stdoutChunks.push(chunk);
|
|
1583
|
+
});
|
|
178
1584
|
child.stderr.on("data", (chunk) => stderrChunks.push(chunk));
|
|
179
1585
|
child.on("error", (err) => {
|
|
180
|
-
|
|
181
|
-
reject(err);
|
|
1586
|
+
finish(() => reject(err));
|
|
182
1587
|
});
|
|
183
1588
|
child.on("close", (exitCode) => {
|
|
184
|
-
|
|
185
|
-
|
|
186
|
-
|
|
187
|
-
|
|
188
|
-
diagnostics
|
|
189
|
-
|
|
190
|
-
|
|
1589
|
+
finish(() => {
|
|
1590
|
+
const stdout = Buffer.concat(stdoutChunks).toString("utf8");
|
|
1591
|
+
const stderr = Buffer.concat(stderrChunks).toString("utf8");
|
|
1592
|
+
const diagnostics = parseOxlintJsonStream(stdout);
|
|
1593
|
+
if (exitCode !== 0 && exitCode !== null && diagnostics.length === 0) {
|
|
1594
|
+
reject(new OxlintSpawnFailed(exitCode, stderr));
|
|
1595
|
+
return;
|
|
1596
|
+
}
|
|
1597
|
+
resolve({
|
|
1598
|
+
diagnostics,
|
|
1599
|
+
stderr,
|
|
1600
|
+
exitCode
|
|
1601
|
+
});
|
|
1602
|
+
});
|
|
1603
|
+
});
|
|
1604
|
+
});
|
|
1605
|
+
}
|
|
1606
|
+
function parseOxlintJsonStream(stdout) {
|
|
1607
|
+
const trimmed = stdout.trim();
|
|
1608
|
+
if (!trimmed) return [];
|
|
1609
|
+
try {
|
|
1610
|
+
const parsed = JSON.parse(trimmed);
|
|
1611
|
+
if (Array.isArray(parsed)) return parsed;
|
|
1612
|
+
if (parsed.diagnostics) return parsed.diagnostics;
|
|
1613
|
+
} catch {
|
|
1614
|
+
return parseNdjson(trimmed);
|
|
1615
|
+
}
|
|
1616
|
+
return [];
|
|
1617
|
+
}
|
|
1618
|
+
function parseNdjson(text) {
|
|
1619
|
+
const out = [];
|
|
1620
|
+
for (const line of text.split("\n")) {
|
|
1621
|
+
const t = line.trim();
|
|
1622
|
+
if (!t) continue;
|
|
1623
|
+
try {
|
|
1624
|
+
const obj = JSON.parse(t);
|
|
1625
|
+
if (obj && typeof obj === "object" && "message" in obj) out.push(obj);
|
|
1626
|
+
} catch {}
|
|
1627
|
+
}
|
|
1628
|
+
return out;
|
|
1629
|
+
}
|
|
1630
|
+
//#endregion
|
|
1631
|
+
//#region src/oxlint/run.ts
|
|
1632
|
+
async function runScriptPass(opts) {
|
|
1633
|
+
const vuePluginPath = resolveVueDoctorPluginPath(opts.rootDir);
|
|
1634
|
+
const oxlintBin = resolveOxlintBin(opts.rootDir);
|
|
1635
|
+
let pluginPaths;
|
|
1636
|
+
if (opts.framework === "nuxt") pluginPaths = [resolveNuxtDoctorPluginPath(opts.rootDir), vuePluginPath];
|
|
1637
|
+
else pluginPaths = [vuePluginPath];
|
|
1638
|
+
const { configPath, cleanup } = await generateOxlintConfig({
|
|
1639
|
+
pluginPaths,
|
|
1640
|
+
ruleOverrides: opts.ruleOverrides,
|
|
1641
|
+
rootDir: opts.rootDir,
|
|
1642
|
+
framework: opts.framework
|
|
1643
|
+
});
|
|
1644
|
+
try {
|
|
1645
|
+
const raw = await runOxlint({
|
|
1646
|
+
rootDir: opts.rootDir,
|
|
1647
|
+
targetPath: opts.targetPath,
|
|
1648
|
+
configPath,
|
|
1649
|
+
oxlintBin,
|
|
1650
|
+
timeoutMs: opts.timeoutMs
|
|
1651
|
+
});
|
|
1652
|
+
return {
|
|
1653
|
+
diagnostics: toCanonicalDiagnostics(raw.diagnostics, opts.rootDir),
|
|
1654
|
+
stderr: raw.stderr,
|
|
1655
|
+
exitCode: raw.exitCode
|
|
1656
|
+
};
|
|
1657
|
+
} finally {
|
|
1658
|
+
await cleanup();
|
|
1659
|
+
}
|
|
1660
|
+
}
|
|
1661
|
+
//#endregion
|
|
1662
|
+
//#region src/score.ts
|
|
1663
|
+
const SEVERITY_WEIGHTS = {
|
|
1664
|
+
error: 5,
|
|
1665
|
+
warn: 2,
|
|
1666
|
+
info: .5
|
|
1667
|
+
};
|
|
1668
|
+
function scoreDiagnostics(diagnostics, config) {
|
|
1669
|
+
const threshold = config?.threshold ?? 0;
|
|
1670
|
+
let errorCount = 0;
|
|
1671
|
+
let warnCount = 0;
|
|
1672
|
+
let infoCount = 0;
|
|
1673
|
+
const byRule = /* @__PURE__ */ new Map();
|
|
1674
|
+
for (const d of diagnostics) {
|
|
1675
|
+
if (d.severity === "error") errorCount += 1;
|
|
1676
|
+
else if (d.severity === "warn") warnCount += 1;
|
|
1677
|
+
else infoCount += 1;
|
|
1678
|
+
const list = byRule.get(d.ruleId);
|
|
1679
|
+
if (list) list.push(d);
|
|
1680
|
+
else byRule.set(d.ruleId, [d]);
|
|
1681
|
+
}
|
|
1682
|
+
const sortedRuleIds = [...byRule.keys()].sort();
|
|
1683
|
+
const breakdown = [];
|
|
1684
|
+
let penalty = 0;
|
|
1685
|
+
for (const ruleId of sortedRuleIds) {
|
|
1686
|
+
const list = byRule.get(ruleId);
|
|
1687
|
+
const weight = config?.rules?.[ruleId]?.weight ?? SEVERITY_WEIGHTS[list[0].severity];
|
|
1688
|
+
let rulePenalty = 0;
|
|
1689
|
+
for (let i = 0; i < list.length; i++) rulePenalty += weight * (i === 0 ? 1 : 1 / Math.sqrt(i + 1));
|
|
1690
|
+
penalty += rulePenalty;
|
|
1691
|
+
breakdown.push({
|
|
1692
|
+
ruleId,
|
|
1693
|
+
occurrences: list.length,
|
|
1694
|
+
weightPerOccurrence: weight,
|
|
1695
|
+
penalty: rulePenalty
|
|
1696
|
+
});
|
|
1697
|
+
}
|
|
1698
|
+
breakdown.sort((a, b) => b.penalty - a.penalty);
|
|
1699
|
+
const score = Math.max(0, Math.round(100 - penalty));
|
|
1700
|
+
return {
|
|
1701
|
+
score,
|
|
1702
|
+
passed: score >= threshold,
|
|
1703
|
+
threshold,
|
|
1704
|
+
totalFindings: diagnostics.length,
|
|
1705
|
+
errorCount,
|
|
1706
|
+
warnCount,
|
|
1707
|
+
infoCount,
|
|
1708
|
+
breakdown
|
|
1709
|
+
};
|
|
1710
|
+
}
|
|
1711
|
+
//#endregion
|
|
1712
|
+
//#region src/sfc/parse-sfc-descriptor.ts
|
|
1713
|
+
const cache$1 = /* @__PURE__ */ new Map();
|
|
1714
|
+
async function parseSfcDescriptor(absPath) {
|
|
1715
|
+
if (cache$1.has(absPath)) return cache$1.get(absPath) ?? null;
|
|
1716
|
+
let source;
|
|
1717
|
+
try {
|
|
1718
|
+
source = await readFile(absPath, "utf8");
|
|
1719
|
+
} catch {
|
|
1720
|
+
cache$1.set(absPath, null);
|
|
1721
|
+
return null;
|
|
1722
|
+
}
|
|
1723
|
+
const { descriptor, errors } = parse(source, { filename: absPath });
|
|
1724
|
+
if (errors.length > 0) {
|
|
1725
|
+
cache$1.set(absPath, null);
|
|
1726
|
+
return null;
|
|
1727
|
+
}
|
|
1728
|
+
cache$1.set(absPath, descriptor);
|
|
1729
|
+
return descriptor;
|
|
1730
|
+
}
|
|
1731
|
+
//#endregion
|
|
1732
|
+
//#region src/sfc/rules/no-mixed-options-and-composition-api.ts
|
|
1733
|
+
const RULE_ID$3 = "vue-doctor/sfc/no-mixed-options-and-composition-api";
|
|
1734
|
+
const MESSAGE$3 = "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";
|
|
1735
|
+
const RECOMMENDATION$3 = "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.";
|
|
1736
|
+
const DISALLOWED = new Set([
|
|
1737
|
+
"data",
|
|
1738
|
+
"methods",
|
|
1739
|
+
"computed",
|
|
1740
|
+
"watch",
|
|
1741
|
+
"props",
|
|
1742
|
+
"emits",
|
|
1743
|
+
"provide",
|
|
1744
|
+
"inject",
|
|
1745
|
+
"beforeCreate",
|
|
1746
|
+
"created",
|
|
1747
|
+
"beforeMount",
|
|
1748
|
+
"mounted",
|
|
1749
|
+
"beforeUpdate",
|
|
1750
|
+
"updated",
|
|
1751
|
+
"beforeUnmount",
|
|
1752
|
+
"unmounted",
|
|
1753
|
+
"activated",
|
|
1754
|
+
"deactivated",
|
|
1755
|
+
"errorCaptured",
|
|
1756
|
+
"serverPrefetch"
|
|
1757
|
+
]);
|
|
1758
|
+
function resolveDefineComponentCall(call) {
|
|
1759
|
+
if (call.callee.type !== "Identifier") return null;
|
|
1760
|
+
if (call.callee.name !== "defineComponent") return null;
|
|
1761
|
+
const first = call.arguments[0];
|
|
1762
|
+
if (!first || first.type !== "ObjectExpression") return null;
|
|
1763
|
+
return first;
|
|
1764
|
+
}
|
|
1765
|
+
function findBindingInit(body, name) {
|
|
1766
|
+
for (const stmt of body) {
|
|
1767
|
+
if (stmt.type !== "VariableDeclaration") continue;
|
|
1768
|
+
for (const declarator of stmt.declarations) {
|
|
1769
|
+
if (declarator.id.type !== "Identifier") continue;
|
|
1770
|
+
if (declarator.id.name !== name) continue;
|
|
1771
|
+
return declarator.init;
|
|
1772
|
+
}
|
|
1773
|
+
}
|
|
1774
|
+
return null;
|
|
1775
|
+
}
|
|
1776
|
+
function resolveOptionsObject(program) {
|
|
1777
|
+
const exported = program.body.find((node) => node.type === "ExportDefaultDeclaration");
|
|
1778
|
+
if (!exported) return null;
|
|
1779
|
+
const declaration = exported.declaration;
|
|
1780
|
+
if (declaration.type === "ObjectExpression") return declaration;
|
|
1781
|
+
if (declaration.type === "CallExpression") return resolveDefineComponentCall(declaration);
|
|
1782
|
+
if (declaration.type === "Identifier") {
|
|
1783
|
+
const init = findBindingInit(program.body, declaration.name);
|
|
1784
|
+
if (init && init.type === "CallExpression") return resolveDefineComponentCall(init);
|
|
1785
|
+
return null;
|
|
1786
|
+
}
|
|
1787
|
+
return null;
|
|
1788
|
+
}
|
|
1789
|
+
function locate(content, offset, startLine, startColumn) {
|
|
1790
|
+
let line = startLine;
|
|
1791
|
+
let lastNewline = -1;
|
|
1792
|
+
for (let i = 0; i < offset; i += 1) if (content.charCodeAt(i) === 10) {
|
|
1793
|
+
line += 1;
|
|
1794
|
+
lastNewline = i;
|
|
1795
|
+
}
|
|
1796
|
+
const column = lastNewline === -1 ? startColumn + offset : offset - lastNewline;
|
|
1797
|
+
return {
|
|
1798
|
+
line,
|
|
1799
|
+
column
|
|
1800
|
+
};
|
|
1801
|
+
}
|
|
1802
|
+
function check$9(ctx) {
|
|
1803
|
+
const { script, scriptSetup } = ctx.descriptor;
|
|
1804
|
+
if (!script || !scriptSetup) return { diagnostics: [] };
|
|
1805
|
+
const lang = script.lang === "ts" ? "ts" : "js";
|
|
1806
|
+
const { program } = parseSync(`script.${lang}`, script.content, {
|
|
1807
|
+
sourceType: "module",
|
|
1808
|
+
lang
|
|
1809
|
+
});
|
|
1810
|
+
const options = resolveOptionsObject(program);
|
|
1811
|
+
if (!options) return { diagnostics: [] };
|
|
1812
|
+
const diagnostics = [];
|
|
1813
|
+
for (const property of options.properties) {
|
|
1814
|
+
if (property.type !== "Property") continue;
|
|
1815
|
+
if (property.key.type !== "Identifier") continue;
|
|
1816
|
+
if (!DISALLOWED.has(property.key.name)) continue;
|
|
1817
|
+
const { line, column } = locate(script.content, property.start, script.loc.start.line, script.loc.start.column);
|
|
1818
|
+
diagnostics.push({
|
|
1819
|
+
file: ctx.file,
|
|
1820
|
+
line,
|
|
1821
|
+
column,
|
|
1822
|
+
ruleId: RULE_ID$3,
|
|
1823
|
+
severity: "error",
|
|
1824
|
+
message: MESSAGE$3,
|
|
1825
|
+
source: "sfc",
|
|
1826
|
+
recommendation: RECOMMENDATION$3
|
|
1827
|
+
});
|
|
1828
|
+
}
|
|
1829
|
+
return { diagnostics };
|
|
1830
|
+
}
|
|
1831
|
+
//#endregion
|
|
1832
|
+
//#region src/nuxt/file-role.ts
|
|
1833
|
+
const PAGE_DIR = /^(?:app\/)?pages\//;
|
|
1834
|
+
const LAYOUT_DIR = /^(?:app\/)?layouts\//;
|
|
1835
|
+
function normalize(relPath) {
|
|
1836
|
+
return relPath.replace(/\\/g, "/").replace(/^\.\//, "");
|
|
1837
|
+
}
|
|
1838
|
+
/**
|
|
1839
|
+
* True when the path points at a Nuxt page component — a `.vue` file under
|
|
1840
|
+
* `pages/` (Nuxt 3 layout) or `app/pages/` (Nuxt 4 layout) at the project root.
|
|
1841
|
+
*/
|
|
1842
|
+
function isNuxtPageFile(relPath) {
|
|
1843
|
+
const path = normalize(relPath);
|
|
1844
|
+
return PAGE_DIR.test(path) && path.endsWith(".vue");
|
|
1845
|
+
}
|
|
1846
|
+
/**
|
|
1847
|
+
* True when the path points at a Nitro server file — a `.ts` file under the
|
|
1848
|
+
* project-root `server/` directory.
|
|
1849
|
+
*/
|
|
1850
|
+
function isNuxtServerFile(relPath) {
|
|
1851
|
+
const path = normalize(relPath);
|
|
1852
|
+
return path.startsWith("server/") && path.endsWith(".ts");
|
|
1853
|
+
}
|
|
1854
|
+
/**
|
|
1855
|
+
* True when the path points at a Nuxt layout component — a `.vue` file under
|
|
1856
|
+
* `layouts/` (Nuxt 3 layout) or `app/layouts/` (Nuxt 4 layout) at the project
|
|
1857
|
+
* root.
|
|
1858
|
+
*/
|
|
1859
|
+
function isNuxtLayoutFile(relPath) {
|
|
1860
|
+
const path = normalize(relPath);
|
|
1861
|
+
return LAYOUT_DIR.test(path) && path.endsWith(".vue");
|
|
1862
|
+
}
|
|
1863
|
+
//#endregion
|
|
1864
|
+
//#region src/template/walk.ts
|
|
1865
|
+
const NODE_ELEMENT$2 = 1;
|
|
1866
|
+
function isElementNode(node) {
|
|
1867
|
+
return node !== null && node !== void 0 && node.type === NODE_ELEMENT$2;
|
|
1868
|
+
}
|
|
1869
|
+
function walkElements(root, visit) {
|
|
1870
|
+
const stack = [...root.children];
|
|
1871
|
+
while (stack.length > 0) {
|
|
1872
|
+
const node = stack.pop();
|
|
1873
|
+
if (!node) continue;
|
|
1874
|
+
if (isElementNode(node)) {
|
|
1875
|
+
visit(node);
|
|
1876
|
+
if (node.children) for (const child of node.children) stack.push(child);
|
|
1877
|
+
}
|
|
1878
|
+
}
|
|
1879
|
+
}
|
|
1880
|
+
//#endregion
|
|
1881
|
+
//#region src/sfc/rules/nuxt/no-mixed-app-and-root-layout.ts
|
|
1882
|
+
const RULE_ID$2 = "nuxt-doctor/ai-slop/no-mixed-app-and-root-layout";
|
|
1883
|
+
const MESSAGE$2 = "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.";
|
|
1884
|
+
const RECOMMENDATION$2 = "Replace <NuxtLayout> in this layout file with <slot />. The layout slot is already provided by the parent layout wrapper.";
|
|
1885
|
+
function check$8(ctx) {
|
|
1886
|
+
if (!isNuxtLayoutFile(ctx.relativePath)) return { diagnostics: [] };
|
|
1887
|
+
const { template } = ctx.descriptor;
|
|
1888
|
+
if (!template || !template.ast) return { diagnostics: [] };
|
|
1889
|
+
let foundNuxtLayout = false;
|
|
1890
|
+
let nuxtLayoutNode;
|
|
1891
|
+
walkElements(template.ast, (el) => {
|
|
1892
|
+
if (el.tag === "NuxtLayout") {
|
|
1893
|
+
foundNuxtLayout = true;
|
|
1894
|
+
nuxtLayoutNode = el;
|
|
1895
|
+
}
|
|
1896
|
+
});
|
|
1897
|
+
if (!foundNuxtLayout) return { diagnostics: [] };
|
|
1898
|
+
const node = nuxtLayoutNode;
|
|
1899
|
+
return { diagnostics: [{
|
|
1900
|
+
file: ctx.file,
|
|
1901
|
+
line: node.loc.start.line,
|
|
1902
|
+
column: node.loc.start.column,
|
|
1903
|
+
endLine: node.loc.end.line,
|
|
1904
|
+
endColumn: node.loc.end.column,
|
|
1905
|
+
ruleId: RULE_ID$2,
|
|
1906
|
+
severity: "warn",
|
|
1907
|
+
message: MESSAGE$2,
|
|
1908
|
+
source: "sfc",
|
|
1909
|
+
recommendation: RECOMMENDATION$2
|
|
1910
|
+
}] };
|
|
1911
|
+
}
|
|
1912
|
+
//#endregion
|
|
1913
|
+
//#region src/sfc/rules/nuxt/og-image-defined.ts
|
|
1914
|
+
const RULE_ID$1 = "nuxt-doctor/seo/og-image-defined";
|
|
1915
|
+
const MESSAGE$1 = "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.";
|
|
1916
|
+
const RECOMMENDATION$1 = "Add ogImage: \"/path/to/image.png\" to useSeoMeta / useHead, or install @nuxtjs/og-image.";
|
|
1917
|
+
function hasOgImageInCall(program) {
|
|
1918
|
+
for (const stmt of program.body) {
|
|
1919
|
+
if (stmt.type !== "ExpressionStatement") continue;
|
|
1920
|
+
const call = stmt.expression;
|
|
1921
|
+
if (call.type !== "CallExpression") continue;
|
|
1922
|
+
if (call.callee.type !== "Identifier") continue;
|
|
1923
|
+
const name = call.callee.name;
|
|
1924
|
+
if (name !== "useSeoMeta" && name !== "useHead") continue;
|
|
1925
|
+
const firstArg = call.arguments[0];
|
|
1926
|
+
if (!firstArg || firstArg.type !== "ObjectExpression") continue;
|
|
1927
|
+
for (const prop of firstArg.properties) {
|
|
1928
|
+
if (prop.type !== "Property") continue;
|
|
1929
|
+
if (prop.key.type === "Identifier" && prop.key.name === "ogImage") return true;
|
|
1930
|
+
if (prop.key.type === "Literal" && prop.key.value === "og:image") return true;
|
|
1931
|
+
}
|
|
1932
|
+
}
|
|
1933
|
+
return false;
|
|
1934
|
+
}
|
|
1935
|
+
function hasOgImageDep(packageJsonPath) {
|
|
1936
|
+
try {
|
|
1937
|
+
const raw = readFileSync(packageJsonPath, "utf8");
|
|
1938
|
+
const pkg = JSON.parse(raw);
|
|
1939
|
+
const deps = {
|
|
1940
|
+
...pkg.dependencies,
|
|
1941
|
+
...pkg.devDependencies
|
|
1942
|
+
};
|
|
1943
|
+
return "@nuxtjs/og-image" in deps || "nuxt-og-image" in deps;
|
|
1944
|
+
} catch {
|
|
1945
|
+
return false;
|
|
1946
|
+
}
|
|
1947
|
+
}
|
|
1948
|
+
function check$7(ctx) {
|
|
1949
|
+
if (!isNuxtPageFile(ctx.relativePath)) return { diagnostics: [] };
|
|
1950
|
+
const { scriptSetup } = ctx.descriptor;
|
|
1951
|
+
if (!scriptSetup) return { diagnostics: [] };
|
|
1952
|
+
const lang = scriptSetup.lang === "ts" ? "ts" : "js";
|
|
1953
|
+
const { program } = parseSync(`script.${lang}`, scriptSetup.content, {
|
|
1954
|
+
sourceType: "module",
|
|
1955
|
+
lang
|
|
1956
|
+
});
|
|
1957
|
+
if (hasOgImageInCall(program)) return { diagnostics: [] };
|
|
1958
|
+
if (ctx.projectInfo.packageJsonPath === null) return { diagnostics: [] };
|
|
1959
|
+
if (hasOgImageDep(ctx.projectInfo.packageJsonPath)) return { diagnostics: [] };
|
|
1960
|
+
const { line, column } = scriptSetup.loc.start;
|
|
1961
|
+
return { diagnostics: [{
|
|
1962
|
+
file: ctx.file,
|
|
1963
|
+
line,
|
|
1964
|
+
column,
|
|
1965
|
+
ruleId: RULE_ID$1,
|
|
1966
|
+
severity: "warn",
|
|
1967
|
+
message: MESSAGE$1,
|
|
1968
|
+
source: "sfc",
|
|
1969
|
+
recommendation: RECOMMENDATION$1
|
|
1970
|
+
}] };
|
|
1971
|
+
}
|
|
1972
|
+
//#endregion
|
|
1973
|
+
//#region src/sfc/rules/nuxt/use-seo-meta-on-public-page.ts
|
|
1974
|
+
const RULE_ID = "nuxt-doctor/seo/useSeoMeta-on-public-page";
|
|
1975
|
+
const MESSAGE = "Public page component is missing SEO metadata. Add useSeoMeta, useHead, or definePageMeta with a title so search engines can index it properly.";
|
|
1976
|
+
const RECOMMENDATION = "Call useSeoMeta({ title: \"...\" }) in<script setup> to define page title and meta tags for search engines and social previews.";
|
|
1977
|
+
function hasTitleInCall(program) {
|
|
1978
|
+
for (const stmt of program.body) {
|
|
1979
|
+
if (stmt.type !== "ExpressionStatement") continue;
|
|
1980
|
+
const call = stmt.expression;
|
|
1981
|
+
if (call.type !== "CallExpression") continue;
|
|
1982
|
+
if (call.callee.type !== "Identifier") continue;
|
|
1983
|
+
const name = call.callee.name;
|
|
1984
|
+
if (name !== "useSeoMeta" && name !== "useHead" && name !== "definePageMeta") continue;
|
|
1985
|
+
const firstArg = call.arguments[0];
|
|
1986
|
+
if (!firstArg || firstArg.type !== "ObjectExpression") continue;
|
|
1987
|
+
for (const prop of firstArg.properties) {
|
|
1988
|
+
if (prop.type !== "Property") continue;
|
|
1989
|
+
if (prop.key.type === "Identifier" && prop.key.name === "title") return true;
|
|
1990
|
+
if (prop.key.type === "Literal" && prop.key.value === "title") return true;
|
|
1991
|
+
}
|
|
1992
|
+
}
|
|
1993
|
+
return false;
|
|
1994
|
+
}
|
|
1995
|
+
function check$6(ctx) {
|
|
1996
|
+
if (!isNuxtPageFile(ctx.relativePath)) return { diagnostics: [] };
|
|
1997
|
+
const { scriptSetup } = ctx.descriptor;
|
|
1998
|
+
if (!scriptSetup) return { diagnostics: [] };
|
|
1999
|
+
const lang = scriptSetup.lang === "ts" ? "ts" : "js";
|
|
2000
|
+
const { program } = parseSync(`script.${lang}`, scriptSetup.content, {
|
|
2001
|
+
sourceType: "module",
|
|
2002
|
+
lang
|
|
2003
|
+
});
|
|
2004
|
+
if (hasTitleInCall(program)) return { diagnostics: [] };
|
|
2005
|
+
const { line, column } = scriptSetup.loc.start;
|
|
2006
|
+
return { diagnostics: [{
|
|
2007
|
+
file: ctx.file,
|
|
2008
|
+
line,
|
|
2009
|
+
column,
|
|
2010
|
+
ruleId: RULE_ID,
|
|
2011
|
+
severity: "warn",
|
|
2012
|
+
message: MESSAGE,
|
|
2013
|
+
source: "sfc",
|
|
2014
|
+
recommendation: RECOMMENDATION
|
|
2015
|
+
}] };
|
|
2016
|
+
}
|
|
2017
|
+
//#endregion
|
|
2018
|
+
//#region src/sfc/rules/index.ts
|
|
2019
|
+
const SFC_RULES = [
|
|
2020
|
+
{
|
|
2021
|
+
id: "vue-doctor/sfc/no-mixed-options-and-composition-api",
|
|
2022
|
+
check: check$9
|
|
2023
|
+
},
|
|
2024
|
+
{
|
|
2025
|
+
id: "nuxt-doctor/seo/useSeoMeta-on-public-page",
|
|
2026
|
+
check: check$6
|
|
2027
|
+
},
|
|
2028
|
+
{
|
|
2029
|
+
id: "nuxt-doctor/seo/og-image-defined",
|
|
2030
|
+
check: check$7
|
|
2031
|
+
},
|
|
2032
|
+
{
|
|
2033
|
+
id: "nuxt-doctor/ai-slop/no-mixed-app-and-root-layout",
|
|
2034
|
+
check: check$8
|
|
2035
|
+
}
|
|
2036
|
+
];
|
|
2037
|
+
//#endregion
|
|
2038
|
+
//#region src/sfc/run.ts
|
|
2039
|
+
async function runSfcPass(opts) {
|
|
2040
|
+
const all = [];
|
|
2041
|
+
const { projectInfo } = opts;
|
|
2042
|
+
const rootDir = projectInfo?.rootDirectory ?? process.cwd();
|
|
2043
|
+
for (const file of opts.files) {
|
|
2044
|
+
if (!file.endsWith(".vue")) continue;
|
|
2045
|
+
const descriptor = await parseSfcDescriptor(file);
|
|
2046
|
+
if (!descriptor) continue;
|
|
2047
|
+
const relativePath = relative(rootDir, file).replace(/\\/g, "/");
|
|
2048
|
+
for (const rule of SFC_RULES) {
|
|
2049
|
+
const override = opts.ruleOverrides?.[rule.id];
|
|
2050
|
+
if (override === "off") continue;
|
|
2051
|
+
const { diagnostics } = rule.check({
|
|
2052
|
+
file,
|
|
2053
|
+
descriptor,
|
|
2054
|
+
rootDirectory: rootDir,
|
|
2055
|
+
relativePath,
|
|
2056
|
+
projectInfo
|
|
191
2057
|
});
|
|
192
|
-
|
|
193
|
-
|
|
2058
|
+
for (const d of diagnostics) all.push(override ? {
|
|
2059
|
+
...d,
|
|
2060
|
+
severity: override
|
|
2061
|
+
} : d);
|
|
2062
|
+
}
|
|
2063
|
+
}
|
|
2064
|
+
return all;
|
|
194
2065
|
}
|
|
195
|
-
|
|
196
|
-
|
|
197
|
-
|
|
2066
|
+
//#endregion
|
|
2067
|
+
//#region src/nuxt/cross-file/run.ts
|
|
2068
|
+
function extractDataFetchingKeys(content, lang) {
|
|
2069
|
+
const keys = [];
|
|
198
2070
|
try {
|
|
199
|
-
const
|
|
200
|
-
|
|
201
|
-
|
|
202
|
-
|
|
203
|
-
|
|
2071
|
+
const { program } = parseSync(`script.${lang}`, content, {
|
|
2072
|
+
sourceType: "module",
|
|
2073
|
+
lang
|
|
2074
|
+
});
|
|
2075
|
+
for (const stmt of program.body) if (stmt.type === "VariableDeclaration") for (const decl of stmt.declarations) {
|
|
2076
|
+
const call = unwrapAwait(decl.init);
|
|
2077
|
+
if (call?.type !== "CallExpression") continue;
|
|
2078
|
+
extractKeyFromCall(call, keys);
|
|
2079
|
+
}
|
|
2080
|
+
else if (stmt.type === "ExpressionStatement") {
|
|
2081
|
+
const expr = unwrapAwait(stmt.expression);
|
|
2082
|
+
if (expr?.type === "CallExpression") extractKeyFromCall(expr, keys);
|
|
2083
|
+
}
|
|
2084
|
+
} catch {}
|
|
2085
|
+
return keys;
|
|
2086
|
+
}
|
|
2087
|
+
function unwrapAwait(node) {
|
|
2088
|
+
if (node?.type === "AwaitExpression") return node.argument;
|
|
2089
|
+
return node;
|
|
2090
|
+
}
|
|
2091
|
+
function extractKeyFromCall(call, keys) {
|
|
2092
|
+
if (call.callee.type !== "Identifier") return;
|
|
2093
|
+
const fnName = call.callee.name;
|
|
2094
|
+
if (fnName !== "useAsyncData" && fnName !== "useFetch") return;
|
|
2095
|
+
const keyArg = call.arguments[0];
|
|
2096
|
+
if (keyArg?.type === "Literal" && typeof keyArg.value === "string") keys.push(keyArg.value);
|
|
2097
|
+
else if (keyArg?.type === "TemplateLiteral" && keyArg.quasis.length === 1) {
|
|
2098
|
+
const val = keyArg.quasis[0]?.value?.raw;
|
|
2099
|
+
if (val) keys.push(val);
|
|
2100
|
+
}
|
|
2101
|
+
if (fnName === "useFetch" && call.arguments.length >= 2) {
|
|
2102
|
+
const optsArg = call.arguments[1];
|
|
2103
|
+
if (optsArg?.type === "ObjectExpression") {
|
|
2104
|
+
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);
|
|
2105
|
+
}
|
|
204
2106
|
}
|
|
205
|
-
return [];
|
|
206
2107
|
}
|
|
207
|
-
function
|
|
208
|
-
const
|
|
209
|
-
|
|
210
|
-
|
|
211
|
-
|
|
212
|
-
|
|
213
|
-
|
|
214
|
-
|
|
215
|
-
|
|
2108
|
+
async function runCrossFilePass(opts) {
|
|
2109
|
+
const { files, projectInfo } = opts;
|
|
2110
|
+
if (projectInfo.framework !== "nuxt") return [];
|
|
2111
|
+
const rootDir = projectInfo.rootDirectory;
|
|
2112
|
+
const pageFiles = files.filter((f) => isNuxtPageFile(relative(rootDir, f).replace(/\\/g, "/")));
|
|
2113
|
+
if (pageFiles.length === 0) return [];
|
|
2114
|
+
const parsed = [];
|
|
2115
|
+
for (const file of pageFiles) {
|
|
2116
|
+
const descriptor = await parseSfcDescriptor(file);
|
|
2117
|
+
if (!descriptor) continue;
|
|
2118
|
+
const { scriptSetup } = descriptor;
|
|
2119
|
+
if (!scriptSetup) continue;
|
|
2120
|
+
parsed.push({
|
|
2121
|
+
file,
|
|
2122
|
+
relativePath: relative(rootDir, file).replace(/\\/g, "/"),
|
|
2123
|
+
keys: extractDataFetchingKeys(scriptSetup.content, scriptSetup.lang === "ts" ? "ts" : "js"),
|
|
2124
|
+
scriptSetupContent: scriptSetup.content,
|
|
2125
|
+
scriptSetupLang: scriptSetup.lang === "ts" ? "ts" : "js",
|
|
2126
|
+
scriptSetupLine: scriptSetup.loc.start.line
|
|
2127
|
+
});
|
|
216
2128
|
}
|
|
217
|
-
return
|
|
2129
|
+
return runCrossFileRules(parsed);
|
|
218
2130
|
}
|
|
219
|
-
|
|
220
|
-
|
|
221
|
-
|
|
222
|
-
|
|
223
|
-
|
|
224
|
-
|
|
225
|
-
|
|
226
|
-
|
|
227
|
-
|
|
228
|
-
|
|
229
|
-
|
|
230
|
-
|
|
231
|
-
|
|
232
|
-
|
|
233
|
-
|
|
2131
|
+
function runCrossFileRules(pages) {
|
|
2132
|
+
const all = [];
|
|
2133
|
+
all.push(...ruleNoSharedKeyAcrossPages(pages));
|
|
2134
|
+
all.push(...ruleSsrSafeOnMountedOnlyForClient(pages));
|
|
2135
|
+
return all;
|
|
2136
|
+
}
|
|
2137
|
+
function ruleNoSharedKeyAcrossPages(pages) {
|
|
2138
|
+
const keyToFiles = /* @__PURE__ */ new Map();
|
|
2139
|
+
for (const page of pages) for (const key of page.keys) {
|
|
2140
|
+
const existing = keyToFiles.get(key) ?? [];
|
|
2141
|
+
if (!existing.includes(page.file)) existing.push(page.file);
|
|
2142
|
+
keyToFiles.set(key, existing);
|
|
2143
|
+
}
|
|
2144
|
+
const diags = [];
|
|
2145
|
+
for (const [key, files] of keyToFiles) {
|
|
2146
|
+
if (files.length < 2) continue;
|
|
2147
|
+
for (const file of files) diags.push({
|
|
2148
|
+
file,
|
|
2149
|
+
line: 1,
|
|
2150
|
+
column: 1,
|
|
2151
|
+
ruleId: "nuxt-doctor/data-fetching/no-shared-key-across-pages",
|
|
2152
|
+
severity: "warn",
|
|
2153
|
+
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.`,
|
|
2154
|
+
source: "cross-file",
|
|
2155
|
+
recommendation: `Rename the key to something page-specific, e.g. "page-${key}" or "users-${key}".`
|
|
2156
|
+
});
|
|
2157
|
+
}
|
|
2158
|
+
return diags;
|
|
2159
|
+
}
|
|
2160
|
+
const BROWSER_GLOBALS = new Set([
|
|
2161
|
+
"window",
|
|
2162
|
+
"document",
|
|
2163
|
+
"navigator",
|
|
2164
|
+
"localStorage",
|
|
2165
|
+
"sessionStorage",
|
|
2166
|
+
"location",
|
|
2167
|
+
"history",
|
|
2168
|
+
"fetch",
|
|
2169
|
+
"XMLHttpRequest",
|
|
2170
|
+
"matchMedia",
|
|
2171
|
+
"IntersectionObserver",
|
|
2172
|
+
"MutationObserver",
|
|
2173
|
+
"indexedDB",
|
|
2174
|
+
"webkit",
|
|
2175
|
+
"moz",
|
|
2176
|
+
"onmessage"
|
|
2177
|
+
]);
|
|
2178
|
+
function ruleSsrSafeOnMountedOnlyForClient(pages) {
|
|
2179
|
+
const diags = [];
|
|
2180
|
+
for (const page of pages) {
|
|
2181
|
+
const { program } = parseSync(`script.${page.scriptSetupLang}`, page.scriptSetupContent, {
|
|
2182
|
+
sourceType: "module",
|
|
2183
|
+
lang: page.scriptSetupLang
|
|
2184
|
+
});
|
|
2185
|
+
for (const stmt of program.body) if (stmt.type === "VariableDeclaration") for (const decl of stmt.declarations) {
|
|
2186
|
+
const init = decl.init;
|
|
2187
|
+
if (!init) continue;
|
|
2188
|
+
if (init.type === "CallExpression") {
|
|
2189
|
+
const call = init;
|
|
2190
|
+
if (isSkippedReactiveCall(call)) continue;
|
|
2191
|
+
checkCallForBrowserGlobal(call, page, diags);
|
|
2192
|
+
} else if (init.type === "MemberExpression") checkMemberForBrowserGlobal(init, page, diags);
|
|
2193
|
+
}
|
|
2194
|
+
else if (stmt.type === "ExpressionStatement") {
|
|
2195
|
+
const expr = stmt.expression;
|
|
2196
|
+
if (expr.type === "CallExpression") {
|
|
2197
|
+
if (isSkippedReactiveCall(expr)) continue;
|
|
2198
|
+
checkCallForBrowserGlobal(expr, page, diags);
|
|
2199
|
+
} else if (expr.type === "MemberExpression") checkMemberForBrowserGlobal(expr, page, diags);
|
|
2200
|
+
}
|
|
2201
|
+
}
|
|
2202
|
+
return diags;
|
|
2203
|
+
}
|
|
2204
|
+
const CLIENT_SAFE_WRAPPERS = new Set([
|
|
2205
|
+
"onMounted",
|
|
2206
|
+
"watchEffect",
|
|
2207
|
+
"watch"
|
|
2208
|
+
]);
|
|
2209
|
+
function isSkippedReactiveCall(call) {
|
|
2210
|
+
if (call.callee.type !== "Identifier") return false;
|
|
2211
|
+
return CLIENT_SAFE_WRAPPERS.has(call.callee.name);
|
|
2212
|
+
}
|
|
2213
|
+
function checkCallForBrowserGlobal(call, page, diags) {
|
|
2214
|
+
const callee = call.callee;
|
|
2215
|
+
if (callee.type !== "MemberExpression") return;
|
|
2216
|
+
const obj = callee.object;
|
|
2217
|
+
if (obj.type !== "Identifier" || !BROWSER_GLOBALS.has(obj.name)) return;
|
|
2218
|
+
diags.push({
|
|
2219
|
+
file: page.file,
|
|
2220
|
+
line: page.scriptSetupLine,
|
|
2221
|
+
column: 1,
|
|
2222
|
+
ruleId: "nuxt-doctor/data-fetching/ssr-safe-onMounted-only-for-client",
|
|
2223
|
+
severity: "warn",
|
|
2224
|
+
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.`,
|
|
2225
|
+
source: "cross-file",
|
|
2226
|
+
recommendation: `Wrap "${obj.name}" access in onMounted(() => { /* code */ }) or use defineNuxtPlugin to run only on the client.`
|
|
234
2227
|
});
|
|
235
|
-
return {
|
|
236
|
-
diagnostics: toCanonicalDiagnostics(raw.diagnostics, opts.rootDir),
|
|
237
|
-
stderr: raw.stderr,
|
|
238
|
-
exitCode: raw.exitCode
|
|
239
|
-
};
|
|
240
2228
|
}
|
|
241
|
-
|
|
242
|
-
|
|
243
|
-
|
|
244
|
-
|
|
245
|
-
|
|
246
|
-
|
|
247
|
-
|
|
248
|
-
|
|
249
|
-
|
|
250
|
-
|
|
251
|
-
|
|
252
|
-
|
|
253
|
-
|
|
254
|
-
warningCount
|
|
255
|
-
};
|
|
2229
|
+
function checkMemberForBrowserGlobal(mem, page, diags) {
|
|
2230
|
+
const obj = mem.object;
|
|
2231
|
+
if (obj.type !== "Identifier" || !BROWSER_GLOBALS.has(obj.name)) return;
|
|
2232
|
+
diags.push({
|
|
2233
|
+
file: page.file,
|
|
2234
|
+
line: page.scriptSetupLine,
|
|
2235
|
+
column: 1,
|
|
2236
|
+
ruleId: "nuxt-doctor/data-fetching/ssr-safe-onMounted-only-for-client",
|
|
2237
|
+
severity: "warn",
|
|
2238
|
+
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.`,
|
|
2239
|
+
source: "cross-file",
|
|
2240
|
+
recommendation: `Wrap "${obj.name}" access in onMounted(() => { /* code */ }) or use defineNuxtPlugin to run only on the client.`
|
|
2241
|
+
});
|
|
256
2242
|
}
|
|
257
2243
|
//#endregion
|
|
258
2244
|
//#region src/template/parse-sfc.ts
|
|
@@ -276,13 +2262,13 @@ async function parseSfc(absPath) {
|
|
|
276
2262
|
}
|
|
277
2263
|
//#endregion
|
|
278
2264
|
//#region src/template/directive-helpers.ts
|
|
279
|
-
const NODE_DIRECTIVE = 7;
|
|
2265
|
+
const NODE_DIRECTIVE$3 = 7;
|
|
280
2266
|
function findDirective(el, name) {
|
|
281
|
-
for (const prop of el.props) if (prop.type === NODE_DIRECTIVE && prop.name === name) return prop;
|
|
2267
|
+
for (const prop of el.props) if (prop.type === NODE_DIRECTIVE$3 && prop.name === name) return prop;
|
|
282
2268
|
}
|
|
283
2269
|
function findBindAttr(el, attrName) {
|
|
284
2270
|
for (const prop of el.props) {
|
|
285
|
-
if (prop.type !== NODE_DIRECTIVE) continue;
|
|
2271
|
+
if (prop.type !== NODE_DIRECTIVE$3) continue;
|
|
286
2272
|
const dir = prop;
|
|
287
2273
|
if (dir.name !== "bind") continue;
|
|
288
2274
|
if (dir.arg?.content === attrName) return dir;
|
|
@@ -292,25 +2278,8 @@ function findStaticAttr(el, attrName) {
|
|
|
292
2278
|
for (const prop of el.props) if (prop.type === 6 && prop.name === attrName) return prop;
|
|
293
2279
|
}
|
|
294
2280
|
//#endregion
|
|
295
|
-
//#region src/template/walk.ts
|
|
296
|
-
const NODE_ELEMENT = 1;
|
|
297
|
-
function isElementNode(node) {
|
|
298
|
-
return node !== null && node !== void 0 && node.type === NODE_ELEMENT;
|
|
299
|
-
}
|
|
300
|
-
function walkElements(root, visit) {
|
|
301
|
-
const stack = [...root.children];
|
|
302
|
-
while (stack.length > 0) {
|
|
303
|
-
const node = stack.pop();
|
|
304
|
-
if (!node) continue;
|
|
305
|
-
if (isElementNode(node)) {
|
|
306
|
-
visit(node);
|
|
307
|
-
if (node.children) for (const child of node.children) stack.push(child);
|
|
308
|
-
}
|
|
309
|
-
}
|
|
310
|
-
}
|
|
311
|
-
//#endregion
|
|
312
2281
|
//#region src/template/rules/v-for-has-key.ts
|
|
313
|
-
function check$
|
|
2282
|
+
function check$5(ctx) {
|
|
314
2283
|
const diagnostics = [];
|
|
315
2284
|
walkElements(ctx.template, (el) => {
|
|
316
2285
|
if (!findDirective(el, "for")) return;
|
|
@@ -332,7 +2301,7 @@ function check$1(ctx) {
|
|
|
332
2301
|
}
|
|
333
2302
|
//#endregion
|
|
334
2303
|
//#region src/template/rules/v-if-v-for-precedence.ts
|
|
335
|
-
function check(ctx) {
|
|
2304
|
+
function check$4(ctx) {
|
|
336
2305
|
const diagnostics = [];
|
|
337
2306
|
walkElements(ctx.template, (el) => {
|
|
338
2307
|
const vIf = findDirective(el, "if");
|
|
@@ -354,14 +2323,221 @@ function check(ctx) {
|
|
|
354
2323
|
return { diagnostics };
|
|
355
2324
|
}
|
|
356
2325
|
//#endregion
|
|
2326
|
+
//#region src/template/rules/v-memo-on-large-list.ts
|
|
2327
|
+
const ARRAY_LITERAL_THRESHOLD = 100;
|
|
2328
|
+
function findLargeArrayBindings(script) {
|
|
2329
|
+
const bindings = /* @__PURE__ */ new Map();
|
|
2330
|
+
try {
|
|
2331
|
+
const ast = babelParse(script, { sourceType: "module" });
|
|
2332
|
+
for (const node of ast.program.body) {
|
|
2333
|
+
if (node.type !== "VariableDeclaration") continue;
|
|
2334
|
+
if (node.kind !== "const" && node.kind !== "let") continue;
|
|
2335
|
+
for (const decl of node.declarations) {
|
|
2336
|
+
if (decl.id.type !== "Identifier") continue;
|
|
2337
|
+
const init = decl.init;
|
|
2338
|
+
if (!init || init.type !== "ArrayExpression") continue;
|
|
2339
|
+
if (!init.elements || init.elements.length <= ARRAY_LITERAL_THRESHOLD) continue;
|
|
2340
|
+
bindings.set(decl.id.name, init.elements.length);
|
|
2341
|
+
}
|
|
2342
|
+
}
|
|
2343
|
+
} catch {}
|
|
2344
|
+
return bindings;
|
|
2345
|
+
}
|
|
2346
|
+
function check$3(ctx) {
|
|
2347
|
+
const diagnostics = [];
|
|
2348
|
+
const largeArrays = ctx.script && ctx.script.length > 0 ? findLargeArrayBindings(ctx.script) : /* @__PURE__ */ new Map();
|
|
2349
|
+
walkElements(ctx.template, (el) => {
|
|
2350
|
+
const vFor = findDirective(el, "for");
|
|
2351
|
+
if (!vFor) return;
|
|
2352
|
+
if (findDirective(el, "memo")) return;
|
|
2353
|
+
const source = vFor.forParseResult?.source;
|
|
2354
|
+
if (!source) return;
|
|
2355
|
+
const sourceName = source.content;
|
|
2356
|
+
const largeArraySize = largeArrays.get(sourceName);
|
|
2357
|
+
if (largeArraySize !== void 0 && largeArraySize > ARRAY_LITERAL_THRESHOLD) diagnostics.push({
|
|
2358
|
+
file: ctx.file,
|
|
2359
|
+
line: el.loc.start.line,
|
|
2360
|
+
column: el.loc.start.column,
|
|
2361
|
+
endLine: el.loc.end.line,
|
|
2362
|
+
endColumn: el.loc.end.column,
|
|
2363
|
+
ruleId: "vue-doctor/template/v-memo-on-large-list",
|
|
2364
|
+
severity: "warn",
|
|
2365
|
+
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.`,
|
|
2366
|
+
source: "template",
|
|
2367
|
+
recommendation: "Add v-memo with a meaningful memoization key so Vue can skip re-rendering unchanged items."
|
|
2368
|
+
});
|
|
2369
|
+
});
|
|
2370
|
+
return { diagnostics };
|
|
2371
|
+
}
|
|
2372
|
+
//#endregion
|
|
2373
|
+
//#region src/template/rules/no-inline-object-prop-in-list.ts
|
|
2374
|
+
const NODE_DIRECTIVE$2 = 7;
|
|
2375
|
+
function checkElement$2(el, inVForSubtree, ctx, diagnostics) {
|
|
2376
|
+
const isVFor = el.props.some((p) => p.type === NODE_DIRECTIVE$2 && p.name === "for");
|
|
2377
|
+
const effectiveInVFor = inVForSubtree || isVFor;
|
|
2378
|
+
if (effectiveInVFor) for (const prop of el.props) {
|
|
2379
|
+
if (prop.type !== NODE_DIRECTIVE$2) continue;
|
|
2380
|
+
const dir = prop;
|
|
2381
|
+
if (dir.name !== "bind") continue;
|
|
2382
|
+
const attrName = dir.arg?.content;
|
|
2383
|
+
if (!attrName || attrName === "key") continue;
|
|
2384
|
+
const astType = dir.exp?.ast?.type;
|
|
2385
|
+
if (astType !== "ObjectExpression" && astType !== "ArrayExpression") continue;
|
|
2386
|
+
diagnostics.push({
|
|
2387
|
+
file: ctx.file,
|
|
2388
|
+
line: dir.loc.start.line,
|
|
2389
|
+
column: dir.loc.start.column,
|
|
2390
|
+
endLine: dir.loc.end.line,
|
|
2391
|
+
endColumn: dir.loc.end.column,
|
|
2392
|
+
ruleId: "vue-doctor/template/no-inline-object-prop-in-list",
|
|
2393
|
+
severity: "warn",
|
|
2394
|
+
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.`,
|
|
2395
|
+
source: "template",
|
|
2396
|
+
recommendation: "Move the object or array to a computed property or a module-level constant so the reference remains stable across renders."
|
|
2397
|
+
});
|
|
2398
|
+
}
|
|
2399
|
+
for (const child of el.children) if (child.type === 1) checkElement$2(child, effectiveInVFor, ctx, diagnostics);
|
|
2400
|
+
}
|
|
2401
|
+
function check$2(ctx) {
|
|
2402
|
+
const diagnostics = [];
|
|
2403
|
+
function walk(node, inVForSubtree) {
|
|
2404
|
+
if (node.type === 1) checkElement$2(node, inVForSubtree, ctx, diagnostics);
|
|
2405
|
+
else if (node.type === 0) {
|
|
2406
|
+
const root = node;
|
|
2407
|
+
for (const child of root.children) walk(child, inVForSubtree);
|
|
2408
|
+
}
|
|
2409
|
+
}
|
|
2410
|
+
walk(ctx.template, false);
|
|
2411
|
+
return { diagnostics };
|
|
2412
|
+
}
|
|
2413
|
+
//#endregion
|
|
2414
|
+
//#region src/template/rules/no-computed-getter-in-template-loop.ts
|
|
2415
|
+
const NODE_ELEMENT$1 = 1;
|
|
2416
|
+
const NODE_INTERPOLATION = 5;
|
|
2417
|
+
const NODE_DIRECTIVE$1 = 7;
|
|
2418
|
+
function isValueDeref(node) {
|
|
2419
|
+
if (node.type !== "MemberExpression" || node.computed === true) return false;
|
|
2420
|
+
const { object, property } = node;
|
|
2421
|
+
return object.type === "Identifier" && property.name === "value";
|
|
2422
|
+
}
|
|
2423
|
+
function containsValueDeref(value) {
|
|
2424
|
+
if (value === null || typeof value !== "object") return false;
|
|
2425
|
+
if (Array.isArray(value)) return value.some((item) => containsValueDeref(item));
|
|
2426
|
+
const node = value;
|
|
2427
|
+
return isValueDeref(node) || Object.values(node).some((child) => containsValueDeref(child));
|
|
2428
|
+
}
|
|
2429
|
+
function pushDiagnostic(el, loc, ctx, diagnostics) {
|
|
2430
|
+
diagnostics.push({
|
|
2431
|
+
file: ctx.file,
|
|
2432
|
+
line: loc.start.line,
|
|
2433
|
+
column: loc.start.column,
|
|
2434
|
+
endLine: loc.end.line,
|
|
2435
|
+
endColumn: loc.end.column,
|
|
2436
|
+
ruleId: "vue-doctor/template/no-computed-getter-in-template-loop",
|
|
2437
|
+
severity: "warn",
|
|
2438
|
+
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.`,
|
|
2439
|
+
source: "template",
|
|
2440
|
+
recommendation: "Read .value once into a computed property or a local binding outside the loop so the getter runs a single time per render."
|
|
2441
|
+
});
|
|
2442
|
+
}
|
|
2443
|
+
function checkElement$1(el, inVForSubtree, ctx, diagnostics) {
|
|
2444
|
+
const isVFor = el.props.some((p) => p.type === NODE_DIRECTIVE$1 && p.name === "for");
|
|
2445
|
+
const effectiveInVFor = inVForSubtree || isVFor;
|
|
2446
|
+
if (effectiveInVFor) {
|
|
2447
|
+
for (const prop of el.props) {
|
|
2448
|
+
if (prop.type !== NODE_DIRECTIVE$1) continue;
|
|
2449
|
+
const dir = prop;
|
|
2450
|
+
if (dir.name !== "bind") continue;
|
|
2451
|
+
if (containsValueDeref(dir.exp?.ast)) pushDiagnostic(el, dir.loc, ctx, diagnostics);
|
|
2452
|
+
}
|
|
2453
|
+
for (const child of el.children) if (child.type === NODE_INTERPOLATION) {
|
|
2454
|
+
const interp = child;
|
|
2455
|
+
if (containsValueDeref(interp.content.ast)) pushDiagnostic(el, interp.loc, ctx, diagnostics);
|
|
2456
|
+
}
|
|
2457
|
+
}
|
|
2458
|
+
for (const child of el.children) if (child.type === NODE_ELEMENT$1) checkElement$1(child, effectiveInVFor, ctx, diagnostics);
|
|
2459
|
+
}
|
|
2460
|
+
function check$1(ctx) {
|
|
2461
|
+
const diagnostics = [];
|
|
2462
|
+
function walk(node, inVForSubtree) {
|
|
2463
|
+
if (node.type === NODE_ELEMENT$1) checkElement$1(node, inVForSubtree, ctx, diagnostics);
|
|
2464
|
+
else if (node.type === 0) {
|
|
2465
|
+
const root = node;
|
|
2466
|
+
for (const child of root.children) walk(child, inVForSubtree);
|
|
2467
|
+
}
|
|
2468
|
+
}
|
|
2469
|
+
walk(ctx.template, false);
|
|
2470
|
+
return { diagnostics };
|
|
2471
|
+
}
|
|
2472
|
+
//#endregion
|
|
2473
|
+
//#region src/template/rules/avoid-deep-v-bind-spread-in-list.ts
|
|
2474
|
+
const NODE_ELEMENT = 1;
|
|
2475
|
+
const NODE_DIRECTIVE = 7;
|
|
2476
|
+
function isIdentifierSpread(dir) {
|
|
2477
|
+
return dir.name === "bind" && dir.arg === void 0 && dir.exp?.ast === null;
|
|
2478
|
+
}
|
|
2479
|
+
function checkElement(el, inVForSubtree, ctx, diagnostics) {
|
|
2480
|
+
const isVFor = el.props.some((p) => p.type === NODE_DIRECTIVE && p.name === "for");
|
|
2481
|
+
const effectiveInVFor = inVForSubtree || isVFor;
|
|
2482
|
+
if (effectiveInVFor) for (const prop of el.props) {
|
|
2483
|
+
if (prop.type !== NODE_DIRECTIVE) continue;
|
|
2484
|
+
const dir = prop;
|
|
2485
|
+
if (!isIdentifierSpread(dir)) continue;
|
|
2486
|
+
diagnostics.push({
|
|
2487
|
+
file: ctx.file,
|
|
2488
|
+
line: dir.loc.start.line,
|
|
2489
|
+
column: dir.loc.start.column,
|
|
2490
|
+
endLine: dir.loc.end.line,
|
|
2491
|
+
endColumn: dir.loc.end.column,
|
|
2492
|
+
ruleId: "vue-doctor/template/avoid-deep-v-bind-spread-in-list",
|
|
2493
|
+
severity: "info",
|
|
2494
|
+
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.`,
|
|
2495
|
+
source: "template",
|
|
2496
|
+
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."
|
|
2497
|
+
});
|
|
2498
|
+
}
|
|
2499
|
+
for (const child of el.children) if (child.type === NODE_ELEMENT) checkElement(child, effectiveInVFor, ctx, diagnostics);
|
|
2500
|
+
}
|
|
2501
|
+
function check(ctx) {
|
|
2502
|
+
const diagnostics = [];
|
|
2503
|
+
function walk(node, inVForSubtree) {
|
|
2504
|
+
if (node.type === NODE_ELEMENT) checkElement(node, inVForSubtree, ctx, diagnostics);
|
|
2505
|
+
else if (node.type === 0) {
|
|
2506
|
+
const root = node;
|
|
2507
|
+
for (const child of root.children) walk(child, inVForSubtree);
|
|
2508
|
+
}
|
|
2509
|
+
}
|
|
2510
|
+
walk(ctx.template, false);
|
|
2511
|
+
return { diagnostics };
|
|
2512
|
+
}
|
|
2513
|
+
//#endregion
|
|
357
2514
|
//#region src/template/rules/index.ts
|
|
358
|
-
const TEMPLATE_RULES = [
|
|
359
|
-
|
|
360
|
-
|
|
361
|
-
|
|
362
|
-
|
|
363
|
-
|
|
364
|
-
|
|
2515
|
+
const TEMPLATE_RULES = [
|
|
2516
|
+
{
|
|
2517
|
+
id: "vue-doctor/template/v-for-has-key",
|
|
2518
|
+
check: check$5
|
|
2519
|
+
},
|
|
2520
|
+
{
|
|
2521
|
+
id: "vue-doctor/template/v-if-v-for-precedence",
|
|
2522
|
+
check: check$4
|
|
2523
|
+
},
|
|
2524
|
+
{
|
|
2525
|
+
id: "vue-doctor/template/v-memo-on-large-list",
|
|
2526
|
+
check: check$3
|
|
2527
|
+
},
|
|
2528
|
+
{
|
|
2529
|
+
id: "vue-doctor/template/no-inline-object-prop-in-list",
|
|
2530
|
+
check: check$2
|
|
2531
|
+
},
|
|
2532
|
+
{
|
|
2533
|
+
id: "vue-doctor/template/no-computed-getter-in-template-loop",
|
|
2534
|
+
check: check$1
|
|
2535
|
+
},
|
|
2536
|
+
{
|
|
2537
|
+
id: "vue-doctor/template/avoid-deep-v-bind-spread-in-list",
|
|
2538
|
+
check
|
|
2539
|
+
}
|
|
2540
|
+
];
|
|
365
2541
|
//#endregion
|
|
366
2542
|
//#region src/template/run.ts
|
|
367
2543
|
async function runTemplatePass(opts) {
|
|
@@ -375,7 +2551,8 @@ async function runTemplatePass(opts) {
|
|
|
375
2551
|
if (override === "off") continue;
|
|
376
2552
|
const { diagnostics } = rule.check({
|
|
377
2553
|
file,
|
|
378
|
-
template: descriptor.template.ast
|
|
2554
|
+
template: descriptor.template.ast,
|
|
2555
|
+
script: descriptor.scriptSetup?.content ?? descriptor.script?.content
|
|
379
2556
|
});
|
|
380
2557
|
for (const d of diagnostics) all.push(override ? {
|
|
381
2558
|
...d,
|
|
@@ -401,27 +2578,47 @@ const DEFAULT_EXCLUDE = [
|
|
|
401
2578
|
".output",
|
|
402
2579
|
"coverage"
|
|
403
2580
|
];
|
|
2581
|
+
function countRuleCounts(diagnostics) {
|
|
2582
|
+
const counts = {};
|
|
2583
|
+
for (const d of diagnostics) counts[d.ruleId] = (counts[d.ruleId] ?? 0) + 1;
|
|
2584
|
+
return counts;
|
|
2585
|
+
}
|
|
404
2586
|
async function audit(config = {}) {
|
|
405
2587
|
const rootDir = resolve(config.rootDir ?? process.cwd());
|
|
406
2588
|
const include = config.include ?? DEFAULT_INCLUDE;
|
|
407
2589
|
const exclude = config.exclude ?? DEFAULT_EXCLUDE;
|
|
408
2590
|
const failOn = config.failOn ?? "error";
|
|
2591
|
+
const threshold = config.threshold ?? 0;
|
|
2592
|
+
const lintEnabled = config.lint !== false;
|
|
409
2593
|
const files = await listSourceFiles({
|
|
410
2594
|
rootDir,
|
|
411
2595
|
include,
|
|
412
2596
|
exclude
|
|
413
2597
|
});
|
|
414
|
-
const
|
|
2598
|
+
const overallStart = performance.now();
|
|
2599
|
+
const project = await detectProject(rootDir);
|
|
2600
|
+
const templateStart = performance.now();
|
|
2601
|
+
const templateDiagnostics = lintEnabled ? await runTemplatePass({
|
|
415
2602
|
files,
|
|
416
2603
|
ruleOverrides: config.rules
|
|
417
|
-
});
|
|
2604
|
+
}) : [];
|
|
2605
|
+
const templateElapsed = performance.now() - templateStart;
|
|
2606
|
+
const sfcStart = performance.now();
|
|
2607
|
+
const sfcDiagnostics = lintEnabled ? await runSfcPass({
|
|
2608
|
+
files,
|
|
2609
|
+
ruleOverrides: config.rules,
|
|
2610
|
+
projectInfo: project
|
|
2611
|
+
}) : [];
|
|
2612
|
+
const sfcElapsed = performance.now() - sfcStart;
|
|
418
2613
|
let scriptDiagnostics = [];
|
|
419
2614
|
let oxlintStderr = "";
|
|
420
|
-
|
|
2615
|
+
const scriptStart = performance.now();
|
|
2616
|
+
if (lintEnabled) try {
|
|
421
2617
|
const result = await runScriptPass({
|
|
422
2618
|
rootDir,
|
|
423
2619
|
targetPath: rootDir,
|
|
424
|
-
ruleOverrides: config.rules
|
|
2620
|
+
ruleOverrides: config.rules,
|
|
2621
|
+
framework: project.framework === "nuxt" ? "nuxt" : "vue"
|
|
425
2622
|
});
|
|
426
2623
|
scriptDiagnostics = result.diagnostics;
|
|
427
2624
|
oxlintStderr = result.stderr;
|
|
@@ -429,95 +2626,595 @@ async function audit(config = {}) {
|
|
|
429
2626
|
oxlintStderr = err instanceof Error ? err.message : String(err);
|
|
430
2627
|
if (process.env.DOCTOR_DEBUG) process.stderr.write(`[doctor-core] script pass failed: ${oxlintStderr}\n`);
|
|
431
2628
|
}
|
|
432
|
-
const
|
|
433
|
-
|
|
2629
|
+
const scriptElapsed = performance.now() - scriptStart;
|
|
2630
|
+
let deadCodeDiagnostics = [];
|
|
2631
|
+
let deadCodeElapsed = 0;
|
|
2632
|
+
if (config.deadCode !== false) {
|
|
2633
|
+
const deadCodeStart = performance.now();
|
|
2634
|
+
try {
|
|
2635
|
+
const { loadDoctorConfig } = await import("./load-BGjurxpY.js").then((n) => n.n);
|
|
2636
|
+
deadCodeDiagnostics = dedupeDeadCodeAgainstLint(await checkDeadCode({
|
|
2637
|
+
projectInfo: project,
|
|
2638
|
+
doctorConfig: await loadDoctorConfig(rootDir),
|
|
2639
|
+
enabled: true
|
|
2640
|
+
}), [
|
|
2641
|
+
...templateDiagnostics,
|
|
2642
|
+
...sfcDiagnostics,
|
|
2643
|
+
...scriptDiagnostics
|
|
2644
|
+
]).filter((d) => config.rules?.[d.ruleId] !== "off");
|
|
2645
|
+
} catch {}
|
|
2646
|
+
deadCodeElapsed = performance.now() - deadCodeStart;
|
|
2647
|
+
}
|
|
2648
|
+
let buildQualityDiagnostics = [];
|
|
2649
|
+
try {
|
|
2650
|
+
buildQualityDiagnostics = (await checkBuildQuality(project)).filter((d) => config.rules?.[d.ruleId] !== "off").map((d) => {
|
|
2651
|
+
const override = config.rules?.[d.ruleId];
|
|
2652
|
+
return override ? {
|
|
2653
|
+
...d,
|
|
2654
|
+
severity: override
|
|
2655
|
+
} : d;
|
|
2656
|
+
});
|
|
2657
|
+
} catch {}
|
|
2658
|
+
let depsDiagnostics = [];
|
|
2659
|
+
try {
|
|
2660
|
+
depsDiagnostics = (await checkDeps(project)).filter((d) => config.rules?.[d.ruleId] !== "off").map((d) => {
|
|
2661
|
+
const override = config.rules?.[d.ruleId];
|
|
2662
|
+
return override ? {
|
|
2663
|
+
...d,
|
|
2664
|
+
severity: override
|
|
2665
|
+
} : d;
|
|
2666
|
+
});
|
|
2667
|
+
} catch {}
|
|
2668
|
+
let nuxtProjectDiagnostics = [];
|
|
2669
|
+
if (project.framework === "nuxt") try {
|
|
2670
|
+
nuxtProjectDiagnostics = (await checkNuxtProject(project)).filter((d) => config.rules?.[d.ruleId] !== "off").map((d) => {
|
|
2671
|
+
const override = config.rules?.[d.ruleId];
|
|
2672
|
+
return override ? {
|
|
2673
|
+
...d,
|
|
2674
|
+
severity: override
|
|
2675
|
+
} : d;
|
|
2676
|
+
});
|
|
2677
|
+
} catch {}
|
|
2678
|
+
let crossFileDiagnostics = [];
|
|
2679
|
+
if (project.framework === "nuxt") try {
|
|
2680
|
+
crossFileDiagnostics = (await runCrossFilePass({
|
|
2681
|
+
files,
|
|
2682
|
+
projectInfo: project
|
|
2683
|
+
})).filter((d) => config.rules?.[d.ruleId] !== "off").map((d) => {
|
|
2684
|
+
const override = config.rules?.[d.ruleId];
|
|
2685
|
+
return override ? {
|
|
2686
|
+
...d,
|
|
2687
|
+
severity: override
|
|
2688
|
+
} : d;
|
|
2689
|
+
});
|
|
2690
|
+
} catch {}
|
|
2691
|
+
const elapsedMs = performance.now() - overallStart;
|
|
2692
|
+
const timings = {
|
|
2693
|
+
template: templateElapsed,
|
|
2694
|
+
sfc: sfcElapsed,
|
|
2695
|
+
script: scriptElapsed,
|
|
2696
|
+
deadCode: deadCodeElapsed,
|
|
2697
|
+
total: elapsedMs
|
|
2698
|
+
};
|
|
2699
|
+
let afterDisables = applyInlineDisables(mergeDiagnostics(templateDiagnostics, sfcDiagnostics, scriptDiagnostics, deadCodeDiagnostics, buildQualityDiagnostics, depsDiagnostics, nuxtProjectDiagnostics, crossFileDiagnostics), { respect: config.respectInlineDisables !== false });
|
|
2700
|
+
if (config.scopeFiles) {
|
|
2701
|
+
const scope = new Set(config.scopeFiles.map((f) => resolve(rootDir, f)));
|
|
2702
|
+
afterDisables = afterDisables.filter((d) => scope.has(resolve(rootDir, d.file)));
|
|
2703
|
+
}
|
|
2704
|
+
const diagnostics = await attachCodeSnippets(afterDisables);
|
|
2705
|
+
const scored = scoreDiagnostics(diagnostics, { threshold });
|
|
2706
|
+
const ruleCounts = countRuleCounts(diagnostics);
|
|
2707
|
+
const projectInfo = {
|
|
2708
|
+
framework: project.framework,
|
|
2709
|
+
vueVersion: project.vueVersion,
|
|
2710
|
+
nuxtVersion: project.nuxtVersion,
|
|
2711
|
+
capabilities: [...project.capabilities].sort(),
|
|
2712
|
+
rootDirectory: project.rootDirectory
|
|
2713
|
+
};
|
|
434
2714
|
let exitCode = 0;
|
|
435
2715
|
if (oxlintStderr && scriptDiagnostics.length === 0 && oxlintStderr.includes("Failed")) exitCode = 2;
|
|
436
|
-
else if (
|
|
2716
|
+
else if (failOn !== "none") {
|
|
2717
|
+
if ((failOn === "warn" ? scored.errorCount + scored.warnCount : scored.errorCount) > 0) exitCode = 1;
|
|
2718
|
+
}
|
|
437
2719
|
return {
|
|
438
2720
|
rootDir,
|
|
439
2721
|
filesScanned: files.length,
|
|
440
|
-
diagnostics
|
|
441
|
-
score,
|
|
442
|
-
errorCount,
|
|
443
|
-
|
|
444
|
-
|
|
2722
|
+
diagnostics,
|
|
2723
|
+
score: scored.score,
|
|
2724
|
+
errorCount: scored.errorCount,
|
|
2725
|
+
warnCount: scored.warnCount,
|
|
2726
|
+
infoCount: scored.infoCount,
|
|
2727
|
+
exitCode,
|
|
2728
|
+
scoreResult: scored,
|
|
2729
|
+
projectInfo,
|
|
2730
|
+
elapsedMs,
|
|
2731
|
+
timings,
|
|
2732
|
+
ruleCounts
|
|
445
2733
|
};
|
|
446
2734
|
}
|
|
447
2735
|
//#endregion
|
|
448
|
-
//#region src/config.ts
|
|
449
|
-
|
|
450
|
-
|
|
451
|
-
|
|
452
|
-
|
|
453
|
-
|
|
454
|
-
|
|
455
|
-
|
|
456
|
-
]
|
|
457
|
-
|
|
458
|
-
|
|
459
|
-
|
|
460
|
-
|
|
461
|
-
|
|
462
|
-
|
|
463
|
-
|
|
464
|
-
|
|
465
|
-
|
|
466
|
-
|
|
467
|
-
|
|
468
|
-
|
|
469
|
-
|
|
470
|
-
|
|
471
|
-
|
|
2736
|
+
//#region src/config/define-config.ts
|
|
2737
|
+
function defineConfig(config) {
|
|
2738
|
+
return config;
|
|
2739
|
+
}
|
|
2740
|
+
//#endregion
|
|
2741
|
+
//#region src/config/merge-cli-overrides.ts
|
|
2742
|
+
function mergeCliOverrides(resolved, cli) {
|
|
2743
|
+
const rules = { ...resolved.rules };
|
|
2744
|
+
if (cli.rules) for (const [key, value] of Object.entries(cli.rules)) if (value === "off") delete rules[key];
|
|
2745
|
+
else rules[key] = value;
|
|
2746
|
+
return {
|
|
2747
|
+
rootDir: resolved.rootDir,
|
|
2748
|
+
include: cli.include ?? resolved.include,
|
|
2749
|
+
exclude: cli.exclude ?? resolved.exclude,
|
|
2750
|
+
failOn: cli.failOn ?? resolved.failOn,
|
|
2751
|
+
threshold: cli.threshold ?? resolved.threshold,
|
|
2752
|
+
rules,
|
|
2753
|
+
source: resolved.source,
|
|
2754
|
+
configFile: resolved.configFile
|
|
2755
|
+
};
|
|
2756
|
+
}
|
|
2757
|
+
//#endregion
|
|
2758
|
+
//#region src/git-scope.ts
|
|
2759
|
+
const execFileAsync = promisify(execFile);
|
|
2760
|
+
const SOURCE_EXTENSIONS = [
|
|
2761
|
+
".vue",
|
|
2762
|
+
".ts",
|
|
2763
|
+
".tsx",
|
|
2764
|
+
".js",
|
|
2765
|
+
".jsx"
|
|
2766
|
+
];
|
|
2767
|
+
function isSourceFile(path) {
|
|
2768
|
+
return SOURCE_EXTENSIONS.some((ext) => path.endsWith(ext));
|
|
2769
|
+
}
|
|
2770
|
+
async function gitLines(rootDir, args) {
|
|
2771
|
+
const { stdout } = await execFileAsync("git", args, { cwd: rootDir });
|
|
2772
|
+
return stdout.split("\n").map((line) => line.trim()).filter((line) => line.length > 0);
|
|
2773
|
+
}
|
|
2774
|
+
async function listChangedFiles(options) {
|
|
2775
|
+
const { rootDir, mode } = options;
|
|
2776
|
+
let relPaths;
|
|
2777
|
+
if (mode === "staged") relPaths = await gitLines(rootDir, [
|
|
2778
|
+
"diff",
|
|
2779
|
+
"--name-only",
|
|
2780
|
+
"--cached",
|
|
2781
|
+
"--diff-filter=ACMR"
|
|
2782
|
+
]);
|
|
2783
|
+
else {
|
|
2784
|
+
const tracked = await gitLines(rootDir, [
|
|
2785
|
+
"diff",
|
|
2786
|
+
"--name-only",
|
|
2787
|
+
"HEAD",
|
|
2788
|
+
"--diff-filter=ACMR"
|
|
2789
|
+
]);
|
|
2790
|
+
const untracked = await gitLines(rootDir, [
|
|
2791
|
+
"ls-files",
|
|
2792
|
+
"--others",
|
|
2793
|
+
"--exclude-standard"
|
|
2794
|
+
]);
|
|
2795
|
+
relPaths = [...tracked, ...untracked];
|
|
2796
|
+
}
|
|
2797
|
+
const absolute = relPaths.filter(isSourceFile).map((rel) => resolve(rootDir, rel));
|
|
2798
|
+
return [...new Set(absolute)].sort();
|
|
2799
|
+
}
|
|
2800
|
+
//#endregion
|
|
2801
|
+
//#region src/reporters/docs-url.ts
|
|
2802
|
+
function docsUrl(ruleId) {
|
|
2803
|
+
return `https://docs.doctor.geoql.in/rules/${ruleId}`;
|
|
2804
|
+
}
|
|
2805
|
+
//#endregion
|
|
2806
|
+
//#region src/reporters/render.ts
|
|
2807
|
+
const CODE_WIDTH = 80;
|
|
2808
|
+
const WHY_WIDTH = 70;
|
|
2809
|
+
const SEVERITY_WIDTH = 6;
|
|
2810
|
+
const CONTINUATION_INDENT = " ".repeat(10);
|
|
2811
|
+
function relativize$1(file, rootDirectory) {
|
|
2812
|
+
return relative(rootDirectory, file).replaceAll("\\", "/");
|
|
2813
|
+
}
|
|
2814
|
+
function compareStrings(a, b) {
|
|
2815
|
+
if (a < b) return -1;
|
|
2816
|
+
if (a > b) return 1;
|
|
2817
|
+
return 0;
|
|
2818
|
+
}
|
|
2819
|
+
function truncateCode(code) {
|
|
2820
|
+
if (code.length <= CODE_WIDTH) return code;
|
|
2821
|
+
return `${code.slice(0, CODE_WIDTH - 1)}…`;
|
|
2822
|
+
}
|
|
2823
|
+
function wrapText(text, width) {
|
|
2824
|
+
const words = text.trim().split(/\s+/);
|
|
2825
|
+
const lines = [];
|
|
2826
|
+
let current = "";
|
|
2827
|
+
for (const word of words) if (current === "") current = word;
|
|
2828
|
+
else if (`${current} ${word}`.length <= width) current = `${current} ${word}`;
|
|
2829
|
+
else {
|
|
2830
|
+
lines.push(current);
|
|
2831
|
+
current = word;
|
|
2832
|
+
}
|
|
2833
|
+
if (current !== "") lines.push(current);
|
|
2834
|
+
return lines;
|
|
2835
|
+
}
|
|
2836
|
+
function formatWhy(message) {
|
|
2837
|
+
return wrapText(message, WHY_WIDTH).join(`\n${CONTINUATION_INDENT}`);
|
|
2838
|
+
}
|
|
2839
|
+
function severityTag(severity, theme) {
|
|
2840
|
+
const padding = " ".repeat(SEVERITY_WIDTH - severity.length);
|
|
2841
|
+
return `${theme.severity(severity)}${padding}`;
|
|
2842
|
+
}
|
|
2843
|
+
function sortDiagnostics(diagnostics, rootDirectory) {
|
|
2844
|
+
return [...diagnostics].sort((a, b) => {
|
|
2845
|
+
const byFile = compareStrings(relativize$1(a.file, rootDirectory), relativize$1(b.file, rootDirectory));
|
|
2846
|
+
if (byFile !== 0) return byFile;
|
|
2847
|
+
if (a.line !== b.line) return a.line - b.line;
|
|
2848
|
+
if (a.column !== b.column) return a.column - b.column;
|
|
2849
|
+
return compareStrings(a.ruleId, b.ruleId);
|
|
2850
|
+
});
|
|
2851
|
+
}
|
|
2852
|
+
function diagnosticBlock(index, diagnostic, rootDirectory, theme) {
|
|
2853
|
+
const tag = severityTag(diagnostic.severity, theme);
|
|
2854
|
+
const location = `${relativize$1(diagnostic.file, rootDirectory)}:${diagnostic.line}:${diagnostic.column}`;
|
|
2855
|
+
const code = truncateCode(diagnostic.codeSnippet ?? "");
|
|
2856
|
+
const fix = diagnostic.recommendation ?? "(no automated fix)";
|
|
2857
|
+
const why = formatWhy(diagnostic.message);
|
|
2858
|
+
return [
|
|
2859
|
+
`[${index}] ${tag} ${diagnostic.ruleId}`,
|
|
2860
|
+
` file: ${location}`,
|
|
2861
|
+
` code: ${code}`,
|
|
2862
|
+
` fix: ${fix}`,
|
|
2863
|
+
` why: ${why}`,
|
|
2864
|
+
` docs: ${docsUrl(diagnostic.ruleId)}`
|
|
2865
|
+
].join("\n");
|
|
2866
|
+
}
|
|
2867
|
+
function findingsLine(score) {
|
|
2868
|
+
if (score.totalFindings === 0) return "FINDINGS: 0 (clean)";
|
|
2869
|
+
return `FINDINGS: ${score.totalFindings} (${score.errorCount} error, ${score.warnCount} warn, ${score.infoCount} info)`;
|
|
2870
|
+
}
|
|
2871
|
+
function nextSteps(breakdown) {
|
|
2872
|
+
const lines = breakdown.slice(0, 3).map((entry) => ` −${Math.round(entry.penalty)} pts ${entry.occurrences}× ${entry.ruleId}`);
|
|
2873
|
+
lines.push(` Run \`vue-doctor explain ${breakdown[0].ruleId}\` for full context.`);
|
|
2874
|
+
return ["NEXT STEPS:", ...lines].join("\n");
|
|
2875
|
+
}
|
|
2876
|
+
function render(input, options, theme) {
|
|
2877
|
+
if (options?.quiet) return `SCORE: ${theme.scoreValue(input.score.score)}/100 (threshold: ${input.score.threshold})\n`;
|
|
2878
|
+
const seconds = (input.elapsedMs / 1e3).toFixed(1);
|
|
2879
|
+
const segments = [
|
|
2880
|
+
`${input.toolName} v${input.toolVersion}`,
|
|
2881
|
+
`analyzed ${input.analyzedFileCount} files in ${seconds}s`,
|
|
2882
|
+
"",
|
|
2883
|
+
`SCORE: ${theme.scoreValue(input.score.score)}/100 (threshold: ${input.score.threshold})`,
|
|
2884
|
+
"",
|
|
2885
|
+
findingsLine(input.score)
|
|
2886
|
+
];
|
|
2887
|
+
sortDiagnostics(input.diagnostics, input.rootDirectory).forEach((diagnostic, i) => {
|
|
2888
|
+
segments.push("");
|
|
2889
|
+
segments.push(diagnosticBlock(i + 1, diagnostic, input.rootDirectory, theme));
|
|
472
2890
|
});
|
|
2891
|
+
if (input.score.breakdown.length > 0) {
|
|
2892
|
+
segments.push("");
|
|
2893
|
+
segments.push(nextSteps(input.score.breakdown));
|
|
2894
|
+
}
|
|
2895
|
+
return `${segments.join("\n")}\n`;
|
|
2896
|
+
}
|
|
2897
|
+
//#endregion
|
|
2898
|
+
//#region src/reporters/agent.ts
|
|
2899
|
+
const plainTheme = {
|
|
2900
|
+
severity: (severity) => severity,
|
|
2901
|
+
scoreValue: (score) => String(score)
|
|
2902
|
+
};
|
|
2903
|
+
function agentReport(input, options) {
|
|
2904
|
+
return render(input, options, plainTheme);
|
|
2905
|
+
}
|
|
2906
|
+
//#endregion
|
|
2907
|
+
//#region src/rule-docs.ts
|
|
2908
|
+
function autoDescription(rule) {
|
|
2909
|
+
const presetNote = rule.recommended ? "Active in the `recommended` preset." : "Off by default in `recommended`; enable via `--rule <id>:warn` or in `doctor.config.ts`.";
|
|
2910
|
+
return `\`${rule.id}\` is a ${rule.severity}-severity ${rule.category} rule from ${rule.source}. ${presetNote}\n\nSee ${docsUrl(rule.id)} for details when the docs site lands.`;
|
|
2911
|
+
}
|
|
2912
|
+
function resolveDocsRoot() {
|
|
2913
|
+
return resolve(dirname(fileURLToPath(import.meta.url)), "..", "docs", "rules");
|
|
2914
|
+
}
|
|
2915
|
+
function safeReadOverride(ruleId, docsRoot) {
|
|
2916
|
+
const candidate = join(docsRoot, `${ruleId.replace(/\//g, "__")}.md`);
|
|
2917
|
+
if (!existsSync(candidate)) return null;
|
|
2918
|
+
return readFileSync(candidate, "utf-8");
|
|
2919
|
+
}
|
|
2920
|
+
function loadRuleDoc(ruleId, options = {}) {
|
|
2921
|
+
const rule = RULE_REGISTRY.find((r) => r.id === ruleId);
|
|
2922
|
+
if (!rule) return null;
|
|
2923
|
+
const override = safeReadOverride(ruleId, options.docsRoot ?? resolveDocsRoot());
|
|
2924
|
+
const description = override ?? autoDescription(rule);
|
|
473
2925
|
return {
|
|
474
|
-
|
|
475
|
-
|
|
476
|
-
|
|
477
|
-
|
|
478
|
-
|
|
2926
|
+
id: rule.id,
|
|
2927
|
+
title: rule.id,
|
|
2928
|
+
severity: rule.severity,
|
|
2929
|
+
category: rule.category,
|
|
2930
|
+
recommended: rule.recommended,
|
|
2931
|
+
source: rule.source,
|
|
2932
|
+
helpUri: docsUrl(rule.id),
|
|
2933
|
+
description,
|
|
2934
|
+
hasOverride: override !== null
|
|
2935
|
+
};
|
|
2936
|
+
}
|
|
2937
|
+
function loadAllRuleDocs(options = {}) {
|
|
2938
|
+
return RULE_REGISTRY.map((rule) => loadRuleDoc(rule.id, options));
|
|
2939
|
+
}
|
|
2940
|
+
//#endregion
|
|
2941
|
+
//#region src/reporters/sarif.ts
|
|
2942
|
+
const SARIF_SCHEMA = "https://raw.githubusercontent.com/oasis-tcs/sarif-spec/main/sarif-2.1/schema/sarif-schema-2.1.0.json";
|
|
2943
|
+
const INFORMATION_URI = "https://github.com/geoql/doctor";
|
|
2944
|
+
function toSarifLevel(severity) {
|
|
2945
|
+
if (severity === "error") return "error";
|
|
2946
|
+
if (severity === "warn") return "warning";
|
|
2947
|
+
return "note";
|
|
2948
|
+
}
|
|
2949
|
+
function toRelativeUri(filePath, rootDirectory) {
|
|
2950
|
+
const normalizedRoot = rootDirectory.endsWith("/") ? rootDirectory : `${rootDirectory}/`;
|
|
2951
|
+
if (filePath.startsWith(normalizedRoot)) return filePath.slice(normalizedRoot.length);
|
|
2952
|
+
return filePath;
|
|
2953
|
+
}
|
|
2954
|
+
function toSarifResult(diag, rootDirectory) {
|
|
2955
|
+
const region = {
|
|
2956
|
+
startLine: diag.line,
|
|
2957
|
+
startColumn: diag.column
|
|
2958
|
+
};
|
|
2959
|
+
if (diag.endLine !== void 0) region.endLine = diag.endLine;
|
|
2960
|
+
if (diag.endColumn !== void 0) region.endColumn = diag.endColumn;
|
|
2961
|
+
const uri = toRelativeUri(diag.file, rootDirectory);
|
|
2962
|
+
return {
|
|
2963
|
+
ruleId: diag.ruleId,
|
|
2964
|
+
level: toSarifLevel(diag.severity),
|
|
2965
|
+
message: { text: diag.message },
|
|
2966
|
+
locations: [{ physicalLocation: {
|
|
2967
|
+
artifactLocation: {
|
|
2968
|
+
uri,
|
|
2969
|
+
uriBaseId: "%SRCROOT%"
|
|
2970
|
+
},
|
|
2971
|
+
region
|
|
2972
|
+
} }],
|
|
2973
|
+
partialFingerprints: { primaryLocationLineHash: `${uri}:${diag.line}:${diag.ruleId}` }
|
|
2974
|
+
};
|
|
2975
|
+
}
|
|
2976
|
+
function collectRuleIds(diagnostics) {
|
|
2977
|
+
const ids = /* @__PURE__ */ new Set();
|
|
2978
|
+
for (const d of diagnostics) ids.add(d.ruleId);
|
|
2979
|
+
return ids;
|
|
2980
|
+
}
|
|
2981
|
+
function toRuleDescriptor(ruleId) {
|
|
2982
|
+
const registered = RULE_REGISTRY.find((r) => r.id === ruleId);
|
|
2983
|
+
const severity = registered?.severity ?? "warn";
|
|
2984
|
+
const category = registered?.category ?? "doctor-owned";
|
|
2985
|
+
const name = ruleId.includes("/") ? ruleId.split("/").slice(1).join("/") : ruleId;
|
|
2986
|
+
const fullDescription = loadRuleDoc(ruleId)?.description ?? ruleId;
|
|
2987
|
+
return {
|
|
2988
|
+
id: ruleId,
|
|
2989
|
+
name,
|
|
2990
|
+
shortDescription: { text: ruleId },
|
|
2991
|
+
fullDescription: { text: fullDescription },
|
|
2992
|
+
helpUri: docsUrl(ruleId),
|
|
2993
|
+
defaultConfiguration: { level: toSarifLevel(severity) },
|
|
2994
|
+
properties: { category }
|
|
479
2995
|
};
|
|
480
2996
|
}
|
|
2997
|
+
function sarifReport(input) {
|
|
2998
|
+
const rules = [...collectRuleIds(input.diagnostics)].sort().map(toRuleDescriptor);
|
|
2999
|
+
const results = input.diagnostics.map((d) => toSarifResult(d, input.rootDirectory));
|
|
3000
|
+
const log = {
|
|
3001
|
+
$schema: SARIF_SCHEMA,
|
|
3002
|
+
version: "2.1.0",
|
|
3003
|
+
runs: [{
|
|
3004
|
+
tool: { driver: {
|
|
3005
|
+
name: input.toolName,
|
|
3006
|
+
version: input.toolVersion,
|
|
3007
|
+
informationUri: INFORMATION_URI,
|
|
3008
|
+
rules
|
|
3009
|
+
} },
|
|
3010
|
+
results
|
|
3011
|
+
}]
|
|
3012
|
+
};
|
|
3013
|
+
return `${JSON.stringify(log, null, 2)}\n`;
|
|
3014
|
+
}
|
|
3015
|
+
//#endregion
|
|
3016
|
+
//#region src/reporters/html.ts
|
|
3017
|
+
const SEVERITY_LABEL = {
|
|
3018
|
+
error: "Error",
|
|
3019
|
+
warn: "Warn",
|
|
3020
|
+
info: "Info"
|
|
3021
|
+
};
|
|
3022
|
+
function escapeHtml(s) {
|
|
3023
|
+
return s.replace(/&/g, "&").replace(/</g, "<").replace(/>/g, ">").replace(/"/g, """).replace(/'/g, "'");
|
|
3024
|
+
}
|
|
3025
|
+
function relativize(file, rootDir) {
|
|
3026
|
+
return file.startsWith(`${rootDir}/`) ? file.slice(rootDir.length + 1) : file;
|
|
3027
|
+
}
|
|
3028
|
+
function renderDiagnosticRow(d, rootDir) {
|
|
3029
|
+
const loc = `${escapeHtml(relativize(d.file, rootDir))}:${d.line}:${d.column}`;
|
|
3030
|
+
const recommendation = d.recommendation ? `<div class="rec"><strong>Fix:</strong> ${escapeHtml(d.recommendation)}</div>` : "";
|
|
3031
|
+
return [
|
|
3032
|
+
`<details class="finding sev-${d.severity}">`,
|
|
3033
|
+
` <summary>`,
|
|
3034
|
+
` <span class="sev">${SEVERITY_LABEL[d.severity]}</span>`,
|
|
3035
|
+
` <code class="rule">${escapeHtml(d.ruleId)}</code>`,
|
|
3036
|
+
` <span class="loc">${loc}</span>`,
|
|
3037
|
+
` <span class="msg">${escapeHtml(d.message)}</span>`,
|
|
3038
|
+
` </summary>`,
|
|
3039
|
+
` <div class="body">`,
|
|
3040
|
+
recommendation,
|
|
3041
|
+
` <div class="docs"><a href="${escapeHtml(docsUrl(d.ruleId))}" target="_blank" rel="noopener">Rule docs</a></div>`,
|
|
3042
|
+
` </div>`,
|
|
3043
|
+
`</details>`
|
|
3044
|
+
].join("\n");
|
|
3045
|
+
}
|
|
3046
|
+
const STYLES = `
|
|
3047
|
+
* { box-sizing: border-box; margin: 0; padding: 0; }
|
|
3048
|
+
body { font: 14px/1.5 system-ui, -apple-system, sans-serif; background: #0f0f0f; color: #e5e5e5; padding: 2rem; }
|
|
3049
|
+
header { border-bottom: 1px solid #2a2a2a; padding-bottom: 1.5rem; margin-bottom: 1.5rem; }
|
|
3050
|
+
h1 { font-size: 1.25rem; font-weight: 500; color: #fafafa; }
|
|
3051
|
+
.meta { color: #888; margin-top: 0.5rem; font-size: 0.85rem; }
|
|
3052
|
+
.score-row { display: flex; gap: 2rem; margin-top: 1rem; align-items: baseline; }
|
|
3053
|
+
.score-row .score { font-size: 3rem; font-weight: 200; color: #fafafa; }
|
|
3054
|
+
.score-row .score.pass { color: #4ade80; }
|
|
3055
|
+
.score-row .score.fail { color: #f87171; }
|
|
3056
|
+
.score-row .breakdown { color: #aaa; font-size: 0.9rem; }
|
|
3057
|
+
.finding { border: 1px solid #2a2a2a; border-radius: 6px; margin-bottom: 0.5rem; background: #1a1a1a; overflow: hidden; }
|
|
3058
|
+
.finding summary { padding: 0.75rem 1rem; cursor: pointer; display: grid; grid-template-columns: 4rem 14rem 14rem 1fr; gap: 0.75rem; align-items: baseline; font-size: 0.85rem; list-style: none; }
|
|
3059
|
+
.finding summary::-webkit-details-marker { display: none; }
|
|
3060
|
+
.sev { font-size: 0.7rem; text-transform: uppercase; font-weight: 600; letter-spacing: 0.05em; }
|
|
3061
|
+
.sev-error .sev { color: #f87171; }
|
|
3062
|
+
.sev-warn .sev { color: #fbbf24; }
|
|
3063
|
+
.sev-info .sev { color: #60a5fa; }
|
|
3064
|
+
.rule { color: #aaa; font-family: ui-monospace, monospace; font-size: 0.8rem; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
|
|
3065
|
+
.loc { color: #888; font-family: ui-monospace, monospace; font-size: 0.8rem; }
|
|
3066
|
+
.msg { color: #d4d4d4; }
|
|
3067
|
+
.body { padding: 0 1rem 1rem; border-top: 1px solid #2a2a2a; color: #ccc; font-size: 0.85rem; }
|
|
3068
|
+
.rec { margin-top: 0.75rem; }
|
|
3069
|
+
.docs { margin-top: 0.5rem; }
|
|
3070
|
+
.docs a { color: #60a5fa; text-decoration: none; }
|
|
3071
|
+
.docs a:hover { text-decoration: underline; }
|
|
3072
|
+
.empty { color: #888; padding: 2rem 0; text-align: center; }
|
|
3073
|
+
`;
|
|
3074
|
+
function htmlReport(input) {
|
|
3075
|
+
const score = input.score.score;
|
|
3076
|
+
const pass = input.score.passed;
|
|
3077
|
+
const rows = input.diagnostics.map((d) => renderDiagnosticRow(d, input.rootDirectory)).join("\n");
|
|
3078
|
+
const body = input.diagnostics.length === 0 ? `<p class="empty">No findings. Clean run.</p>` : rows;
|
|
3079
|
+
const findingsLabel = `${input.diagnostics.length} finding${input.diagnostics.length === 1 ? "" : "s"}`;
|
|
3080
|
+
const meta = `${input.toolName} v${input.toolVersion} · ${input.analyzedFileCount} file${input.analyzedFileCount === 1 ? "" : "s"} · ${input.elapsedMs.toFixed(0)}ms`;
|
|
3081
|
+
return `<!DOCTYPE html>
|
|
3082
|
+
<html lang="en">
|
|
3083
|
+
<head>
|
|
3084
|
+
<meta charset="UTF-8">
|
|
3085
|
+
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
|
3086
|
+
<title>${escapeHtml(input.toolName)} — Report</title>
|
|
3087
|
+
<style>${STYLES}</style>
|
|
3088
|
+
</head>
|
|
3089
|
+
<body>
|
|
3090
|
+
<header>
|
|
3091
|
+
<h1>${escapeHtml(input.toolName)} report</h1>
|
|
3092
|
+
<div class="meta">${escapeHtml(meta)} · ${escapeHtml(input.rootDirectory)}</div>
|
|
3093
|
+
<div class="score-row">
|
|
3094
|
+
<div class="score ${pass ? "pass" : "fail"}">${score}<span style="font-size:1.5rem;color:#888">/100</span></div>
|
|
3095
|
+
<div class="breakdown">${findingsLabel} · ${input.score.errorCount} error · ${input.score.warnCount} warn · ${input.score.infoCount} info</div>
|
|
3096
|
+
</div>
|
|
3097
|
+
</header>
|
|
3098
|
+
<main>
|
|
3099
|
+
${body}
|
|
3100
|
+
</main>
|
|
3101
|
+
</body>
|
|
3102
|
+
</html>
|
|
3103
|
+
`;
|
|
3104
|
+
}
|
|
481
3105
|
//#endregion
|
|
482
3106
|
//#region src/reporters/json.ts
|
|
483
|
-
|
|
484
|
-
|
|
3107
|
+
const DOCTOR_REPORT_SCHEMA_VERSION = "1";
|
|
3108
|
+
function buildDoctorReport(input) {
|
|
3109
|
+
return {
|
|
3110
|
+
schemaVersion: "1",
|
|
3111
|
+
tool: {
|
|
3112
|
+
name: input.toolName,
|
|
3113
|
+
version: input.toolVersion
|
|
3114
|
+
},
|
|
3115
|
+
projectInfo: {
|
|
3116
|
+
framework: input.projectInfo.framework,
|
|
3117
|
+
vueVersion: input.projectInfo.vueVersion,
|
|
3118
|
+
nuxtVersion: input.projectInfo.nuxtVersion,
|
|
3119
|
+
capabilities: [...input.projectInfo.capabilities].sort(),
|
|
3120
|
+
rootDirectory: input.projectInfo.rootDirectory
|
|
3121
|
+
},
|
|
3122
|
+
score: {
|
|
3123
|
+
value: input.score.score,
|
|
3124
|
+
threshold: input.score.threshold,
|
|
3125
|
+
passed: input.score.passed,
|
|
3126
|
+
bySeverity: {
|
|
3127
|
+
error: input.score.errorCount,
|
|
3128
|
+
warn: input.score.warnCount,
|
|
3129
|
+
info: input.score.infoCount
|
|
3130
|
+
},
|
|
3131
|
+
breakdown: input.score.breakdown.map((entry) => ({
|
|
3132
|
+
ruleId: entry.ruleId,
|
|
3133
|
+
occurrences: entry.occurrences,
|
|
3134
|
+
weightPerOccurrence: entry.weightPerOccurrence,
|
|
3135
|
+
penalty: entry.penalty
|
|
3136
|
+
}))
|
|
3137
|
+
},
|
|
3138
|
+
diagnostics: input.diagnostics.map((d) => ({
|
|
3139
|
+
file: relative(input.rootDirectory, d.file).replaceAll("\\", "/"),
|
|
3140
|
+
line: d.line,
|
|
3141
|
+
column: d.column,
|
|
3142
|
+
endLine: d.endLine,
|
|
3143
|
+
endColumn: d.endColumn,
|
|
3144
|
+
ruleId: d.ruleId,
|
|
3145
|
+
severity: d.severity,
|
|
3146
|
+
message: d.message,
|
|
3147
|
+
source: d.source,
|
|
3148
|
+
recommendation: d.recommendation
|
|
3149
|
+
})),
|
|
3150
|
+
timing: {
|
|
3151
|
+
elapsedMs: input.elapsedMs,
|
|
3152
|
+
analyzedFileCount: input.analyzedFileCount
|
|
3153
|
+
}
|
|
3154
|
+
};
|
|
3155
|
+
}
|
|
3156
|
+
function jsonReport(input) {
|
|
3157
|
+
return JSON.stringify(buildDoctorReport(input), null, 2);
|
|
3158
|
+
}
|
|
3159
|
+
//#endregion
|
|
3160
|
+
//#region src/reporters/json-compact.ts
|
|
3161
|
+
function jsonCompactReport(input) {
|
|
3162
|
+
return `${JSON.stringify(buildDoctorReport(input))}\n`;
|
|
3163
|
+
}
|
|
3164
|
+
//#endregion
|
|
3165
|
+
//#region src/reporters/pretty.ts
|
|
3166
|
+
const colors = pc.createColors(true);
|
|
3167
|
+
const colorTheme = {
|
|
3168
|
+
severity: (severity) => {
|
|
3169
|
+
if (severity === "error") return colors.red(severity);
|
|
3170
|
+
if (severity === "warn") return colors.yellow(severity);
|
|
3171
|
+
return colors.cyan(severity);
|
|
3172
|
+
},
|
|
3173
|
+
scoreValue: (score) => {
|
|
3174
|
+
if (score <= 50) return colors.red(String(score));
|
|
3175
|
+
if (score <= 79) return colors.yellow(String(score));
|
|
3176
|
+
return colors.green(String(score));
|
|
3177
|
+
}
|
|
3178
|
+
};
|
|
3179
|
+
function prettyReport(input, options) {
|
|
3180
|
+
if (options?.color === false || Boolean(process$1.env.NO_COLOR)) return agentReport(input, options);
|
|
3181
|
+
return render(input, options, colorTheme);
|
|
485
3182
|
}
|
|
486
3183
|
//#endregion
|
|
487
|
-
//#region src/reporters/
|
|
488
|
-
function
|
|
3184
|
+
//#region src/reporters/verbose.ts
|
|
3185
|
+
function formatMs(ms) {
|
|
3186
|
+
return `${ms.toFixed(1)}ms`;
|
|
3187
|
+
}
|
|
3188
|
+
function renderVerboseTrace(report, options) {
|
|
489
3189
|
const lines = [];
|
|
490
|
-
|
|
491
|
-
|
|
492
|
-
|
|
493
|
-
|
|
494
|
-
|
|
495
|
-
}
|
|
496
|
-
|
|
497
|
-
|
|
498
|
-
lines.push(
|
|
499
|
-
for (const
|
|
500
|
-
|
|
501
|
-
|
|
502
|
-
|
|
503
|
-
|
|
504
|
-
if (d.recommendation) lines.push(` ${c.dim("hint:")} ${d.recommendation}`);
|
|
505
|
-
}
|
|
506
|
-
lines.push("");
|
|
3190
|
+
lines.push("TIMINGS");
|
|
3191
|
+
lines.push(` template: ${formatMs(report.timings.template)}`);
|
|
3192
|
+
lines.push(` sfc: ${formatMs(report.timings.sfc)}`);
|
|
3193
|
+
lines.push(` script: ${formatMs(report.timings.script)}`);
|
|
3194
|
+
lines.push(` deadCode: ${formatMs(report.timings.deadCode)}`);
|
|
3195
|
+
lines.push(` total: ${formatMs(report.timings.total)}`);
|
|
3196
|
+
const ruleKeys = Object.keys(report.ruleCounts);
|
|
3197
|
+
if (ruleKeys.length > 0) {
|
|
3198
|
+
lines.push("RULE COUNTS");
|
|
3199
|
+
for (const ruleId of ruleKeys.sort()) lines.push(` ${ruleId}: ${report.ruleCounts[ruleId]}`);
|
|
3200
|
+
}
|
|
3201
|
+
if (options.configSource) {
|
|
3202
|
+
lines.push("CONFIG");
|
|
3203
|
+
lines.push(` source: ${options.configSource}`);
|
|
507
3204
|
}
|
|
508
|
-
const summary = `${report.errorCount} error${report.errorCount === 1 ? "" : "s"}, ${report.warningCount} warning${report.warningCount === 1 ? "" : "s"} in ${report.filesScanned} file${report.filesScanned === 1 ? "" : "s"}`;
|
|
509
|
-
const scoreLine = `Score: ${c.bold(String(report.score))}/100`;
|
|
510
|
-
lines.push(summary);
|
|
511
|
-
lines.push(scoreLine);
|
|
512
3205
|
return lines.join("\n");
|
|
513
3206
|
}
|
|
514
3207
|
//#endregion
|
|
515
3208
|
//#region src/reporters/index.ts
|
|
516
|
-
function format(
|
|
517
|
-
if (kind === "
|
|
518
|
-
return
|
|
3209
|
+
function format(input, kind = "agent", options) {
|
|
3210
|
+
if (kind === "pretty") return prettyReport(input, options);
|
|
3211
|
+
if (kind === "json") return jsonReport(input);
|
|
3212
|
+
if (kind === "json-compact") return jsonCompactReport(input);
|
|
3213
|
+
if (kind === "sarif") return sarifReport(input);
|
|
3214
|
+
if (kind === "html") return htmlReport(input);
|
|
3215
|
+
return agentReport(input, options);
|
|
519
3216
|
}
|
|
520
3217
|
//#endregion
|
|
521
|
-
export { audit, format,
|
|
3218
|
+
export { BUILT_IN_RECOMMENDED, ConfigCycleError, ConfigFileNotFoundError, DOCTOR_REPORT_SCHEMA_VERSION, DeadCodeImportFailed, DeadCodeTimeoutError, InvalidConfigError, OxlintOutputTooLarge, OxlintSpawnFailed, RULE_REGISTRY, agentReport, applyInlineDisables, audit, buildDoctorReport, checkBuildQuality, checkDeadCode, checkDeps, checkNuxtProject, dedupeDeadCodeAgainstLint, defineConfig, detectProject, docsUrl, encodeAnnotation, encodeAnnotations, format, isNuxtLayoutFile, isNuxtPageFile, isNuxtServerFile, jsonCompactReport, jsonReport, listChangedFiles, listRules, loadAllRuleDocs, loadDoctorConfig, loadRuleDoc, mergeCliOverrides, parseDirectives, prettyReport, renderVerboseTrace, runCrossFilePass, sarifReport, scoreDiagnostics, validateConfig };
|
|
522
3219
|
|
|
523
3220
|
//# sourceMappingURL=index.js.map
|