@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.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 { existsSync, readFileSync } from "node:fs";
6
- import { fileURLToPath, pathToFileURL } from "node:url";
7
- import { spawn } from "node:child_process";
8
- import { parse } from "@vue/compiler-sfc";
9
- import { loadConfig } from "c12";
10
- import * as c from "kolorist";
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-9_-]+)\)$/i;
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" ? "warning" : "error";
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": "warning",
78
- "vue-doctor/no-em-dash-in-string": "warning"
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 === "warning") return "warn";
82
- return s;
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 mkdtemp(join(tmpdir(), "geoql-doctor-"));
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
- return configPath;
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
- if (typeof import.meta.resolve === "function") try {
134
- const baseUrl = pathToFileURL(resolve(fromDir, "package.json")).href;
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/spawn.ts
151
- const DEFAULT_TIMEOUT_MS = 6e4;
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 ?? DEFAULT_TIMEOUT_MS;
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) => stdoutChunks.push(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
- if (timer) clearTimeout(timer);
181
- reject(err);
1190
+ finish(() => reject(err));
182
1191
  });
183
1192
  child.on("close", (exitCode) => {
184
- if (timer) clearTimeout(timer);
185
- const stdout = Buffer.concat(stdoutChunks).toString("utf8");
186
- const stderr = Buffer.concat(stderrChunks).toString("utf8");
187
- resolve({
188
- diagnostics: parseOxlintJsonStream(stdout),
189
- stderr,
190
- exitCode
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
- const raw = await runOxlint({
229
- rootDir: opts.rootDir,
230
- targetPath: opts.targetPath,
231
- configPath,
232
- oxlintBin,
233
- timeoutMs: opts.timeoutMs
234
- });
235
- return {
236
- diagnostics: toCanonicalDiagnostics(raw.diagnostics, opts.rootDir),
237
- stderr: raw.stderr,
238
- exitCode: raw.exitCode
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 ERROR_PENALTY = 10;
244
- const WARNING_PENALTY = 2;
245
- function scoreDiagnostics(diagnostics) {
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 warningCount = 0;
248
- for (const d of diagnostics) if (d.severity === "error") errorCount += 1;
249
- else warningCount += 1;
250
- const penalty = errorCount * ERROR_PENALTY + warningCount * WARNING_PENALTY;
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: Math.max(0, 100 - penalty),
1301
+ score,
1302
+ passed: score >= threshold,
1303
+ threshold,
1304
+ totalFindings: diagnostics.length,
253
1305
  errorCount,
254
- warningCount
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$1(ctx) {
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
- id: "vue-doctor/template/v-for-has-key",
360
- check: check$1
361
- }, {
362
- id: "vue-doctor/template/v-if-v-for-precedence",
363
- check
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 templateDiagnostics = await runTemplatePass({
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
- try {
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 merged = mergeDiagnostics(templateDiagnostics, scriptDiagnostics);
433
- const { score, errorCount, warningCount } = scoreDiagnostics(merged);
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 ((failOn === "warning" ? errorCount + warningCount : errorCount) > 0) exitCode = 1;
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: merged,
441
- score,
442
- errorCount,
443
- warningCount,
444
- exitCode
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
- const DEFAULTS = {
450
- include: [
451
- "**/*.vue",
452
- "**/*.ts",
453
- "**/*.tsx",
454
- "**/*.js",
455
- "**/*.jsx"
456
- ],
457
- exclude: [
458
- "node_modules",
459
- "dist",
460
- ".nuxt",
461
- ".output",
462
- "coverage"
463
- ],
464
- failOn: "error"
465
- };
466
- async function loadAuditConfig(rootDir, explicitPath) {
467
- const { config, configFile } = await loadConfig({
468
- cwd: rootDir,
469
- name: "doctor",
470
- configFile: explicitPath,
471
- defaults: DEFAULTS
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
- config: {
475
- rootDir,
476
- ...config
477
- },
478
- configFile
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, "&amp;").replace(/</g, "&lt;").replace(/>/g, "&gt;").replace(/"/g, "&quot;").replace(/'/g, "&#39;");
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
- function formatJson(report) {
484
- return JSON.stringify(report, null, 2);
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/text.ts
488
- function formatText(report) {
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
- const groups = /* @__PURE__ */ new Map();
491
- for (const d of report.diagnostics) {
492
- const arr = groups.get(d.file) ?? [];
493
- arr.push(d);
494
- groups.set(d.file, arr);
495
- }
496
- for (const [file, diags] of groups) {
497
- const rel = relative(report.rootDir, file) || file;
498
- lines.push(c.underline(c.bold(rel)));
499
- for (const d of diags) {
500
- const sev = d.severity === "error" ? c.red("error") : c.yellow("warning");
501
- const loc = c.dim(`${d.line}:${d.column}`);
502
- const rule = c.dim(`(${d.ruleId})`);
503
- lines.push(` ${loc} ${sev} ${d.message} ${rule}`);
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(report, kind) {
517
- if (kind === "json") return formatJson(report);
518
- return formatText(report);
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, loadAuditConfig };
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