@geoql/doctor-core 0.1.0-alpha.0 → 0.1.0

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