@geoql/doctor-core 0.1.0-alpha.0 → 0.2.0-alpha.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 +347 -11
- package/dist/index.js +2049 -143
- package/dist/index.js.map +1 -1
- package/dist/load-DiK7Zv0B.js +447 -0
- package/dist/load-DiK7Zv0B.js.map +1 -0
- package/package.json +13 -5
package/dist/index.js
CHANGED
|
@@ -1,13 +1,942 @@
|
|
|
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-DiK7Zv0B.js";
|
|
1
2
|
import { dirname, join, relative, resolve } from "node:path";
|
|
3
|
+
import { performance } from "node:perf_hooks";
|
|
4
|
+
import { access, mkdir, mkdtemp, readFile, rm, writeFile } from "node:fs/promises";
|
|
5
|
+
import { createRequire } from "node:module";
|
|
6
|
+
import { execFile, spawn } from "node:child_process";
|
|
7
|
+
import { existsSync, readFileSync } from "node:fs";
|
|
8
|
+
import { coerce, gte, major, minor } from "semver";
|
|
9
|
+
import { parseSync } from "oxc-parser";
|
|
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$6 = "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$6,
|
|
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$5 = "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$5,
|
|
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$4 = "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$4,
|
|
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$3 = "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$3,
|
|
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$2 = "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$2,
|
|
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$1 = "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(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(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$1,
|
|
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/disables/parse-directives.ts
|
|
587
|
+
const DIRECTIVE = /doctor-(disable-next-line|disable-line|disable|enable)\b(.*)/;
|
|
588
|
+
function parseRuleList(raw) {
|
|
589
|
+
return raw.replace(/-->\s*$/, "").split(",").map((token) => token.trim()).filter((token) => token.length > 0);
|
|
590
|
+
}
|
|
591
|
+
function parseDirectives(text) {
|
|
592
|
+
const blocks = [];
|
|
593
|
+
const nextLine = [];
|
|
594
|
+
const sameLine = [];
|
|
595
|
+
const lines = text.split("\n");
|
|
596
|
+
let open = null;
|
|
597
|
+
for (let index = 0; index < lines.length; index += 1) {
|
|
598
|
+
const match = DIRECTIVE.exec(lines[index]);
|
|
599
|
+
if (!match) continue;
|
|
600
|
+
const keyword = match[1];
|
|
601
|
+
const rules = parseRuleList(match[2]);
|
|
602
|
+
const lineNumber = index + 1;
|
|
603
|
+
if (keyword === "disable-next-line") nextLine.push({
|
|
604
|
+
line: lineNumber + 1,
|
|
605
|
+
rules
|
|
606
|
+
});
|
|
607
|
+
else if (keyword === "disable-line") sameLine.push({
|
|
608
|
+
line: lineNumber,
|
|
609
|
+
rules
|
|
610
|
+
});
|
|
611
|
+
else if (keyword === "disable") {
|
|
612
|
+
if (!open) open = {
|
|
613
|
+
start: lineNumber,
|
|
614
|
+
rules
|
|
615
|
+
};
|
|
616
|
+
} else if (open) {
|
|
617
|
+
blocks.push({
|
|
618
|
+
start: open.start,
|
|
619
|
+
end: lineNumber,
|
|
620
|
+
rules: open.rules
|
|
621
|
+
});
|
|
622
|
+
open = null;
|
|
623
|
+
}
|
|
624
|
+
}
|
|
625
|
+
if (open) blocks.push({
|
|
626
|
+
start: open.start,
|
|
627
|
+
end: lines.length,
|
|
628
|
+
rules: open.rules
|
|
629
|
+
});
|
|
630
|
+
return {
|
|
631
|
+
blocks,
|
|
632
|
+
nextLine,
|
|
633
|
+
sameLine
|
|
634
|
+
};
|
|
635
|
+
}
|
|
636
|
+
//#endregion
|
|
637
|
+
//#region src/disables/apply.ts
|
|
638
|
+
function ruleMatches(ruleId, rules) {
|
|
639
|
+
if (rules.length === 0) return true;
|
|
640
|
+
return rules.some((token) => ruleId === token || ruleId.endsWith(`/${token}`));
|
|
641
|
+
}
|
|
642
|
+
function isSuppressed(set, diagnostic) {
|
|
643
|
+
const { line, ruleId } = diagnostic;
|
|
644
|
+
for (const block of set.blocks) if (line >= block.start && line <= block.end && ruleMatches(ruleId, block.rules)) return true;
|
|
645
|
+
for (const target of set.nextLine) if (target.line === line && ruleMatches(ruleId, target.rules)) return true;
|
|
646
|
+
for (const target of set.sameLine) if (target.line === line && ruleMatches(ruleId, target.rules)) return true;
|
|
647
|
+
return false;
|
|
648
|
+
}
|
|
649
|
+
function loadDirectives(file, cache) {
|
|
650
|
+
const cached = cache.get(file);
|
|
651
|
+
if (cached !== void 0) return cached;
|
|
652
|
+
let set;
|
|
653
|
+
try {
|
|
654
|
+
set = parseDirectives(readFileSync(file, "utf-8"));
|
|
655
|
+
} catch {
|
|
656
|
+
set = null;
|
|
657
|
+
}
|
|
658
|
+
cache.set(file, set);
|
|
659
|
+
return set;
|
|
660
|
+
}
|
|
661
|
+
function applyInlineDisables(diags, opts) {
|
|
662
|
+
if (!opts.respect) return diags;
|
|
663
|
+
const cache = /* @__PURE__ */ new Map();
|
|
664
|
+
return diags.filter((diagnostic) => {
|
|
665
|
+
const set = loadDirectives(diagnostic.file, cache);
|
|
666
|
+
return set === null || !isSuppressed(set, diagnostic);
|
|
667
|
+
});
|
|
668
|
+
}
|
|
669
|
+
//#endregion
|
|
670
|
+
//#region src/code-snippet.ts
|
|
671
|
+
async function readLines(file) {
|
|
672
|
+
try {
|
|
673
|
+
return (await readFile(file, "utf-8")).split("\n");
|
|
674
|
+
} catch {
|
|
675
|
+
return [];
|
|
676
|
+
}
|
|
677
|
+
}
|
|
678
|
+
async function attachCodeSnippets(diagnostics) {
|
|
679
|
+
const cache = /* @__PURE__ */ new Map();
|
|
680
|
+
const out = [];
|
|
681
|
+
for (const d of diagnostics) {
|
|
682
|
+
let lines = cache.get(d.file);
|
|
683
|
+
if (lines === void 0) {
|
|
684
|
+
lines = await readLines(d.file);
|
|
685
|
+
cache.set(d.file, lines);
|
|
686
|
+
}
|
|
687
|
+
const raw = lines[d.line - 1];
|
|
688
|
+
if (raw === void 0) out.push(d);
|
|
689
|
+
else out.push({
|
|
690
|
+
...d,
|
|
691
|
+
codeSnippet: raw.trim()
|
|
692
|
+
});
|
|
693
|
+
}
|
|
694
|
+
return out;
|
|
695
|
+
}
|
|
696
|
+
//#endregion
|
|
697
|
+
//#region src/project-info/path-exists.ts
|
|
698
|
+
async function pathExists(target) {
|
|
699
|
+
try {
|
|
700
|
+
await access(target);
|
|
701
|
+
return true;
|
|
702
|
+
} catch {
|
|
703
|
+
return false;
|
|
704
|
+
}
|
|
705
|
+
}
|
|
706
|
+
//#endregion
|
|
707
|
+
//#region src/project-info/find-monorepo-root.ts
|
|
708
|
+
async function detectKind(dir) {
|
|
709
|
+
if (await pathExists(join(dir, "pnpm-workspace.yaml"))) return "pnpm";
|
|
710
|
+
if ((await readPackageJson(dir))?.workspaces) {
|
|
711
|
+
if (await pathExists(join(dir, "yarn.lock"))) return "yarn";
|
|
712
|
+
if (await pathExists(join(dir, "package-lock.json"))) return "npm";
|
|
713
|
+
}
|
|
714
|
+
if (await pathExists(join(dir, "turbo.json"))) return "turbo";
|
|
715
|
+
return null;
|
|
716
|
+
}
|
|
717
|
+
async function findMonorepoRoot(rootDirectory) {
|
|
718
|
+
let current = rootDirectory;
|
|
719
|
+
for (;;) {
|
|
720
|
+
const kind = await detectKind(current);
|
|
721
|
+
if (kind) return {
|
|
722
|
+
root: current,
|
|
723
|
+
kind
|
|
724
|
+
};
|
|
725
|
+
const parent = dirname(current);
|
|
726
|
+
if (parent === current) break;
|
|
727
|
+
current = parent;
|
|
728
|
+
}
|
|
729
|
+
return {
|
|
730
|
+
root: rootDirectory,
|
|
731
|
+
kind: null
|
|
732
|
+
};
|
|
733
|
+
}
|
|
734
|
+
//#endregion
|
|
735
|
+
//#region src/project-info/parse-nuxt-config.ts
|
|
736
|
+
const CONFIG_FILES = [
|
|
737
|
+
"nuxt.config.ts",
|
|
738
|
+
"nuxt.config.js",
|
|
739
|
+
"nuxt.config.mjs"
|
|
740
|
+
];
|
|
741
|
+
async function readConfigSource(dir) {
|
|
742
|
+
for (const name of CONFIG_FILES) try {
|
|
743
|
+
return await readFile(join(dir, name), "utf8");
|
|
744
|
+
} catch {
|
|
745
|
+
continue;
|
|
746
|
+
}
|
|
747
|
+
return null;
|
|
748
|
+
}
|
|
749
|
+
function* objectEntries(obj) {
|
|
750
|
+
for (const prop of obj.properties) {
|
|
751
|
+
if (prop.type !== "Property") continue;
|
|
752
|
+
const key = prop.key;
|
|
753
|
+
if (key.type !== "Identifier") continue;
|
|
754
|
+
yield [key.name, prop.value];
|
|
755
|
+
}
|
|
756
|
+
}
|
|
757
|
+
function findEntry(obj, name) {
|
|
758
|
+
for (const [key, value] of objectEntries(obj)) if (key === name) return value;
|
|
759
|
+
}
|
|
760
|
+
function stringLiteral(node) {
|
|
761
|
+
return node.type === "Literal" && typeof node.value === "string" ? node.value : void 0;
|
|
762
|
+
}
|
|
763
|
+
function unwrapDefault(decl) {
|
|
764
|
+
if (decl.type !== "CallExpression") return decl;
|
|
765
|
+
const callee = decl.callee;
|
|
766
|
+
if (callee.type !== "Identifier") return null;
|
|
767
|
+
if (callee.name !== "defineNuxtConfig") return null;
|
|
768
|
+
return decl.arguments[0] ?? null;
|
|
769
|
+
}
|
|
770
|
+
function extractConfigObject(source) {
|
|
771
|
+
const exported = parseSync("nuxt.config.ts", source, {
|
|
772
|
+
sourceType: "module",
|
|
773
|
+
lang: "ts"
|
|
774
|
+
}).program.body.find((n) => n.type === "ExportDefaultDeclaration");
|
|
775
|
+
if (!exported) return null;
|
|
776
|
+
const node = unwrapDefault(exported.declaration);
|
|
777
|
+
if (!node) return null;
|
|
778
|
+
return node.type === "ObjectExpression" ? node : null;
|
|
779
|
+
}
|
|
780
|
+
function readNitroPreset(value) {
|
|
781
|
+
if (value.type !== "ObjectExpression") return void 0;
|
|
782
|
+
const preset = findEntry(value, "preset");
|
|
783
|
+
return preset ? stringLiteral(preset) : void 0;
|
|
784
|
+
}
|
|
785
|
+
function readImportsAutoImport(value) {
|
|
786
|
+
if (value.type !== "ObjectExpression") return void 0;
|
|
787
|
+
const node = findEntry(value, "autoImport");
|
|
788
|
+
if (node?.type === "Literal" && typeof node.value === "boolean") return node.value;
|
|
789
|
+
}
|
|
790
|
+
function readModules(value) {
|
|
791
|
+
if (value.type !== "ArrayExpression") return void 0;
|
|
792
|
+
const out = [];
|
|
793
|
+
for (const element of value.elements) {
|
|
794
|
+
if (!element) continue;
|
|
795
|
+
const str = stringLiteral(element);
|
|
796
|
+
if (str !== void 0) out.push(str);
|
|
797
|
+
}
|
|
798
|
+
return out;
|
|
799
|
+
}
|
|
800
|
+
function readCompatibility(value) {
|
|
801
|
+
return value.type === "Literal" && typeof value.value === "number" ? value.value : void 0;
|
|
802
|
+
}
|
|
803
|
+
async function parseNuxtConfig(dir) {
|
|
804
|
+
const source = await readConfigSource(dir);
|
|
805
|
+
if (source === null) return null;
|
|
806
|
+
const info = {};
|
|
807
|
+
const config = extractConfigObject(source);
|
|
808
|
+
if (!config) return info;
|
|
809
|
+
for (const [key, value] of objectEntries(config)) if (key === "compatibilityVersion") {
|
|
810
|
+
const compat = readCompatibility(value);
|
|
811
|
+
if (compat !== void 0) info.compatibilityVersion = compat;
|
|
812
|
+
} else if (key === "nitro") {
|
|
813
|
+
const preset = readNitroPreset(value);
|
|
814
|
+
if (preset !== void 0) info.nitroPreset = preset;
|
|
815
|
+
} else if (key === "modules") {
|
|
816
|
+
const modules = readModules(value);
|
|
817
|
+
if (modules !== void 0) info.modules = modules;
|
|
818
|
+
} else if (key === "imports") {
|
|
819
|
+
const autoImport = readImportsAutoImport(value);
|
|
820
|
+
if (autoImport !== void 0) info.importsAutoImport = autoImport;
|
|
821
|
+
}
|
|
822
|
+
return info;
|
|
823
|
+
}
|
|
824
|
+
//#endregion
|
|
825
|
+
//#region src/project-info/resolve-dep-version.ts
|
|
826
|
+
async function readInstalledVersion(base, dep) {
|
|
827
|
+
try {
|
|
828
|
+
const source = await readFile(join(base, "node_modules", dep, "package.json"), "utf8");
|
|
829
|
+
return JSON.parse(source).version ?? null;
|
|
830
|
+
} catch {
|
|
831
|
+
return null;
|
|
832
|
+
}
|
|
833
|
+
}
|
|
834
|
+
function declaredRange(dep, pkg) {
|
|
835
|
+
return pkg?.dependencies?.[dep] ?? pkg?.devDependencies?.[dep] ?? null;
|
|
836
|
+
}
|
|
837
|
+
async function resolveDepVersion(dep, rootDirectory, monorepoRoot, pkg) {
|
|
838
|
+
const installed = await readInstalledVersion(rootDirectory, dep) ?? await readInstalledVersion(monorepoRoot, dep);
|
|
839
|
+
if (installed) return installed;
|
|
840
|
+
const range = declaredRange(dep, pkg);
|
|
841
|
+
if (!range) return null;
|
|
842
|
+
return coerce(range)?.version ?? null;
|
|
843
|
+
}
|
|
844
|
+
//#endregion
|
|
845
|
+
//#region src/project-info/parse-nuxt-version.ts
|
|
846
|
+
function parseNuxtVersion(rootDirectory, monorepoRoot, pkg) {
|
|
847
|
+
return resolveDepVersion("nuxt", rootDirectory, monorepoRoot, pkg);
|
|
848
|
+
}
|
|
849
|
+
//#endregion
|
|
850
|
+
//#region src/project-info/parse-vue-version.ts
|
|
851
|
+
function parseVueVersion(rootDirectory, monorepoRoot, pkg) {
|
|
852
|
+
return resolveDepVersion("vue", rootDirectory, monorepoRoot, pkg);
|
|
853
|
+
}
|
|
854
|
+
//#endregion
|
|
855
|
+
//#region src/detect-project.ts
|
|
856
|
+
function hasDependency(pkg, name) {
|
|
857
|
+
return Boolean(pkg?.dependencies?.[name] ?? pkg?.devDependencies?.[name]);
|
|
858
|
+
}
|
|
859
|
+
function resolveFramework(pkg) {
|
|
860
|
+
if (hasDependency(pkg, "nuxt")) return "nuxt";
|
|
861
|
+
if (hasDependency(pkg, "vue")) return "vue";
|
|
862
|
+
return "unknown";
|
|
863
|
+
}
|
|
864
|
+
function resolveCompatibility(nuxtVersion, compatibilityVersion) {
|
|
865
|
+
const nuxtMajor = nuxtVersion ? major(nuxtVersion) : 0;
|
|
866
|
+
if (compatibilityVersion === 4 || nuxtMajor >= 4) return 4;
|
|
867
|
+
if (compatibilityVersion === 3) return 3;
|
|
868
|
+
return null;
|
|
869
|
+
}
|
|
870
|
+
function buildCapabilities(input) {
|
|
871
|
+
const caps = /* @__PURE__ */ new Set();
|
|
872
|
+
if (input.framework === "unknown") return caps;
|
|
873
|
+
if (input.vueVersion && major(input.vueVersion) === 3) {
|
|
874
|
+
caps.add("vue:3");
|
|
875
|
+
if (minor(input.vueVersion) >= 4) caps.add("vue:3.4");
|
|
876
|
+
if (minor(input.vueVersion) >= 5) caps.add("vue:3.5");
|
|
877
|
+
}
|
|
878
|
+
if (input.nuxtCompatibilityVersion === 4) caps.add("nuxt:4");
|
|
879
|
+
if (input.nuxtVersion && gte(input.nuxtVersion, "4.4.0")) caps.add("nuxt:4.4");
|
|
880
|
+
if (input.hasAutoImports) caps.add("auto-imports:vue");
|
|
881
|
+
if (input.hasComponentsAutoImport) caps.add("components:auto");
|
|
882
|
+
if (input.hasPinia) caps.add("pinia");
|
|
883
|
+
if (input.hasVueRouter) caps.add("vue-router");
|
|
884
|
+
if (input.hasTypescript) caps.add("typescript");
|
|
885
|
+
if (input.typescriptVersion && major(input.typescriptVersion) >= 6) caps.add("typescript:6");
|
|
886
|
+
if (input.monorepoKind === "pnpm" || input.monorepoKind === "yarn" || input.monorepoKind === "npm") caps.add(`monorepo:${input.monorepoKind}`);
|
|
887
|
+
if (input.hasWrangler || input.nitroPreset === "cloudflare-pages") caps.add("cf-pages:enabled");
|
|
888
|
+
if (input.nitroPreset === "node-server") caps.add("nitro:node-server");
|
|
889
|
+
return caps;
|
|
890
|
+
}
|
|
891
|
+
async function detectProject(rootDirectory) {
|
|
892
|
+
const pkg = await readPackageJson(rootDirectory);
|
|
893
|
+
const packageJsonPath = pkg ? join(rootDirectory, "package.json") : null;
|
|
894
|
+
const { root: monorepoRoot, kind: monorepoKind } = await findMonorepoRoot(rootDirectory);
|
|
895
|
+
const framework = resolveFramework(pkg);
|
|
896
|
+
const vueVersion = await parseVueVersion(rootDirectory, monorepoRoot, pkg);
|
|
897
|
+
const nuxtVersion = await parseNuxtVersion(rootDirectory, monorepoRoot, pkg);
|
|
898
|
+
const typescriptVersion = await resolveDepVersion("typescript", rootDirectory, monorepoRoot, pkg);
|
|
899
|
+
const nuxtConfig = framework === "nuxt" ? await parseNuxtConfig(rootDirectory) : null;
|
|
900
|
+
const nitroPreset = nuxtConfig?.nitroPreset ?? null;
|
|
901
|
+
const nuxtCompatibilityVersion = resolveCompatibility(nuxtVersion, nuxtConfig?.compatibilityVersion);
|
|
902
|
+
const isNuxt = framework === "nuxt";
|
|
903
|
+
const hasAutoImports = isNuxt ? nuxtConfig?.importsAutoImport !== false : hasDependency(pkg, "unplugin-auto-import");
|
|
904
|
+
const hasComponentsAutoImport = isNuxt || hasDependency(pkg, "unplugin-vue-components");
|
|
905
|
+
const hasPinia = hasDependency(pkg, "pinia");
|
|
906
|
+
const hasVueRouter = hasDependency(pkg, "vue-router") && !isNuxt;
|
|
907
|
+
const tsconfigExists = await pathExists(join(rootDirectory, "tsconfig.json"));
|
|
908
|
+
return {
|
|
909
|
+
framework,
|
|
910
|
+
rootDirectory,
|
|
911
|
+
packageJsonPath,
|
|
912
|
+
vueVersion,
|
|
913
|
+
nuxtVersion,
|
|
914
|
+
typescriptVersion,
|
|
915
|
+
hasAutoImports,
|
|
916
|
+
hasComponentsAutoImport,
|
|
917
|
+
hasPinia,
|
|
918
|
+
hasVueRouter,
|
|
919
|
+
nitroPreset,
|
|
920
|
+
nuxtCompatibilityVersion,
|
|
921
|
+
monorepoKind,
|
|
922
|
+
capabilities: buildCapabilities({
|
|
923
|
+
framework,
|
|
924
|
+
vueVersion,
|
|
925
|
+
nuxtVersion,
|
|
926
|
+
typescriptVersion,
|
|
927
|
+
hasTypescript: hasDependency(pkg, "typescript") || tsconfigExists,
|
|
928
|
+
hasAutoImports,
|
|
929
|
+
hasComponentsAutoImport,
|
|
930
|
+
hasPinia,
|
|
931
|
+
hasVueRouter,
|
|
932
|
+
nitroPreset,
|
|
933
|
+
nuxtCompatibilityVersion,
|
|
934
|
+
monorepoKind,
|
|
935
|
+
hasWrangler: await pathExists(join(rootDirectory, "wrangler.toml")) || await pathExists(join(rootDirectory, "wrangler.jsonc"))
|
|
936
|
+
})
|
|
937
|
+
};
|
|
938
|
+
}
|
|
939
|
+
//#endregion
|
|
11
940
|
//#region src/file-scan.ts
|
|
12
941
|
async function listSourceFiles(opts) {
|
|
13
942
|
return (await glob(opts.include, {
|
|
@@ -39,7 +968,7 @@ function mergeDiagnostics(...batches) {
|
|
|
39
968
|
}
|
|
40
969
|
//#endregion
|
|
41
970
|
//#region src/oxlint/diagnostic.ts
|
|
42
|
-
const OXLINT_CODE_PATTERN = /^([a-z0-9_-]+)\(([a-z0-
|
|
971
|
+
const OXLINT_CODE_PATTERN = /^([a-z0-9_-]+)\(([a-z0-9/_-]+)\)$/i;
|
|
43
972
|
function normalizeOxlintRuleId(raw) {
|
|
44
973
|
if (raw.code) {
|
|
45
974
|
const match = OXLINT_CODE_PATTERN.exec(raw.code);
|
|
@@ -51,7 +980,7 @@ function normalizeOxlintRuleId(raw) {
|
|
|
51
980
|
}
|
|
52
981
|
function toCanonicalDiagnostic(raw, rootDir) {
|
|
53
982
|
const ruleId = normalizeOxlintRuleId(raw);
|
|
54
|
-
const severity = raw.severity === "warning" ? "
|
|
983
|
+
const severity = raw.severity === "warning" ? "warn" : "error";
|
|
55
984
|
const primary = raw.labels?.[0]?.span;
|
|
56
985
|
const line = primary?.line ?? raw.start_line ?? 1;
|
|
57
986
|
const column = primary?.column ?? raw.start_column ?? 1;
|
|
@@ -74,29 +1003,68 @@ function toCanonicalDiagnostics(raws, rootDir) {
|
|
|
74
1003
|
//#region src/oxlint/generate-config.ts
|
|
75
1004
|
const DEFAULT_RULES = {
|
|
76
1005
|
"vue/no-export-in-script-setup": "error",
|
|
77
|
-
"vue/require-typed-ref": "
|
|
78
|
-
"vue-doctor/no-em-dash-in-string": "
|
|
1006
|
+
"vue/require-typed-ref": "warn",
|
|
1007
|
+
"vue-doctor/no-em-dash-in-string": "warn",
|
|
1008
|
+
"vue-doctor/no-destructure-props-without-to-refs": "error",
|
|
1009
|
+
"vue-doctor/no-destructure-reactive-without-to-refs": "error",
|
|
1010
|
+
"vue-doctor/no-non-null-assertion-on-ref-value": "warn",
|
|
1011
|
+
"vue-doctor/no-imports-from-vue-when-auto-imported": "warn",
|
|
1012
|
+
"vue-doctor/reactivity/watch-without-cleanup": "warn",
|
|
1013
|
+
"vue-doctor/composition/prefer-script-setup-for-new-files": "warn",
|
|
1014
|
+
"vue-doctor/composition/defineProps-typed": "warn"
|
|
79
1015
|
};
|
|
80
1016
|
function toOxlintSeverity(s) {
|
|
81
|
-
if (s === "
|
|
82
|
-
return
|
|
1017
|
+
if (s === "error") return "error";
|
|
1018
|
+
return "warn";
|
|
1019
|
+
}
|
|
1020
|
+
async function resolveCacheDir(rootDir) {
|
|
1021
|
+
if (rootDir && existsSync(join(rootDir, "node_modules"))) {
|
|
1022
|
+
const dir = join(rootDir, "node_modules", ".cache", "doctor");
|
|
1023
|
+
await mkdir(dir, { recursive: true });
|
|
1024
|
+
return {
|
|
1025
|
+
dir,
|
|
1026
|
+
removeDir: false
|
|
1027
|
+
};
|
|
1028
|
+
}
|
|
1029
|
+
return {
|
|
1030
|
+
dir: await mkdtemp(join(tmpdir(), "geoql-doctor-")),
|
|
1031
|
+
removeDir: true
|
|
1032
|
+
};
|
|
1033
|
+
}
|
|
1034
|
+
function resolveUserConfig(rootDir) {
|
|
1035
|
+
if (!rootDir) return void 0;
|
|
1036
|
+
for (const name of [".oxlintrc.json", ".oxlintrc"]) {
|
|
1037
|
+
const candidate = join(rootDir, name);
|
|
1038
|
+
if (existsSync(candidate)) return candidate;
|
|
1039
|
+
}
|
|
83
1040
|
}
|
|
84
1041
|
async function generateOxlintConfig(input) {
|
|
85
|
-
const dir = await
|
|
1042
|
+
const { dir, removeDir } = await resolveCacheDir(input.rootDir);
|
|
86
1043
|
const merged = { ...DEFAULT_RULES };
|
|
87
1044
|
if (input.ruleOverrides) for (const [id, sev] of Object.entries(input.ruleOverrides)) if (sev === "off") delete merged[id];
|
|
88
1045
|
else merged[id] = sev;
|
|
89
1046
|
const rules = {};
|
|
90
1047
|
for (const [id, sev] of Object.entries(merged)) rules[id] = toOxlintSeverity(sev);
|
|
1048
|
+
const userConfig = resolveUserConfig(input.rootDir);
|
|
91
1049
|
const config = {
|
|
92
1050
|
$schema: "https://raw.githubusercontent.com/oxc-project/oxc/main/npm/oxlint/configuration_schema.json",
|
|
1051
|
+
...userConfig ? { extends: [userConfig] } : {},
|
|
93
1052
|
plugins: ["vue"],
|
|
94
1053
|
jsPlugins: [input.pluginPath],
|
|
95
1054
|
rules
|
|
96
1055
|
};
|
|
97
1056
|
const configPath = join(dir, ".oxlintrc.json");
|
|
98
1057
|
await writeFile(configPath, JSON.stringify(config, null, 2));
|
|
99
|
-
|
|
1058
|
+
const cleanup = async () => {
|
|
1059
|
+
await rm(removeDir ? dir : configPath, {
|
|
1060
|
+
recursive: true,
|
|
1061
|
+
force: true
|
|
1062
|
+
});
|
|
1063
|
+
};
|
|
1064
|
+
return {
|
|
1065
|
+
configPath,
|
|
1066
|
+
cleanup
|
|
1067
|
+
};
|
|
100
1068
|
}
|
|
101
1069
|
//#endregion
|
|
102
1070
|
//#region src/oxlint/resolve-plugin.ts
|
|
@@ -124,18 +1092,21 @@ function lookUpwards(fromDir, relPath) {
|
|
|
124
1092
|
current = parent;
|
|
125
1093
|
}
|
|
126
1094
|
}
|
|
1095
|
+
function resolveFromSelf(specifier) {
|
|
1096
|
+
try {
|
|
1097
|
+
return fileURLToPath(import.meta.resolve(specifier));
|
|
1098
|
+
} catch {
|
|
1099
|
+
return;
|
|
1100
|
+
}
|
|
1101
|
+
}
|
|
127
1102
|
function resolveVueDoctorPluginPath(fromDir) {
|
|
128
1103
|
const pkgJson = lookUpwards(fromDir, "node_modules/@geoql/oxlint-plugin-vue-doctor/package.json");
|
|
129
1104
|
if (pkgJson) {
|
|
130
1105
|
const main = readPkgMain(pkgJson) ?? "./dist/index.js";
|
|
131
1106
|
return resolve(dirname(pkgJson), main);
|
|
132
1107
|
}
|
|
133
|
-
|
|
134
|
-
|
|
135
|
-
return fileURLToPath(import.meta.resolve("@geoql/oxlint-plugin-vue-doctor", baseUrl));
|
|
136
|
-
} catch {
|
|
137
|
-
return throwResolveError(fromDir);
|
|
138
|
-
}
|
|
1108
|
+
const selfMain = resolveFromSelf("@geoql/oxlint-plugin-vue-doctor");
|
|
1109
|
+
if (selfMain) return selfMain;
|
|
139
1110
|
return throwResolveError(fromDir);
|
|
140
1111
|
}
|
|
141
1112
|
function throwResolveError(fromDir) {
|
|
@@ -144,11 +1115,32 @@ function throwResolveError(fromDir) {
|
|
|
144
1115
|
function resolveOxlintBin(fromDir) {
|
|
145
1116
|
const pkgJson = lookUpwards(fromDir, "node_modules/oxlint/package.json");
|
|
146
1117
|
if (pkgJson) return resolve(dirname(pkgJson), "bin/oxlint");
|
|
1118
|
+
const selfPkgJson = resolveFromSelf("oxlint/package.json");
|
|
1119
|
+
if (selfPkgJson) return resolve(dirname(selfPkgJson), "bin/oxlint");
|
|
147
1120
|
throw new Error(`Failed to resolve oxlint from ${fromDir}. Install oxlint as a dependency of your project.`);
|
|
148
1121
|
}
|
|
149
1122
|
//#endregion
|
|
150
|
-
//#region src/oxlint/
|
|
151
|
-
const
|
|
1123
|
+
//#region src/oxlint/errors.ts
|
|
1124
|
+
const STDERR_TAIL_LIMIT = 2e3;
|
|
1125
|
+
var OxlintSpawnFailed = class extends Error {
|
|
1126
|
+
name = "OxlintSpawnFailed";
|
|
1127
|
+
constructor(exitCode, stderr) {
|
|
1128
|
+
const tail = stderr.slice(-STDERR_TAIL_LIMIT);
|
|
1129
|
+
super(`oxlint subprocess exited with code ${exitCode}: ${tail}`);
|
|
1130
|
+
}
|
|
1131
|
+
};
|
|
1132
|
+
var OxlintOutputTooLarge = class extends Error {
|
|
1133
|
+
name = "OxlintOutputTooLarge";
|
|
1134
|
+
constructor(maxBytes) {
|
|
1135
|
+
super(`oxlint subprocess output exceeded ${maxBytes} bytes`);
|
|
1136
|
+
}
|
|
1137
|
+
};
|
|
1138
|
+
function sanitizedEnv() {
|
|
1139
|
+
const env = { ...process.env };
|
|
1140
|
+
delete env.NODE_OPTIONS;
|
|
1141
|
+
for (const key of Object.keys(env)) if (key.startsWith("npm_config_")) delete env[key];
|
|
1142
|
+
return env;
|
|
1143
|
+
}
|
|
152
1144
|
async function runOxlint(opts) {
|
|
153
1145
|
return new Promise((resolve, reject) => {
|
|
154
1146
|
const args = [
|
|
@@ -164,30 +1156,53 @@ async function runOxlint(opts) {
|
|
|
164
1156
|
"ignore",
|
|
165
1157
|
"pipe",
|
|
166
1158
|
"pipe"
|
|
167
|
-
]
|
|
1159
|
+
],
|
|
1160
|
+
env: sanitizedEnv()
|
|
168
1161
|
});
|
|
169
1162
|
const stdoutChunks = [];
|
|
170
1163
|
const stderrChunks = [];
|
|
1164
|
+
let stdoutBytes = 0;
|
|
1165
|
+
let settled = false;
|
|
171
1166
|
let timer;
|
|
172
|
-
const timeoutMs = opts.timeoutMs ??
|
|
1167
|
+
const timeoutMs = opts.timeoutMs ?? 6e4;
|
|
1168
|
+
const maxOutputBytes = opts.maxOutputBytes ?? 33554432;
|
|
1169
|
+
const finish = (fn) => {
|
|
1170
|
+
if (settled) return;
|
|
1171
|
+
settled = true;
|
|
1172
|
+
if (timer) clearTimeout(timer);
|
|
1173
|
+
fn();
|
|
1174
|
+
};
|
|
173
1175
|
if (timeoutMs > 0) timer = setTimeout(() => {
|
|
174
1176
|
child.kill("SIGKILL");
|
|
175
|
-
reject(/* @__PURE__ */ new Error(`oxlint subprocess timed out after ${timeoutMs}ms`));
|
|
1177
|
+
finish(() => reject(/* @__PURE__ */ new Error(`oxlint subprocess timed out after ${timeoutMs}ms`)));
|
|
176
1178
|
}, timeoutMs);
|
|
177
|
-
child.stdout.on("data", (chunk) =>
|
|
1179
|
+
child.stdout.on("data", (chunk) => {
|
|
1180
|
+
stdoutBytes += chunk.length;
|
|
1181
|
+
if (stdoutBytes > maxOutputBytes) {
|
|
1182
|
+
child.kill("SIGKILL");
|
|
1183
|
+
finish(() => reject(new OxlintOutputTooLarge(maxOutputBytes)));
|
|
1184
|
+
return;
|
|
1185
|
+
}
|
|
1186
|
+
stdoutChunks.push(chunk);
|
|
1187
|
+
});
|
|
178
1188
|
child.stderr.on("data", (chunk) => stderrChunks.push(chunk));
|
|
179
1189
|
child.on("error", (err) => {
|
|
180
|
-
|
|
181
|
-
reject(err);
|
|
1190
|
+
finish(() => reject(err));
|
|
182
1191
|
});
|
|
183
1192
|
child.on("close", (exitCode) => {
|
|
184
|
-
|
|
185
|
-
|
|
186
|
-
|
|
187
|
-
|
|
188
|
-
diagnostics
|
|
189
|
-
|
|
190
|
-
|
|
1193
|
+
finish(() => {
|
|
1194
|
+
const stdout = Buffer.concat(stdoutChunks).toString("utf8");
|
|
1195
|
+
const stderr = Buffer.concat(stderrChunks).toString("utf8");
|
|
1196
|
+
const diagnostics = parseOxlintJsonStream(stdout);
|
|
1197
|
+
if (exitCode !== 0 && exitCode !== null && diagnostics.length === 0) {
|
|
1198
|
+
reject(new OxlintSpawnFailed(exitCode, stderr));
|
|
1199
|
+
return;
|
|
1200
|
+
}
|
|
1201
|
+
resolve({
|
|
1202
|
+
diagnostics,
|
|
1203
|
+
stderr,
|
|
1204
|
+
exitCode
|
|
1205
|
+
});
|
|
191
1206
|
});
|
|
192
1207
|
});
|
|
193
1208
|
});
|
|
@@ -221,40 +1236,228 @@ function parseNdjson(text) {
|
|
|
221
1236
|
async function runScriptPass(opts) {
|
|
222
1237
|
const pluginPath = resolveVueDoctorPluginPath(opts.rootDir);
|
|
223
1238
|
const oxlintBin = resolveOxlintBin(opts.rootDir);
|
|
224
|
-
const configPath = await generateOxlintConfig({
|
|
1239
|
+
const { configPath, cleanup } = await generateOxlintConfig({
|
|
225
1240
|
pluginPath,
|
|
226
|
-
ruleOverrides: opts.ruleOverrides
|
|
1241
|
+
ruleOverrides: opts.ruleOverrides,
|
|
1242
|
+
rootDir: opts.rootDir
|
|
227
1243
|
});
|
|
228
|
-
|
|
229
|
-
|
|
230
|
-
|
|
231
|
-
|
|
232
|
-
|
|
233
|
-
|
|
234
|
-
|
|
235
|
-
|
|
236
|
-
|
|
237
|
-
|
|
238
|
-
|
|
239
|
-
|
|
1244
|
+
try {
|
|
1245
|
+
const raw = await runOxlint({
|
|
1246
|
+
rootDir: opts.rootDir,
|
|
1247
|
+
targetPath: opts.targetPath,
|
|
1248
|
+
configPath,
|
|
1249
|
+
oxlintBin,
|
|
1250
|
+
timeoutMs: opts.timeoutMs
|
|
1251
|
+
});
|
|
1252
|
+
return {
|
|
1253
|
+
diagnostics: toCanonicalDiagnostics(raw.diagnostics, opts.rootDir),
|
|
1254
|
+
stderr: raw.stderr,
|
|
1255
|
+
exitCode: raw.exitCode
|
|
1256
|
+
};
|
|
1257
|
+
} finally {
|
|
1258
|
+
await cleanup();
|
|
1259
|
+
}
|
|
240
1260
|
}
|
|
241
1261
|
//#endregion
|
|
242
1262
|
//#region src/score.ts
|
|
243
|
-
const
|
|
244
|
-
|
|
245
|
-
|
|
1263
|
+
const SEVERITY_WEIGHTS = {
|
|
1264
|
+
error: 5,
|
|
1265
|
+
warn: 2,
|
|
1266
|
+
info: .5
|
|
1267
|
+
};
|
|
1268
|
+
function scoreDiagnostics(diagnostics, config) {
|
|
1269
|
+
const threshold = config?.threshold ?? 0;
|
|
246
1270
|
let errorCount = 0;
|
|
247
|
-
let
|
|
248
|
-
|
|
249
|
-
|
|
250
|
-
const
|
|
1271
|
+
let warnCount = 0;
|
|
1272
|
+
let infoCount = 0;
|
|
1273
|
+
const byRule = /* @__PURE__ */ new Map();
|
|
1274
|
+
for (const d of diagnostics) {
|
|
1275
|
+
if (d.severity === "error") errorCount += 1;
|
|
1276
|
+
else if (d.severity === "warn") warnCount += 1;
|
|
1277
|
+
else infoCount += 1;
|
|
1278
|
+
const list = byRule.get(d.ruleId);
|
|
1279
|
+
if (list) list.push(d);
|
|
1280
|
+
else byRule.set(d.ruleId, [d]);
|
|
1281
|
+
}
|
|
1282
|
+
const sortedRuleIds = [...byRule.keys()].sort();
|
|
1283
|
+
const breakdown = [];
|
|
1284
|
+
let penalty = 0;
|
|
1285
|
+
for (const ruleId of sortedRuleIds) {
|
|
1286
|
+
const list = byRule.get(ruleId);
|
|
1287
|
+
const weight = config?.rules?.[ruleId]?.weight ?? SEVERITY_WEIGHTS[list[0].severity];
|
|
1288
|
+
let rulePenalty = 0;
|
|
1289
|
+
for (let i = 0; i < list.length; i++) rulePenalty += weight * (i === 0 ? 1 : 1 / Math.sqrt(i + 1));
|
|
1290
|
+
penalty += rulePenalty;
|
|
1291
|
+
breakdown.push({
|
|
1292
|
+
ruleId,
|
|
1293
|
+
occurrences: list.length,
|
|
1294
|
+
weightPerOccurrence: weight,
|
|
1295
|
+
penalty: rulePenalty
|
|
1296
|
+
});
|
|
1297
|
+
}
|
|
1298
|
+
breakdown.sort((a, b) => b.penalty - a.penalty);
|
|
1299
|
+
const score = Math.max(0, Math.round(100 - penalty));
|
|
251
1300
|
return {
|
|
252
|
-
score
|
|
1301
|
+
score,
|
|
1302
|
+
passed: score >= threshold,
|
|
1303
|
+
threshold,
|
|
1304
|
+
totalFindings: diagnostics.length,
|
|
253
1305
|
errorCount,
|
|
254
|
-
|
|
1306
|
+
warnCount,
|
|
1307
|
+
infoCount,
|
|
1308
|
+
breakdown
|
|
255
1309
|
};
|
|
256
1310
|
}
|
|
257
1311
|
//#endregion
|
|
1312
|
+
//#region src/sfc/parse-sfc-descriptor.ts
|
|
1313
|
+
const cache$1 = /* @__PURE__ */ new Map();
|
|
1314
|
+
async function parseSfcDescriptor(absPath) {
|
|
1315
|
+
if (cache$1.has(absPath)) return cache$1.get(absPath) ?? null;
|
|
1316
|
+
let source;
|
|
1317
|
+
try {
|
|
1318
|
+
source = await readFile(absPath, "utf8");
|
|
1319
|
+
} catch {
|
|
1320
|
+
cache$1.set(absPath, null);
|
|
1321
|
+
return null;
|
|
1322
|
+
}
|
|
1323
|
+
const { descriptor, errors } = parse(source, { filename: absPath });
|
|
1324
|
+
if (errors.length > 0) {
|
|
1325
|
+
cache$1.set(absPath, null);
|
|
1326
|
+
return null;
|
|
1327
|
+
}
|
|
1328
|
+
cache$1.set(absPath, descriptor);
|
|
1329
|
+
return descriptor;
|
|
1330
|
+
}
|
|
1331
|
+
//#endregion
|
|
1332
|
+
//#region src/sfc/rules/no-mixed-options-and-composition-api.ts
|
|
1333
|
+
const RULE_ID = "vue-doctor/sfc/no-mixed-options-and-composition-api";
|
|
1334
|
+
const MESSAGE = "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";
|
|
1335
|
+
const RECOMMENDATION = "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.";
|
|
1336
|
+
const DISALLOWED = new Set([
|
|
1337
|
+
"data",
|
|
1338
|
+
"methods",
|
|
1339
|
+
"computed",
|
|
1340
|
+
"watch",
|
|
1341
|
+
"props",
|
|
1342
|
+
"emits",
|
|
1343
|
+
"provide",
|
|
1344
|
+
"inject",
|
|
1345
|
+
"beforeCreate",
|
|
1346
|
+
"created",
|
|
1347
|
+
"beforeMount",
|
|
1348
|
+
"mounted",
|
|
1349
|
+
"beforeUpdate",
|
|
1350
|
+
"updated",
|
|
1351
|
+
"beforeUnmount",
|
|
1352
|
+
"unmounted",
|
|
1353
|
+
"activated",
|
|
1354
|
+
"deactivated",
|
|
1355
|
+
"errorCaptured",
|
|
1356
|
+
"serverPrefetch"
|
|
1357
|
+
]);
|
|
1358
|
+
function resolveDefineComponentCall(call) {
|
|
1359
|
+
if (call.callee.type !== "Identifier") return null;
|
|
1360
|
+
if (call.callee.name !== "defineComponent") return null;
|
|
1361
|
+
const first = call.arguments[0];
|
|
1362
|
+
if (!first || first.type !== "ObjectExpression") return null;
|
|
1363
|
+
return first;
|
|
1364
|
+
}
|
|
1365
|
+
function findBindingInit(body, name) {
|
|
1366
|
+
for (const stmt of body) {
|
|
1367
|
+
if (stmt.type !== "VariableDeclaration") continue;
|
|
1368
|
+
for (const declarator of stmt.declarations) {
|
|
1369
|
+
if (declarator.id.type !== "Identifier") continue;
|
|
1370
|
+
if (declarator.id.name !== name) continue;
|
|
1371
|
+
return declarator.init;
|
|
1372
|
+
}
|
|
1373
|
+
}
|
|
1374
|
+
return null;
|
|
1375
|
+
}
|
|
1376
|
+
function resolveOptionsObject(program) {
|
|
1377
|
+
const exported = program.body.find((node) => node.type === "ExportDefaultDeclaration");
|
|
1378
|
+
if (!exported) return null;
|
|
1379
|
+
const declaration = exported.declaration;
|
|
1380
|
+
if (declaration.type === "ObjectExpression") return declaration;
|
|
1381
|
+
if (declaration.type === "CallExpression") return resolveDefineComponentCall(declaration);
|
|
1382
|
+
if (declaration.type === "Identifier") {
|
|
1383
|
+
const init = findBindingInit(program.body, declaration.name);
|
|
1384
|
+
if (init && init.type === "CallExpression") return resolveDefineComponentCall(init);
|
|
1385
|
+
return null;
|
|
1386
|
+
}
|
|
1387
|
+
return null;
|
|
1388
|
+
}
|
|
1389
|
+
function locate(content, offset, startLine, startColumn) {
|
|
1390
|
+
let line = startLine;
|
|
1391
|
+
let lastNewline = -1;
|
|
1392
|
+
for (let i = 0; i < offset; i += 1) if (content.charCodeAt(i) === 10) {
|
|
1393
|
+
line += 1;
|
|
1394
|
+
lastNewline = i;
|
|
1395
|
+
}
|
|
1396
|
+
const column = lastNewline === -1 ? startColumn + offset : offset - lastNewline;
|
|
1397
|
+
return {
|
|
1398
|
+
line,
|
|
1399
|
+
column
|
|
1400
|
+
};
|
|
1401
|
+
}
|
|
1402
|
+
function check$6(ctx) {
|
|
1403
|
+
const { script, scriptSetup } = ctx.descriptor;
|
|
1404
|
+
if (!script || !scriptSetup) return { diagnostics: [] };
|
|
1405
|
+
const lang = script.lang === "ts" ? "ts" : "js";
|
|
1406
|
+
const { program } = parseSync(`script.${lang}`, script.content, {
|
|
1407
|
+
sourceType: "module",
|
|
1408
|
+
lang
|
|
1409
|
+
});
|
|
1410
|
+
const options = resolveOptionsObject(program);
|
|
1411
|
+
if (!options) return { diagnostics: [] };
|
|
1412
|
+
const diagnostics = [];
|
|
1413
|
+
for (const property of options.properties) {
|
|
1414
|
+
if (property.type !== "Property") continue;
|
|
1415
|
+
if (property.key.type !== "Identifier") continue;
|
|
1416
|
+
if (!DISALLOWED.has(property.key.name)) continue;
|
|
1417
|
+
const { line, column } = locate(script.content, property.start, script.loc.start.line, script.loc.start.column);
|
|
1418
|
+
diagnostics.push({
|
|
1419
|
+
file: ctx.file,
|
|
1420
|
+
line,
|
|
1421
|
+
column,
|
|
1422
|
+
ruleId: RULE_ID,
|
|
1423
|
+
severity: "error",
|
|
1424
|
+
message: MESSAGE,
|
|
1425
|
+
source: "sfc",
|
|
1426
|
+
recommendation: RECOMMENDATION
|
|
1427
|
+
});
|
|
1428
|
+
}
|
|
1429
|
+
return { diagnostics };
|
|
1430
|
+
}
|
|
1431
|
+
//#endregion
|
|
1432
|
+
//#region src/sfc/rules/index.ts
|
|
1433
|
+
const SFC_RULES = [{
|
|
1434
|
+
id: "vue-doctor/sfc/no-mixed-options-and-composition-api",
|
|
1435
|
+
check: check$6
|
|
1436
|
+
}];
|
|
1437
|
+
//#endregion
|
|
1438
|
+
//#region src/sfc/run.ts
|
|
1439
|
+
async function runSfcPass(opts) {
|
|
1440
|
+
const all = [];
|
|
1441
|
+
for (const file of opts.files) {
|
|
1442
|
+
if (!file.endsWith(".vue")) continue;
|
|
1443
|
+
const descriptor = await parseSfcDescriptor(file);
|
|
1444
|
+
if (!descriptor) continue;
|
|
1445
|
+
for (const rule of SFC_RULES) {
|
|
1446
|
+
const override = opts.ruleOverrides?.[rule.id];
|
|
1447
|
+
if (override === "off") continue;
|
|
1448
|
+
const { diagnostics } = rule.check({
|
|
1449
|
+
file,
|
|
1450
|
+
descriptor
|
|
1451
|
+
});
|
|
1452
|
+
for (const d of diagnostics) all.push(override ? {
|
|
1453
|
+
...d,
|
|
1454
|
+
severity: override
|
|
1455
|
+
} : d);
|
|
1456
|
+
}
|
|
1457
|
+
}
|
|
1458
|
+
return all;
|
|
1459
|
+
}
|
|
1460
|
+
//#endregion
|
|
258
1461
|
//#region src/template/parse-sfc.ts
|
|
259
1462
|
const cache = /* @__PURE__ */ new Map();
|
|
260
1463
|
async function parseSfc(absPath) {
|
|
@@ -276,13 +1479,13 @@ async function parseSfc(absPath) {
|
|
|
276
1479
|
}
|
|
277
1480
|
//#endregion
|
|
278
1481
|
//#region src/template/directive-helpers.ts
|
|
279
|
-
const NODE_DIRECTIVE = 7;
|
|
1482
|
+
const NODE_DIRECTIVE$3 = 7;
|
|
280
1483
|
function findDirective(el, name) {
|
|
281
|
-
for (const prop of el.props) if (prop.type === NODE_DIRECTIVE && prop.name === name) return prop;
|
|
1484
|
+
for (const prop of el.props) if (prop.type === NODE_DIRECTIVE$3 && prop.name === name) return prop;
|
|
282
1485
|
}
|
|
283
1486
|
function findBindAttr(el, attrName) {
|
|
284
1487
|
for (const prop of el.props) {
|
|
285
|
-
if (prop.type !== NODE_DIRECTIVE) continue;
|
|
1488
|
+
if (prop.type !== NODE_DIRECTIVE$3) continue;
|
|
286
1489
|
const dir = prop;
|
|
287
1490
|
if (dir.name !== "bind") continue;
|
|
288
1491
|
if (dir.arg?.content === attrName) return dir;
|
|
@@ -293,9 +1496,9 @@ function findStaticAttr(el, attrName) {
|
|
|
293
1496
|
}
|
|
294
1497
|
//#endregion
|
|
295
1498
|
//#region src/template/walk.ts
|
|
296
|
-
const NODE_ELEMENT = 1;
|
|
1499
|
+
const NODE_ELEMENT$2 = 1;
|
|
297
1500
|
function isElementNode(node) {
|
|
298
|
-
return node !== null && node !== void 0 && node.type === NODE_ELEMENT;
|
|
1501
|
+
return node !== null && node !== void 0 && node.type === NODE_ELEMENT$2;
|
|
299
1502
|
}
|
|
300
1503
|
function walkElements(root, visit) {
|
|
301
1504
|
const stack = [...root.children];
|
|
@@ -310,7 +1513,7 @@ function walkElements(root, visit) {
|
|
|
310
1513
|
}
|
|
311
1514
|
//#endregion
|
|
312
1515
|
//#region src/template/rules/v-for-has-key.ts
|
|
313
|
-
function check$
|
|
1516
|
+
function check$5(ctx) {
|
|
314
1517
|
const diagnostics = [];
|
|
315
1518
|
walkElements(ctx.template, (el) => {
|
|
316
1519
|
if (!findDirective(el, "for")) return;
|
|
@@ -332,7 +1535,7 @@ function check$1(ctx) {
|
|
|
332
1535
|
}
|
|
333
1536
|
//#endregion
|
|
334
1537
|
//#region src/template/rules/v-if-v-for-precedence.ts
|
|
335
|
-
function check(ctx) {
|
|
1538
|
+
function check$4(ctx) {
|
|
336
1539
|
const diagnostics = [];
|
|
337
1540
|
walkElements(ctx.template, (el) => {
|
|
338
1541
|
const vIf = findDirective(el, "if");
|
|
@@ -354,14 +1557,221 @@ function check(ctx) {
|
|
|
354
1557
|
return { diagnostics };
|
|
355
1558
|
}
|
|
356
1559
|
//#endregion
|
|
1560
|
+
//#region src/template/rules/v-memo-on-large-list.ts
|
|
1561
|
+
const ARRAY_LITERAL_THRESHOLD = 100;
|
|
1562
|
+
function findLargeArrayBindings(script) {
|
|
1563
|
+
const bindings = /* @__PURE__ */ new Map();
|
|
1564
|
+
try {
|
|
1565
|
+
const ast = babelParse(script, { sourceType: "module" });
|
|
1566
|
+
for (const node of ast.program.body) {
|
|
1567
|
+
if (node.type !== "VariableDeclaration") continue;
|
|
1568
|
+
if (node.kind !== "const" && node.kind !== "let") continue;
|
|
1569
|
+
for (const decl of node.declarations) {
|
|
1570
|
+
if (decl.id.type !== "Identifier") continue;
|
|
1571
|
+
const init = decl.init;
|
|
1572
|
+
if (!init || init.type !== "ArrayExpression") continue;
|
|
1573
|
+
if (!init.elements || init.elements.length <= ARRAY_LITERAL_THRESHOLD) continue;
|
|
1574
|
+
bindings.set(decl.id.name, init.elements.length);
|
|
1575
|
+
}
|
|
1576
|
+
}
|
|
1577
|
+
} catch {}
|
|
1578
|
+
return bindings;
|
|
1579
|
+
}
|
|
1580
|
+
function check$3(ctx) {
|
|
1581
|
+
const diagnostics = [];
|
|
1582
|
+
const largeArrays = ctx.script && ctx.script.length > 0 ? findLargeArrayBindings(ctx.script) : /* @__PURE__ */ new Map();
|
|
1583
|
+
walkElements(ctx.template, (el) => {
|
|
1584
|
+
const vFor = findDirective(el, "for");
|
|
1585
|
+
if (!vFor) return;
|
|
1586
|
+
if (findDirective(el, "memo")) return;
|
|
1587
|
+
const source = vFor.forParseResult?.source;
|
|
1588
|
+
if (!source) return;
|
|
1589
|
+
const sourceName = source.content;
|
|
1590
|
+
const largeArraySize = largeArrays.get(sourceName);
|
|
1591
|
+
if (largeArraySize !== void 0 && largeArraySize > ARRAY_LITERAL_THRESHOLD) diagnostics.push({
|
|
1592
|
+
file: ctx.file,
|
|
1593
|
+
line: el.loc.start.line,
|
|
1594
|
+
column: el.loc.start.column,
|
|
1595
|
+
endLine: el.loc.end.line,
|
|
1596
|
+
endColumn: el.loc.end.column,
|
|
1597
|
+
ruleId: "vue-doctor/template/v-memo-on-large-list",
|
|
1598
|
+
severity: "warn",
|
|
1599
|
+
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.`,
|
|
1600
|
+
source: "template",
|
|
1601
|
+
recommendation: "Add v-memo with a meaningful memoization key so Vue can skip re-rendering unchanged items."
|
|
1602
|
+
});
|
|
1603
|
+
});
|
|
1604
|
+
return { diagnostics };
|
|
1605
|
+
}
|
|
1606
|
+
//#endregion
|
|
1607
|
+
//#region src/template/rules/no-inline-object-prop-in-list.ts
|
|
1608
|
+
const NODE_DIRECTIVE$2 = 7;
|
|
1609
|
+
function checkElement$2(el, inVForSubtree, ctx, diagnostics) {
|
|
1610
|
+
const isVFor = el.props.some((p) => p.type === NODE_DIRECTIVE$2 && p.name === "for");
|
|
1611
|
+
const effectiveInVFor = inVForSubtree || isVFor;
|
|
1612
|
+
if (effectiveInVFor) for (const prop of el.props) {
|
|
1613
|
+
if (prop.type !== NODE_DIRECTIVE$2) continue;
|
|
1614
|
+
const dir = prop;
|
|
1615
|
+
if (dir.name !== "bind") continue;
|
|
1616
|
+
const attrName = dir.arg?.content;
|
|
1617
|
+
if (!attrName || attrName === "key") continue;
|
|
1618
|
+
const astType = dir.exp?.ast?.type;
|
|
1619
|
+
if (astType !== "ObjectExpression" && astType !== "ArrayExpression") continue;
|
|
1620
|
+
diagnostics.push({
|
|
1621
|
+
file: ctx.file,
|
|
1622
|
+
line: dir.loc.start.line,
|
|
1623
|
+
column: dir.loc.start.column,
|
|
1624
|
+
endLine: dir.loc.end.line,
|
|
1625
|
+
endColumn: dir.loc.end.column,
|
|
1626
|
+
ruleId: "vue-doctor/template/no-inline-object-prop-in-list",
|
|
1627
|
+
severity: "warn",
|
|
1628
|
+
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.`,
|
|
1629
|
+
source: "template",
|
|
1630
|
+
recommendation: "Move the object or array to a computed property or a module-level constant so the reference remains stable across renders."
|
|
1631
|
+
});
|
|
1632
|
+
}
|
|
1633
|
+
for (const child of el.children) if (child.type === 1) checkElement$2(child, effectiveInVFor, ctx, diagnostics);
|
|
1634
|
+
}
|
|
1635
|
+
function check$2(ctx) {
|
|
1636
|
+
const diagnostics = [];
|
|
1637
|
+
function walk(node, inVForSubtree) {
|
|
1638
|
+
if (node.type === 1) checkElement$2(node, inVForSubtree, ctx, diagnostics);
|
|
1639
|
+
else if (node.type === 0) {
|
|
1640
|
+
const root = node;
|
|
1641
|
+
for (const child of root.children) walk(child, inVForSubtree);
|
|
1642
|
+
}
|
|
1643
|
+
}
|
|
1644
|
+
walk(ctx.template, false);
|
|
1645
|
+
return { diagnostics };
|
|
1646
|
+
}
|
|
1647
|
+
//#endregion
|
|
1648
|
+
//#region src/template/rules/no-computed-getter-in-template-loop.ts
|
|
1649
|
+
const NODE_ELEMENT$1 = 1;
|
|
1650
|
+
const NODE_INTERPOLATION = 5;
|
|
1651
|
+
const NODE_DIRECTIVE$1 = 7;
|
|
1652
|
+
function isValueDeref(node) {
|
|
1653
|
+
if (node.type !== "MemberExpression" || node.computed === true) return false;
|
|
1654
|
+
const { object, property } = node;
|
|
1655
|
+
return object.type === "Identifier" && property.name === "value";
|
|
1656
|
+
}
|
|
1657
|
+
function containsValueDeref(value) {
|
|
1658
|
+
if (value === null || typeof value !== "object") return false;
|
|
1659
|
+
if (Array.isArray(value)) return value.some((item) => containsValueDeref(item));
|
|
1660
|
+
const node = value;
|
|
1661
|
+
return isValueDeref(node) || Object.values(node).some((child) => containsValueDeref(child));
|
|
1662
|
+
}
|
|
1663
|
+
function pushDiagnostic(el, loc, ctx, diagnostics) {
|
|
1664
|
+
diagnostics.push({
|
|
1665
|
+
file: ctx.file,
|
|
1666
|
+
line: loc.start.line,
|
|
1667
|
+
column: loc.start.column,
|
|
1668
|
+
endLine: loc.end.line,
|
|
1669
|
+
endColumn: loc.end.column,
|
|
1670
|
+
ruleId: "vue-doctor/template/no-computed-getter-in-template-loop",
|
|
1671
|
+
severity: "warn",
|
|
1672
|
+
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.`,
|
|
1673
|
+
source: "template",
|
|
1674
|
+
recommendation: "Read .value once into a computed property or a local binding outside the loop so the getter runs a single time per render."
|
|
1675
|
+
});
|
|
1676
|
+
}
|
|
1677
|
+
function checkElement$1(el, inVForSubtree, ctx, diagnostics) {
|
|
1678
|
+
const isVFor = el.props.some((p) => p.type === NODE_DIRECTIVE$1 && p.name === "for");
|
|
1679
|
+
const effectiveInVFor = inVForSubtree || isVFor;
|
|
1680
|
+
if (effectiveInVFor) {
|
|
1681
|
+
for (const prop of el.props) {
|
|
1682
|
+
if (prop.type !== NODE_DIRECTIVE$1) continue;
|
|
1683
|
+
const dir = prop;
|
|
1684
|
+
if (dir.name !== "bind") continue;
|
|
1685
|
+
if (containsValueDeref(dir.exp?.ast)) pushDiagnostic(el, dir.loc, ctx, diagnostics);
|
|
1686
|
+
}
|
|
1687
|
+
for (const child of el.children) if (child.type === NODE_INTERPOLATION) {
|
|
1688
|
+
const interp = child;
|
|
1689
|
+
if (containsValueDeref(interp.content.ast)) pushDiagnostic(el, interp.loc, ctx, diagnostics);
|
|
1690
|
+
}
|
|
1691
|
+
}
|
|
1692
|
+
for (const child of el.children) if (child.type === NODE_ELEMENT$1) checkElement$1(child, effectiveInVFor, ctx, diagnostics);
|
|
1693
|
+
}
|
|
1694
|
+
function check$1(ctx) {
|
|
1695
|
+
const diagnostics = [];
|
|
1696
|
+
function walk(node, inVForSubtree) {
|
|
1697
|
+
if (node.type === NODE_ELEMENT$1) checkElement$1(node, inVForSubtree, ctx, diagnostics);
|
|
1698
|
+
else if (node.type === 0) {
|
|
1699
|
+
const root = node;
|
|
1700
|
+
for (const child of root.children) walk(child, inVForSubtree);
|
|
1701
|
+
}
|
|
1702
|
+
}
|
|
1703
|
+
walk(ctx.template, false);
|
|
1704
|
+
return { diagnostics };
|
|
1705
|
+
}
|
|
1706
|
+
//#endregion
|
|
1707
|
+
//#region src/template/rules/avoid-deep-v-bind-spread-in-list.ts
|
|
1708
|
+
const NODE_ELEMENT = 1;
|
|
1709
|
+
const NODE_DIRECTIVE = 7;
|
|
1710
|
+
function isIdentifierSpread(dir) {
|
|
1711
|
+
return dir.name === "bind" && dir.arg === void 0 && dir.exp?.ast === null;
|
|
1712
|
+
}
|
|
1713
|
+
function checkElement(el, inVForSubtree, ctx, diagnostics) {
|
|
1714
|
+
const isVFor = el.props.some((p) => p.type === NODE_DIRECTIVE && p.name === "for");
|
|
1715
|
+
const effectiveInVFor = inVForSubtree || isVFor;
|
|
1716
|
+
if (effectiveInVFor) for (const prop of el.props) {
|
|
1717
|
+
if (prop.type !== NODE_DIRECTIVE) continue;
|
|
1718
|
+
const dir = prop;
|
|
1719
|
+
if (!isIdentifierSpread(dir)) continue;
|
|
1720
|
+
diagnostics.push({
|
|
1721
|
+
file: ctx.file,
|
|
1722
|
+
line: dir.loc.start.line,
|
|
1723
|
+
column: dir.loc.start.column,
|
|
1724
|
+
endLine: dir.loc.end.line,
|
|
1725
|
+
endColumn: dir.loc.end.column,
|
|
1726
|
+
ruleId: "vue-doctor/template/avoid-deep-v-bind-spread-in-list",
|
|
1727
|
+
severity: "info",
|
|
1728
|
+
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.`,
|
|
1729
|
+
source: "template",
|
|
1730
|
+
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."
|
|
1731
|
+
});
|
|
1732
|
+
}
|
|
1733
|
+
for (const child of el.children) if (child.type === NODE_ELEMENT) checkElement(child, effectiveInVFor, ctx, diagnostics);
|
|
1734
|
+
}
|
|
1735
|
+
function check(ctx) {
|
|
1736
|
+
const diagnostics = [];
|
|
1737
|
+
function walk(node, inVForSubtree) {
|
|
1738
|
+
if (node.type === NODE_ELEMENT) checkElement(node, inVForSubtree, ctx, diagnostics);
|
|
1739
|
+
else if (node.type === 0) {
|
|
1740
|
+
const root = node;
|
|
1741
|
+
for (const child of root.children) walk(child, inVForSubtree);
|
|
1742
|
+
}
|
|
1743
|
+
}
|
|
1744
|
+
walk(ctx.template, false);
|
|
1745
|
+
return { diagnostics };
|
|
1746
|
+
}
|
|
1747
|
+
//#endregion
|
|
357
1748
|
//#region src/template/rules/index.ts
|
|
358
|
-
const TEMPLATE_RULES = [
|
|
359
|
-
|
|
360
|
-
|
|
361
|
-
|
|
362
|
-
|
|
363
|
-
|
|
364
|
-
|
|
1749
|
+
const TEMPLATE_RULES = [
|
|
1750
|
+
{
|
|
1751
|
+
id: "vue-doctor/template/v-for-has-key",
|
|
1752
|
+
check: check$5
|
|
1753
|
+
},
|
|
1754
|
+
{
|
|
1755
|
+
id: "vue-doctor/template/v-if-v-for-precedence",
|
|
1756
|
+
check: check$4
|
|
1757
|
+
},
|
|
1758
|
+
{
|
|
1759
|
+
id: "vue-doctor/template/v-memo-on-large-list",
|
|
1760
|
+
check: check$3
|
|
1761
|
+
},
|
|
1762
|
+
{
|
|
1763
|
+
id: "vue-doctor/template/no-inline-object-prop-in-list",
|
|
1764
|
+
check: check$2
|
|
1765
|
+
},
|
|
1766
|
+
{
|
|
1767
|
+
id: "vue-doctor/template/no-computed-getter-in-template-loop",
|
|
1768
|
+
check: check$1
|
|
1769
|
+
},
|
|
1770
|
+
{
|
|
1771
|
+
id: "vue-doctor/template/avoid-deep-v-bind-spread-in-list",
|
|
1772
|
+
check
|
|
1773
|
+
}
|
|
1774
|
+
];
|
|
365
1775
|
//#endregion
|
|
366
1776
|
//#region src/template/run.ts
|
|
367
1777
|
async function runTemplatePass(opts) {
|
|
@@ -375,7 +1785,8 @@ async function runTemplatePass(opts) {
|
|
|
375
1785
|
if (override === "off") continue;
|
|
376
1786
|
const { diagnostics } = rule.check({
|
|
377
1787
|
file,
|
|
378
|
-
template: descriptor.template.ast
|
|
1788
|
+
template: descriptor.template.ast,
|
|
1789
|
+
script: descriptor.scriptSetup?.content ?? descriptor.script?.content
|
|
379
1790
|
});
|
|
380
1791
|
for (const d of diagnostics) all.push(override ? {
|
|
381
1792
|
...d,
|
|
@@ -401,23 +1812,40 @@ const DEFAULT_EXCLUDE = [
|
|
|
401
1812
|
".output",
|
|
402
1813
|
"coverage"
|
|
403
1814
|
];
|
|
1815
|
+
function countRuleCounts(diagnostics) {
|
|
1816
|
+
const counts = {};
|
|
1817
|
+
for (const d of diagnostics) counts[d.ruleId] = (counts[d.ruleId] ?? 0) + 1;
|
|
1818
|
+
return counts;
|
|
1819
|
+
}
|
|
404
1820
|
async function audit(config = {}) {
|
|
405
1821
|
const rootDir = resolve(config.rootDir ?? process.cwd());
|
|
406
1822
|
const include = config.include ?? DEFAULT_INCLUDE;
|
|
407
1823
|
const exclude = config.exclude ?? DEFAULT_EXCLUDE;
|
|
408
1824
|
const failOn = config.failOn ?? "error";
|
|
1825
|
+
const threshold = config.threshold ?? 0;
|
|
1826
|
+
const lintEnabled = config.lint !== false;
|
|
409
1827
|
const files = await listSourceFiles({
|
|
410
1828
|
rootDir,
|
|
411
1829
|
include,
|
|
412
1830
|
exclude
|
|
413
1831
|
});
|
|
414
|
-
const
|
|
1832
|
+
const overallStart = performance.now();
|
|
1833
|
+
const templateStart = performance.now();
|
|
1834
|
+
const templateDiagnostics = lintEnabled ? await runTemplatePass({
|
|
415
1835
|
files,
|
|
416
1836
|
ruleOverrides: config.rules
|
|
417
|
-
});
|
|
1837
|
+
}) : [];
|
|
1838
|
+
const templateElapsed = performance.now() - templateStart;
|
|
1839
|
+
const sfcStart = performance.now();
|
|
1840
|
+
const sfcDiagnostics = lintEnabled ? await runSfcPass({
|
|
1841
|
+
files,
|
|
1842
|
+
ruleOverrides: config.rules
|
|
1843
|
+
}) : [];
|
|
1844
|
+
const sfcElapsed = performance.now() - sfcStart;
|
|
418
1845
|
let scriptDiagnostics = [];
|
|
419
1846
|
let oxlintStderr = "";
|
|
420
|
-
|
|
1847
|
+
const scriptStart = performance.now();
|
|
1848
|
+
if (lintEnabled) try {
|
|
421
1849
|
const result = await runScriptPass({
|
|
422
1850
|
rootDir,
|
|
423
1851
|
targetPath: rootDir,
|
|
@@ -429,95 +1857,573 @@ async function audit(config = {}) {
|
|
|
429
1857
|
oxlintStderr = err instanceof Error ? err.message : String(err);
|
|
430
1858
|
if (process.env.DOCTOR_DEBUG) process.stderr.write(`[doctor-core] script pass failed: ${oxlintStderr}\n`);
|
|
431
1859
|
}
|
|
432
|
-
const
|
|
433
|
-
const
|
|
1860
|
+
const scriptElapsed = performance.now() - scriptStart;
|
|
1861
|
+
const project = await detectProject(rootDir);
|
|
1862
|
+
let deadCodeDiagnostics = [];
|
|
1863
|
+
let deadCodeElapsed = 0;
|
|
1864
|
+
if (config.deadCode !== false) {
|
|
1865
|
+
const deadCodeStart = performance.now();
|
|
1866
|
+
try {
|
|
1867
|
+
const { loadDoctorConfig } = await import("./load-DiK7Zv0B.js").then((n) => n.n);
|
|
1868
|
+
deadCodeDiagnostics = dedupeDeadCodeAgainstLint(await checkDeadCode({
|
|
1869
|
+
projectInfo: project,
|
|
1870
|
+
doctorConfig: await loadDoctorConfig(rootDir),
|
|
1871
|
+
enabled: true
|
|
1872
|
+
}), [
|
|
1873
|
+
...templateDiagnostics,
|
|
1874
|
+
...sfcDiagnostics,
|
|
1875
|
+
...scriptDiagnostics
|
|
1876
|
+
]).filter((d) => config.rules?.[d.ruleId] !== "off");
|
|
1877
|
+
} catch {}
|
|
1878
|
+
deadCodeElapsed = performance.now() - deadCodeStart;
|
|
1879
|
+
}
|
|
1880
|
+
let buildQualityDiagnostics = [];
|
|
1881
|
+
try {
|
|
1882
|
+
buildQualityDiagnostics = (await checkBuildQuality(project)).filter((d) => config.rules?.[d.ruleId] !== "off").map((d) => {
|
|
1883
|
+
const override = config.rules?.[d.ruleId];
|
|
1884
|
+
return override ? {
|
|
1885
|
+
...d,
|
|
1886
|
+
severity: override
|
|
1887
|
+
} : d;
|
|
1888
|
+
});
|
|
1889
|
+
} catch {}
|
|
1890
|
+
let depsDiagnostics = [];
|
|
1891
|
+
try {
|
|
1892
|
+
depsDiagnostics = (await checkDeps(project)).filter((d) => config.rules?.[d.ruleId] !== "off").map((d) => {
|
|
1893
|
+
const override = config.rules?.[d.ruleId];
|
|
1894
|
+
return override ? {
|
|
1895
|
+
...d,
|
|
1896
|
+
severity: override
|
|
1897
|
+
} : d;
|
|
1898
|
+
});
|
|
1899
|
+
} catch {}
|
|
1900
|
+
const elapsedMs = performance.now() - overallStart;
|
|
1901
|
+
const timings = {
|
|
1902
|
+
template: templateElapsed,
|
|
1903
|
+
sfc: sfcElapsed,
|
|
1904
|
+
script: scriptElapsed,
|
|
1905
|
+
deadCode: deadCodeElapsed,
|
|
1906
|
+
total: elapsedMs
|
|
1907
|
+
};
|
|
1908
|
+
let afterDisables = applyInlineDisables(mergeDiagnostics(templateDiagnostics, sfcDiagnostics, scriptDiagnostics, deadCodeDiagnostics, buildQualityDiagnostics, depsDiagnostics), { respect: config.respectInlineDisables !== false });
|
|
1909
|
+
if (config.scopeFiles) {
|
|
1910
|
+
const scope = new Set(config.scopeFiles.map((f) => resolve(rootDir, f)));
|
|
1911
|
+
afterDisables = afterDisables.filter((d) => scope.has(resolve(rootDir, d.file)));
|
|
1912
|
+
}
|
|
1913
|
+
const diagnostics = await attachCodeSnippets(afterDisables);
|
|
1914
|
+
const scored = scoreDiagnostics(diagnostics, { threshold });
|
|
1915
|
+
const ruleCounts = countRuleCounts(diagnostics);
|
|
1916
|
+
const projectInfo = {
|
|
1917
|
+
framework: project.framework,
|
|
1918
|
+
vueVersion: project.vueVersion,
|
|
1919
|
+
nuxtVersion: project.nuxtVersion,
|
|
1920
|
+
capabilities: [...project.capabilities].sort(),
|
|
1921
|
+
rootDirectory: project.rootDirectory
|
|
1922
|
+
};
|
|
434
1923
|
let exitCode = 0;
|
|
435
1924
|
if (oxlintStderr && scriptDiagnostics.length === 0 && oxlintStderr.includes("Failed")) exitCode = 2;
|
|
436
|
-
else if (
|
|
1925
|
+
else if (failOn !== "none") {
|
|
1926
|
+
if ((failOn === "warn" ? scored.errorCount + scored.warnCount : scored.errorCount) > 0) exitCode = 1;
|
|
1927
|
+
}
|
|
437
1928
|
return {
|
|
438
1929
|
rootDir,
|
|
439
1930
|
filesScanned: files.length,
|
|
440
|
-
diagnostics
|
|
441
|
-
score,
|
|
442
|
-
errorCount,
|
|
443
|
-
|
|
444
|
-
|
|
1931
|
+
diagnostics,
|
|
1932
|
+
score: scored.score,
|
|
1933
|
+
errorCount: scored.errorCount,
|
|
1934
|
+
warnCount: scored.warnCount,
|
|
1935
|
+
infoCount: scored.infoCount,
|
|
1936
|
+
exitCode,
|
|
1937
|
+
scoreResult: scored,
|
|
1938
|
+
projectInfo,
|
|
1939
|
+
elapsedMs,
|
|
1940
|
+
timings,
|
|
1941
|
+
ruleCounts
|
|
445
1942
|
};
|
|
446
1943
|
}
|
|
447
1944
|
//#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
|
-
|
|
1945
|
+
//#region src/config/define-config.ts
|
|
1946
|
+
function defineConfig(config) {
|
|
1947
|
+
return config;
|
|
1948
|
+
}
|
|
1949
|
+
//#endregion
|
|
1950
|
+
//#region src/config/merge-cli-overrides.ts
|
|
1951
|
+
function mergeCliOverrides(resolved, cli) {
|
|
1952
|
+
const rules = { ...resolved.rules };
|
|
1953
|
+
if (cli.rules) for (const [key, value] of Object.entries(cli.rules)) if (value === "off") delete rules[key];
|
|
1954
|
+
else rules[key] = value;
|
|
1955
|
+
return {
|
|
1956
|
+
rootDir: resolved.rootDir,
|
|
1957
|
+
include: cli.include ?? resolved.include,
|
|
1958
|
+
exclude: cli.exclude ?? resolved.exclude,
|
|
1959
|
+
failOn: cli.failOn ?? resolved.failOn,
|
|
1960
|
+
threshold: cli.threshold ?? resolved.threshold,
|
|
1961
|
+
rules,
|
|
1962
|
+
source: resolved.source,
|
|
1963
|
+
configFile: resolved.configFile
|
|
1964
|
+
};
|
|
1965
|
+
}
|
|
1966
|
+
//#endregion
|
|
1967
|
+
//#region src/git-scope.ts
|
|
1968
|
+
const execFileAsync = promisify(execFile);
|
|
1969
|
+
const SOURCE_EXTENSIONS = [
|
|
1970
|
+
".vue",
|
|
1971
|
+
".ts",
|
|
1972
|
+
".tsx",
|
|
1973
|
+
".js",
|
|
1974
|
+
".jsx"
|
|
1975
|
+
];
|
|
1976
|
+
function isSourceFile(path) {
|
|
1977
|
+
return SOURCE_EXTENSIONS.some((ext) => path.endsWith(ext));
|
|
1978
|
+
}
|
|
1979
|
+
async function gitLines(rootDir, args) {
|
|
1980
|
+
const { stdout } = await execFileAsync("git", args, { cwd: rootDir });
|
|
1981
|
+
return stdout.split("\n").map((line) => line.trim()).filter((line) => line.length > 0);
|
|
1982
|
+
}
|
|
1983
|
+
async function listChangedFiles(options) {
|
|
1984
|
+
const { rootDir, mode } = options;
|
|
1985
|
+
let relPaths;
|
|
1986
|
+
if (mode === "staged") relPaths = await gitLines(rootDir, [
|
|
1987
|
+
"diff",
|
|
1988
|
+
"--name-only",
|
|
1989
|
+
"--cached",
|
|
1990
|
+
"--diff-filter=ACMR"
|
|
1991
|
+
]);
|
|
1992
|
+
else {
|
|
1993
|
+
const tracked = await gitLines(rootDir, [
|
|
1994
|
+
"diff",
|
|
1995
|
+
"--name-only",
|
|
1996
|
+
"HEAD",
|
|
1997
|
+
"--diff-filter=ACMR"
|
|
1998
|
+
]);
|
|
1999
|
+
const untracked = await gitLines(rootDir, [
|
|
2000
|
+
"ls-files",
|
|
2001
|
+
"--others",
|
|
2002
|
+
"--exclude-standard"
|
|
2003
|
+
]);
|
|
2004
|
+
relPaths = [...tracked, ...untracked];
|
|
2005
|
+
}
|
|
2006
|
+
const absolute = relPaths.filter(isSourceFile).map((rel) => resolve(rootDir, rel));
|
|
2007
|
+
return [...new Set(absolute)].sort();
|
|
2008
|
+
}
|
|
2009
|
+
//#endregion
|
|
2010
|
+
//#region src/reporters/docs-url.ts
|
|
2011
|
+
function docsUrl(ruleId) {
|
|
2012
|
+
return `https://docs.doctor.geoql.in/rules/${ruleId}`;
|
|
2013
|
+
}
|
|
2014
|
+
//#endregion
|
|
2015
|
+
//#region src/reporters/render.ts
|
|
2016
|
+
const CODE_WIDTH = 80;
|
|
2017
|
+
const WHY_WIDTH = 70;
|
|
2018
|
+
const SEVERITY_WIDTH = 6;
|
|
2019
|
+
const CONTINUATION_INDENT = " ".repeat(10);
|
|
2020
|
+
function relativize$1(file, rootDirectory) {
|
|
2021
|
+
return relative(rootDirectory, file).replaceAll("\\", "/");
|
|
2022
|
+
}
|
|
2023
|
+
function compareStrings(a, b) {
|
|
2024
|
+
if (a < b) return -1;
|
|
2025
|
+
if (a > b) return 1;
|
|
2026
|
+
return 0;
|
|
2027
|
+
}
|
|
2028
|
+
function truncateCode(code) {
|
|
2029
|
+
if (code.length <= CODE_WIDTH) return code;
|
|
2030
|
+
return `${code.slice(0, CODE_WIDTH - 1)}…`;
|
|
2031
|
+
}
|
|
2032
|
+
function wrapText(text, width) {
|
|
2033
|
+
const words = text.trim().split(/\s+/);
|
|
2034
|
+
const lines = [];
|
|
2035
|
+
let current = "";
|
|
2036
|
+
for (const word of words) if (current === "") current = word;
|
|
2037
|
+
else if (`${current} ${word}`.length <= width) current = `${current} ${word}`;
|
|
2038
|
+
else {
|
|
2039
|
+
lines.push(current);
|
|
2040
|
+
current = word;
|
|
2041
|
+
}
|
|
2042
|
+
if (current !== "") lines.push(current);
|
|
2043
|
+
return lines;
|
|
2044
|
+
}
|
|
2045
|
+
function formatWhy(message) {
|
|
2046
|
+
return wrapText(message, WHY_WIDTH).join(`\n${CONTINUATION_INDENT}`);
|
|
2047
|
+
}
|
|
2048
|
+
function severityTag(severity, theme) {
|
|
2049
|
+
const padding = " ".repeat(SEVERITY_WIDTH - severity.length);
|
|
2050
|
+
return `${theme.severity(severity)}${padding}`;
|
|
2051
|
+
}
|
|
2052
|
+
function sortDiagnostics(diagnostics, rootDirectory) {
|
|
2053
|
+
return [...diagnostics].sort((a, b) => {
|
|
2054
|
+
const byFile = compareStrings(relativize$1(a.file, rootDirectory), relativize$1(b.file, rootDirectory));
|
|
2055
|
+
if (byFile !== 0) return byFile;
|
|
2056
|
+
if (a.line !== b.line) return a.line - b.line;
|
|
2057
|
+
if (a.column !== b.column) return a.column - b.column;
|
|
2058
|
+
return compareStrings(a.ruleId, b.ruleId);
|
|
2059
|
+
});
|
|
2060
|
+
}
|
|
2061
|
+
function diagnosticBlock(index, diagnostic, rootDirectory, theme) {
|
|
2062
|
+
const tag = severityTag(diagnostic.severity, theme);
|
|
2063
|
+
const location = `${relativize$1(diagnostic.file, rootDirectory)}:${diagnostic.line}:${diagnostic.column}`;
|
|
2064
|
+
const code = truncateCode(diagnostic.codeSnippet ?? "");
|
|
2065
|
+
const fix = diagnostic.recommendation ?? "(no automated fix)";
|
|
2066
|
+
const why = formatWhy(diagnostic.message);
|
|
2067
|
+
return [
|
|
2068
|
+
`[${index}] ${tag} ${diagnostic.ruleId}`,
|
|
2069
|
+
` file: ${location}`,
|
|
2070
|
+
` code: ${code}`,
|
|
2071
|
+
` fix: ${fix}`,
|
|
2072
|
+
` why: ${why}`,
|
|
2073
|
+
` docs: ${docsUrl(diagnostic.ruleId)}`
|
|
2074
|
+
].join("\n");
|
|
2075
|
+
}
|
|
2076
|
+
function findingsLine(score) {
|
|
2077
|
+
if (score.totalFindings === 0) return "FINDINGS: 0 (clean)";
|
|
2078
|
+
return `FINDINGS: ${score.totalFindings} (${score.errorCount} error, ${score.warnCount} warn, ${score.infoCount} info)`;
|
|
2079
|
+
}
|
|
2080
|
+
function nextSteps(breakdown) {
|
|
2081
|
+
const lines = breakdown.slice(0, 3).map((entry) => ` −${Math.round(entry.penalty)} pts ${entry.occurrences}× ${entry.ruleId}`);
|
|
2082
|
+
lines.push(` Run \`vue-doctor explain ${breakdown[0].ruleId}\` for full context.`);
|
|
2083
|
+
return ["NEXT STEPS:", ...lines].join("\n");
|
|
2084
|
+
}
|
|
2085
|
+
function render(input, options, theme) {
|
|
2086
|
+
if (options?.quiet) return `SCORE: ${theme.scoreValue(input.score.score)}/100 (threshold: ${input.score.threshold})\n`;
|
|
2087
|
+
const seconds = (input.elapsedMs / 1e3).toFixed(1);
|
|
2088
|
+
const segments = [
|
|
2089
|
+
`${input.toolName} v${input.toolVersion}`,
|
|
2090
|
+
`analyzed ${input.analyzedFileCount} files in ${seconds}s`,
|
|
2091
|
+
"",
|
|
2092
|
+
`SCORE: ${theme.scoreValue(input.score.score)}/100 (threshold: ${input.score.threshold})`,
|
|
2093
|
+
"",
|
|
2094
|
+
findingsLine(input.score)
|
|
2095
|
+
];
|
|
2096
|
+
sortDiagnostics(input.diagnostics, input.rootDirectory).forEach((diagnostic, i) => {
|
|
2097
|
+
segments.push("");
|
|
2098
|
+
segments.push(diagnosticBlock(i + 1, diagnostic, input.rootDirectory, theme));
|
|
472
2099
|
});
|
|
2100
|
+
if (input.score.breakdown.length > 0) {
|
|
2101
|
+
segments.push("");
|
|
2102
|
+
segments.push(nextSteps(input.score.breakdown));
|
|
2103
|
+
}
|
|
2104
|
+
return `${segments.join("\n")}\n`;
|
|
2105
|
+
}
|
|
2106
|
+
//#endregion
|
|
2107
|
+
//#region src/reporters/agent.ts
|
|
2108
|
+
const plainTheme = {
|
|
2109
|
+
severity: (severity) => severity,
|
|
2110
|
+
scoreValue: (score) => String(score)
|
|
2111
|
+
};
|
|
2112
|
+
function agentReport(input, options) {
|
|
2113
|
+
return render(input, options, plainTheme);
|
|
2114
|
+
}
|
|
2115
|
+
//#endregion
|
|
2116
|
+
//#region src/rule-docs.ts
|
|
2117
|
+
function autoDescription(rule) {
|
|
2118
|
+
const presetNote = rule.recommended ? "Active in the `recommended` preset." : "Off by default in `recommended`; enable via `--rule <id>:warn` or in `doctor.config.ts`.";
|
|
2119
|
+
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.`;
|
|
2120
|
+
}
|
|
2121
|
+
function resolveDocsRoot() {
|
|
2122
|
+
return resolve(dirname(fileURLToPath(import.meta.url)), "..", "docs", "rules");
|
|
2123
|
+
}
|
|
2124
|
+
function safeReadOverride(ruleId, docsRoot) {
|
|
2125
|
+
const candidate = join(docsRoot, `${ruleId.replace(/\//g, "__")}.md`);
|
|
2126
|
+
if (!existsSync(candidate)) return null;
|
|
2127
|
+
return readFileSync(candidate, "utf-8");
|
|
2128
|
+
}
|
|
2129
|
+
function loadRuleDoc(ruleId, options = {}) {
|
|
2130
|
+
const rule = RULE_REGISTRY.find((r) => r.id === ruleId);
|
|
2131
|
+
if (!rule) return null;
|
|
2132
|
+
const override = safeReadOverride(ruleId, options.docsRoot ?? resolveDocsRoot());
|
|
2133
|
+
const description = override ?? autoDescription(rule);
|
|
473
2134
|
return {
|
|
474
|
-
|
|
475
|
-
|
|
476
|
-
|
|
477
|
-
|
|
478
|
-
|
|
2135
|
+
id: rule.id,
|
|
2136
|
+
title: rule.id,
|
|
2137
|
+
severity: rule.severity,
|
|
2138
|
+
category: rule.category,
|
|
2139
|
+
recommended: rule.recommended,
|
|
2140
|
+
source: rule.source,
|
|
2141
|
+
helpUri: docsUrl(rule.id),
|
|
2142
|
+
description,
|
|
2143
|
+
hasOverride: override !== null
|
|
2144
|
+
};
|
|
2145
|
+
}
|
|
2146
|
+
function loadAllRuleDocs(options = {}) {
|
|
2147
|
+
return RULE_REGISTRY.map((rule) => loadRuleDoc(rule.id, options));
|
|
2148
|
+
}
|
|
2149
|
+
//#endregion
|
|
2150
|
+
//#region src/reporters/sarif.ts
|
|
2151
|
+
const SARIF_SCHEMA = "https://raw.githubusercontent.com/oasis-tcs/sarif-spec/main/sarif-2.1/schema/sarif-schema-2.1.0.json";
|
|
2152
|
+
const INFORMATION_URI = "https://github.com/geoql/doctor";
|
|
2153
|
+
function toSarifLevel(severity) {
|
|
2154
|
+
if (severity === "error") return "error";
|
|
2155
|
+
if (severity === "warn") return "warning";
|
|
2156
|
+
return "note";
|
|
2157
|
+
}
|
|
2158
|
+
function toRelativeUri(filePath, rootDirectory) {
|
|
2159
|
+
const normalizedRoot = rootDirectory.endsWith("/") ? rootDirectory : `${rootDirectory}/`;
|
|
2160
|
+
if (filePath.startsWith(normalizedRoot)) return filePath.slice(normalizedRoot.length);
|
|
2161
|
+
return filePath;
|
|
2162
|
+
}
|
|
2163
|
+
function toSarifResult(diag, rootDirectory) {
|
|
2164
|
+
const region = {
|
|
2165
|
+
startLine: diag.line,
|
|
2166
|
+
startColumn: diag.column
|
|
2167
|
+
};
|
|
2168
|
+
if (diag.endLine !== void 0) region.endLine = diag.endLine;
|
|
2169
|
+
if (diag.endColumn !== void 0) region.endColumn = diag.endColumn;
|
|
2170
|
+
const uri = toRelativeUri(diag.file, rootDirectory);
|
|
2171
|
+
return {
|
|
2172
|
+
ruleId: diag.ruleId,
|
|
2173
|
+
level: toSarifLevel(diag.severity),
|
|
2174
|
+
message: { text: diag.message },
|
|
2175
|
+
locations: [{ physicalLocation: {
|
|
2176
|
+
artifactLocation: {
|
|
2177
|
+
uri,
|
|
2178
|
+
uriBaseId: "%SRCROOT%"
|
|
2179
|
+
},
|
|
2180
|
+
region
|
|
2181
|
+
} }],
|
|
2182
|
+
partialFingerprints: { primaryLocationLineHash: `${uri}:${diag.line}:${diag.ruleId}` }
|
|
479
2183
|
};
|
|
480
2184
|
}
|
|
2185
|
+
function collectRuleIds(diagnostics) {
|
|
2186
|
+
const ids = /* @__PURE__ */ new Set();
|
|
2187
|
+
for (const d of diagnostics) ids.add(d.ruleId);
|
|
2188
|
+
return ids;
|
|
2189
|
+
}
|
|
2190
|
+
function toRuleDescriptor(ruleId) {
|
|
2191
|
+
const registered = RULE_REGISTRY.find((r) => r.id === ruleId);
|
|
2192
|
+
const severity = registered?.severity ?? "warn";
|
|
2193
|
+
const category = registered?.category ?? "doctor-owned";
|
|
2194
|
+
const name = ruleId.includes("/") ? ruleId.split("/").slice(1).join("/") : ruleId;
|
|
2195
|
+
const fullDescription = loadRuleDoc(ruleId)?.description ?? ruleId;
|
|
2196
|
+
return {
|
|
2197
|
+
id: ruleId,
|
|
2198
|
+
name,
|
|
2199
|
+
shortDescription: { text: ruleId },
|
|
2200
|
+
fullDescription: { text: fullDescription },
|
|
2201
|
+
helpUri: docsUrl(ruleId),
|
|
2202
|
+
defaultConfiguration: { level: toSarifLevel(severity) },
|
|
2203
|
+
properties: { category }
|
|
2204
|
+
};
|
|
2205
|
+
}
|
|
2206
|
+
function sarifReport(input) {
|
|
2207
|
+
const rules = [...collectRuleIds(input.diagnostics)].sort().map(toRuleDescriptor);
|
|
2208
|
+
const results = input.diagnostics.map((d) => toSarifResult(d, input.rootDirectory));
|
|
2209
|
+
const log = {
|
|
2210
|
+
$schema: SARIF_SCHEMA,
|
|
2211
|
+
version: "2.1.0",
|
|
2212
|
+
runs: [{
|
|
2213
|
+
tool: { driver: {
|
|
2214
|
+
name: input.toolName,
|
|
2215
|
+
version: input.toolVersion,
|
|
2216
|
+
informationUri: INFORMATION_URI,
|
|
2217
|
+
rules
|
|
2218
|
+
} },
|
|
2219
|
+
results
|
|
2220
|
+
}]
|
|
2221
|
+
};
|
|
2222
|
+
return `${JSON.stringify(log, null, 2)}\n`;
|
|
2223
|
+
}
|
|
2224
|
+
//#endregion
|
|
2225
|
+
//#region src/reporters/html.ts
|
|
2226
|
+
const SEVERITY_LABEL = {
|
|
2227
|
+
error: "Error",
|
|
2228
|
+
warn: "Warn",
|
|
2229
|
+
info: "Info"
|
|
2230
|
+
};
|
|
2231
|
+
function escapeHtml(s) {
|
|
2232
|
+
return s.replace(/&/g, "&").replace(/</g, "<").replace(/>/g, ">").replace(/"/g, """).replace(/'/g, "'");
|
|
2233
|
+
}
|
|
2234
|
+
function relativize(file, rootDir) {
|
|
2235
|
+
return file.startsWith(`${rootDir}/`) ? file.slice(rootDir.length + 1) : file;
|
|
2236
|
+
}
|
|
2237
|
+
function renderDiagnosticRow(d, rootDir) {
|
|
2238
|
+
const loc = `${escapeHtml(relativize(d.file, rootDir))}:${d.line}:${d.column}`;
|
|
2239
|
+
const recommendation = d.recommendation ? `<div class="rec"><strong>Fix:</strong> ${escapeHtml(d.recommendation)}</div>` : "";
|
|
2240
|
+
return [
|
|
2241
|
+
`<details class="finding sev-${d.severity}">`,
|
|
2242
|
+
` <summary>`,
|
|
2243
|
+
` <span class="sev">${SEVERITY_LABEL[d.severity]}</span>`,
|
|
2244
|
+
` <code class="rule">${escapeHtml(d.ruleId)}</code>`,
|
|
2245
|
+
` <span class="loc">${loc}</span>`,
|
|
2246
|
+
` <span class="msg">${escapeHtml(d.message)}</span>`,
|
|
2247
|
+
` </summary>`,
|
|
2248
|
+
` <div class="body">`,
|
|
2249
|
+
recommendation,
|
|
2250
|
+
` <div class="docs"><a href="${escapeHtml(docsUrl(d.ruleId))}" target="_blank" rel="noopener">Rule docs</a></div>`,
|
|
2251
|
+
` </div>`,
|
|
2252
|
+
`</details>`
|
|
2253
|
+
].join("\n");
|
|
2254
|
+
}
|
|
2255
|
+
const STYLES = `
|
|
2256
|
+
* { box-sizing: border-box; margin: 0; padding: 0; }
|
|
2257
|
+
body { font: 14px/1.5 system-ui, -apple-system, sans-serif; background: #0f0f0f; color: #e5e5e5; padding: 2rem; }
|
|
2258
|
+
header { border-bottom: 1px solid #2a2a2a; padding-bottom: 1.5rem; margin-bottom: 1.5rem; }
|
|
2259
|
+
h1 { font-size: 1.25rem; font-weight: 500; color: #fafafa; }
|
|
2260
|
+
.meta { color: #888; margin-top: 0.5rem; font-size: 0.85rem; }
|
|
2261
|
+
.score-row { display: flex; gap: 2rem; margin-top: 1rem; align-items: baseline; }
|
|
2262
|
+
.score-row .score { font-size: 3rem; font-weight: 200; color: #fafafa; }
|
|
2263
|
+
.score-row .score.pass { color: #4ade80; }
|
|
2264
|
+
.score-row .score.fail { color: #f87171; }
|
|
2265
|
+
.score-row .breakdown { color: #aaa; font-size: 0.9rem; }
|
|
2266
|
+
.finding { border: 1px solid #2a2a2a; border-radius: 6px; margin-bottom: 0.5rem; background: #1a1a1a; overflow: hidden; }
|
|
2267
|
+
.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; }
|
|
2268
|
+
.finding summary::-webkit-details-marker { display: none; }
|
|
2269
|
+
.sev { font-size: 0.7rem; text-transform: uppercase; font-weight: 600; letter-spacing: 0.05em; }
|
|
2270
|
+
.sev-error .sev { color: #f87171; }
|
|
2271
|
+
.sev-warn .sev { color: #fbbf24; }
|
|
2272
|
+
.sev-info .sev { color: #60a5fa; }
|
|
2273
|
+
.rule { color: #aaa; font-family: ui-monospace, monospace; font-size: 0.8rem; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
|
|
2274
|
+
.loc { color: #888; font-family: ui-monospace, monospace; font-size: 0.8rem; }
|
|
2275
|
+
.msg { color: #d4d4d4; }
|
|
2276
|
+
.body { padding: 0 1rem 1rem; border-top: 1px solid #2a2a2a; color: #ccc; font-size: 0.85rem; }
|
|
2277
|
+
.rec { margin-top: 0.75rem; }
|
|
2278
|
+
.docs { margin-top: 0.5rem; }
|
|
2279
|
+
.docs a { color: #60a5fa; text-decoration: none; }
|
|
2280
|
+
.docs a:hover { text-decoration: underline; }
|
|
2281
|
+
.empty { color: #888; padding: 2rem 0; text-align: center; }
|
|
2282
|
+
`;
|
|
2283
|
+
function htmlReport(input) {
|
|
2284
|
+
const score = input.score.score;
|
|
2285
|
+
const pass = input.score.passed;
|
|
2286
|
+
const rows = input.diagnostics.map((d) => renderDiagnosticRow(d, input.rootDirectory)).join("\n");
|
|
2287
|
+
const body = input.diagnostics.length === 0 ? `<p class="empty">No findings. Clean run.</p>` : rows;
|
|
2288
|
+
const findingsLabel = `${input.diagnostics.length} finding${input.diagnostics.length === 1 ? "" : "s"}`;
|
|
2289
|
+
const meta = `${input.toolName} v${input.toolVersion} · ${input.analyzedFileCount} file${input.analyzedFileCount === 1 ? "" : "s"} · ${input.elapsedMs.toFixed(0)}ms`;
|
|
2290
|
+
return `<!DOCTYPE html>
|
|
2291
|
+
<html lang="en">
|
|
2292
|
+
<head>
|
|
2293
|
+
<meta charset="UTF-8">
|
|
2294
|
+
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
|
2295
|
+
<title>${escapeHtml(input.toolName)} — Report</title>
|
|
2296
|
+
<style>${STYLES}</style>
|
|
2297
|
+
</head>
|
|
2298
|
+
<body>
|
|
2299
|
+
<header>
|
|
2300
|
+
<h1>${escapeHtml(input.toolName)} report</h1>
|
|
2301
|
+
<div class="meta">${escapeHtml(meta)} · ${escapeHtml(input.rootDirectory)}</div>
|
|
2302
|
+
<div class="score-row">
|
|
2303
|
+
<div class="score ${pass ? "pass" : "fail"}">${score}<span style="font-size:1.5rem;color:#888">/100</span></div>
|
|
2304
|
+
<div class="breakdown">${findingsLabel} · ${input.score.errorCount} error · ${input.score.warnCount} warn · ${input.score.infoCount} info</div>
|
|
2305
|
+
</div>
|
|
2306
|
+
</header>
|
|
2307
|
+
<main>
|
|
2308
|
+
${body}
|
|
2309
|
+
</main>
|
|
2310
|
+
</body>
|
|
2311
|
+
</html>
|
|
2312
|
+
`;
|
|
2313
|
+
}
|
|
481
2314
|
//#endregion
|
|
482
2315
|
//#region src/reporters/json.ts
|
|
483
|
-
|
|
484
|
-
|
|
2316
|
+
const DOCTOR_REPORT_SCHEMA_VERSION = "1";
|
|
2317
|
+
function buildDoctorReport(input) {
|
|
2318
|
+
return {
|
|
2319
|
+
schemaVersion: "1",
|
|
2320
|
+
tool: {
|
|
2321
|
+
name: input.toolName,
|
|
2322
|
+
version: input.toolVersion
|
|
2323
|
+
},
|
|
2324
|
+
projectInfo: {
|
|
2325
|
+
framework: input.projectInfo.framework,
|
|
2326
|
+
vueVersion: input.projectInfo.vueVersion,
|
|
2327
|
+
nuxtVersion: input.projectInfo.nuxtVersion,
|
|
2328
|
+
capabilities: [...input.projectInfo.capabilities].sort(),
|
|
2329
|
+
rootDirectory: input.projectInfo.rootDirectory
|
|
2330
|
+
},
|
|
2331
|
+
score: {
|
|
2332
|
+
value: input.score.score,
|
|
2333
|
+
threshold: input.score.threshold,
|
|
2334
|
+
passed: input.score.passed,
|
|
2335
|
+
bySeverity: {
|
|
2336
|
+
error: input.score.errorCount,
|
|
2337
|
+
warn: input.score.warnCount,
|
|
2338
|
+
info: input.score.infoCount
|
|
2339
|
+
},
|
|
2340
|
+
breakdown: input.score.breakdown.map((entry) => ({
|
|
2341
|
+
ruleId: entry.ruleId,
|
|
2342
|
+
occurrences: entry.occurrences,
|
|
2343
|
+
weightPerOccurrence: entry.weightPerOccurrence,
|
|
2344
|
+
penalty: entry.penalty
|
|
2345
|
+
}))
|
|
2346
|
+
},
|
|
2347
|
+
diagnostics: input.diagnostics.map((d) => ({
|
|
2348
|
+
file: relative(input.rootDirectory, d.file).replaceAll("\\", "/"),
|
|
2349
|
+
line: d.line,
|
|
2350
|
+
column: d.column,
|
|
2351
|
+
endLine: d.endLine,
|
|
2352
|
+
endColumn: d.endColumn,
|
|
2353
|
+
ruleId: d.ruleId,
|
|
2354
|
+
severity: d.severity,
|
|
2355
|
+
message: d.message,
|
|
2356
|
+
source: d.source,
|
|
2357
|
+
recommendation: d.recommendation
|
|
2358
|
+
})),
|
|
2359
|
+
timing: {
|
|
2360
|
+
elapsedMs: input.elapsedMs,
|
|
2361
|
+
analyzedFileCount: input.analyzedFileCount
|
|
2362
|
+
}
|
|
2363
|
+
};
|
|
2364
|
+
}
|
|
2365
|
+
function jsonReport(input) {
|
|
2366
|
+
return JSON.stringify(buildDoctorReport(input), null, 2);
|
|
2367
|
+
}
|
|
2368
|
+
//#endregion
|
|
2369
|
+
//#region src/reporters/json-compact.ts
|
|
2370
|
+
function jsonCompactReport(input) {
|
|
2371
|
+
return `${JSON.stringify(buildDoctorReport(input))}\n`;
|
|
485
2372
|
}
|
|
486
2373
|
//#endregion
|
|
487
|
-
//#region src/reporters/
|
|
488
|
-
|
|
2374
|
+
//#region src/reporters/pretty.ts
|
|
2375
|
+
const colors = pc.createColors(true);
|
|
2376
|
+
const colorTheme = {
|
|
2377
|
+
severity: (severity) => {
|
|
2378
|
+
if (severity === "error") return colors.red(severity);
|
|
2379
|
+
if (severity === "warn") return colors.yellow(severity);
|
|
2380
|
+
return colors.cyan(severity);
|
|
2381
|
+
},
|
|
2382
|
+
scoreValue: (score) => {
|
|
2383
|
+
if (score <= 50) return colors.red(String(score));
|
|
2384
|
+
if (score <= 79) return colors.yellow(String(score));
|
|
2385
|
+
return colors.green(String(score));
|
|
2386
|
+
}
|
|
2387
|
+
};
|
|
2388
|
+
function prettyReport(input, options) {
|
|
2389
|
+
if (options?.color === false || Boolean(process$1.env.NO_COLOR)) return agentReport(input, options);
|
|
2390
|
+
return render(input, options, colorTheme);
|
|
2391
|
+
}
|
|
2392
|
+
//#endregion
|
|
2393
|
+
//#region src/reporters/verbose.ts
|
|
2394
|
+
function formatMs(ms) {
|
|
2395
|
+
return `${ms.toFixed(1)}ms`;
|
|
2396
|
+
}
|
|
2397
|
+
function renderVerboseTrace(report, options) {
|
|
489
2398
|
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("");
|
|
2399
|
+
lines.push("TIMINGS");
|
|
2400
|
+
lines.push(` template: ${formatMs(report.timings.template)}`);
|
|
2401
|
+
lines.push(` sfc: ${formatMs(report.timings.sfc)}`);
|
|
2402
|
+
lines.push(` script: ${formatMs(report.timings.script)}`);
|
|
2403
|
+
lines.push(` deadCode: ${formatMs(report.timings.deadCode)}`);
|
|
2404
|
+
lines.push(` total: ${formatMs(report.timings.total)}`);
|
|
2405
|
+
const ruleKeys = Object.keys(report.ruleCounts);
|
|
2406
|
+
if (ruleKeys.length > 0) {
|
|
2407
|
+
lines.push("RULE COUNTS");
|
|
2408
|
+
for (const ruleId of ruleKeys.sort()) lines.push(` ${ruleId}: ${report.ruleCounts[ruleId]}`);
|
|
2409
|
+
}
|
|
2410
|
+
if (options.configSource) {
|
|
2411
|
+
lines.push("CONFIG");
|
|
2412
|
+
lines.push(` source: ${options.configSource}`);
|
|
507
2413
|
}
|
|
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
2414
|
return lines.join("\n");
|
|
513
2415
|
}
|
|
514
2416
|
//#endregion
|
|
515
2417
|
//#region src/reporters/index.ts
|
|
516
|
-
function format(
|
|
517
|
-
if (kind === "
|
|
518
|
-
return
|
|
2418
|
+
function format(input, kind = "agent", options) {
|
|
2419
|
+
if (kind === "pretty") return prettyReport(input, options);
|
|
2420
|
+
if (kind === "json") return jsonReport(input);
|
|
2421
|
+
if (kind === "json-compact") return jsonCompactReport(input);
|
|
2422
|
+
if (kind === "sarif") return sarifReport(input);
|
|
2423
|
+
if (kind === "html") return htmlReport(input);
|
|
2424
|
+
return agentReport(input, options);
|
|
519
2425
|
}
|
|
520
2426
|
//#endregion
|
|
521
|
-
export { audit, format,
|
|
2427
|
+
export { BUILT_IN_RECOMMENDED, ConfigCycleError, ConfigFileNotFoundError, DOCTOR_REPORT_SCHEMA_VERSION, DeadCodeImportFailed, DeadCodeTimeoutError, InvalidConfigError, OxlintOutputTooLarge, OxlintSpawnFailed, RULE_REGISTRY, agentReport, applyInlineDisables, audit, buildDoctorReport, checkBuildQuality, checkDeadCode, checkDeps, dedupeDeadCodeAgainstLint, defineConfig, detectProject, docsUrl, encodeAnnotation, encodeAnnotations, format, jsonCompactReport, jsonReport, listChangedFiles, listRules, loadAllRuleDocs, loadDoctorConfig, loadRuleDoc, mergeCliOverrides, parseDirectives, prettyReport, renderVerboseTrace, sarifReport, scoreDiagnostics, validateConfig };
|
|
522
2428
|
|
|
523
2429
|
//# sourceMappingURL=index.js.map
|