@geoql/doctor-core 0.2.0-alpha.0 → 1.0.1

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,12 +1,12 @@
1
- import { a as listRules, c as InvalidConfigError, i as RULE_REGISTRY, l as BUILT_IN_RECOMMENDED, o as ConfigCycleError, r as validateConfig, s as ConfigFileNotFoundError, t as loadDoctorConfig } from "./load-DiK7Zv0B.js";
1
+ 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-yXDi-5ti.js";
2
2
  import { dirname, join, relative, resolve } from "node:path";
3
3
  import { performance } from "node:perf_hooks";
4
- import { access, mkdir, mkdtemp, readFile, rm, writeFile } from "node:fs/promises";
4
+ import { access, mkdir, mkdtemp, readFile, readdir, rm, writeFile } from "node:fs/promises";
5
5
  import { createRequire } from "node:module";
6
6
  import { execFile, spawn } from "node:child_process";
7
- import { existsSync, readFileSync } from "node:fs";
8
- import { coerce, gte, major, minor } from "semver";
9
7
  import { parseSync } from "oxc-parser";
8
+ import { existsSync, readFileSync, statSync } from "node:fs";
9
+ import { coerce, gte, major, minor } from "semver";
10
10
  import { glob } from "tinyglobby";
11
11
  import { tmpdir } from "node:os";
12
12
  import { fileURLToPath } from "node:url";
@@ -54,7 +54,7 @@ async function readDeps(packageJsonPath) {
54
54
  }
55
55
  //#endregion
56
56
  //#region src/build-quality/check-eslint-plugin-vue.ts
57
- const RULE_ID$6 = "vue-doctor/build-quality/eslint-plugin-vue-installed";
57
+ const RULE_ID$20 = "vue-doctor/build-quality/eslint-plugin-vue-installed";
58
58
  const DOCS$3 = "https://eslint.vuejs.org/";
59
59
  async function checkEslintPluginVue(projectInfo) {
60
60
  if (projectInfo.packageJsonPath === null) return [];
@@ -63,7 +63,7 @@ async function checkEslintPluginVue(projectInfo) {
63
63
  if (deps === null) return [];
64
64
  if (deps.devDependencies["eslint-plugin-vue"]) return [];
65
65
  return [{
66
- ruleId: RULE_ID$6,
66
+ ruleId: RULE_ID$20,
67
67
  file: projectInfo.packageJsonPath,
68
68
  line: 1,
69
69
  column: 1,
@@ -74,7 +74,7 @@ async function checkEslintPluginVue(projectInfo) {
74
74
  }
75
75
  //#endregion
76
76
  //#region src/build-quality/check-no-vue-cli.ts
77
- const RULE_ID$5 = "vue-doctor/build-quality/no-vue-cli";
77
+ const RULE_ID$19 = "vue-doctor/build-quality/no-vue-cli";
78
78
  const DOCS$2 = "https://cli.vuejs.org/migrations/migrate-from-v4.html";
79
79
  function isOffendingKey(key) {
80
80
  return key === "@vue/cli-service" || key.startsWith("vue-cli-plugin-");
@@ -86,7 +86,7 @@ async function checkNoVueCli(projectInfo) {
86
86
  const offenders = [];
87
87
  for (const key of [...Object.keys(deps.dependencies), ...Object.keys(deps.devDependencies)]) if (isOffendingKey(key) && !offenders.includes(key)) offenders.push(key);
88
88
  return offenders.map((key, index) => ({
89
- ruleId: RULE_ID$5,
89
+ ruleId: RULE_ID$19,
90
90
  file: projectInfo.packageJsonPath,
91
91
  line: index + 1,
92
92
  column: 1,
@@ -148,7 +148,7 @@ function stripJsonComments(input) {
148
148
  }
149
149
  //#endregion
150
150
  //#region src/build-quality/check-tsconfig-strict.ts
151
- const RULE_ID$4 = "vue-doctor/build-quality/tsconfig-strict-required";
151
+ const RULE_ID$18 = "vue-doctor/build-quality/tsconfig-strict-required";
152
152
  const DOCS$1 = "https://www.typescriptlang.org/tsconfig#strict";
153
153
  async function checkTsconfigStrict(projectInfo) {
154
154
  if (projectInfo.packageJsonPath === null) return [];
@@ -162,7 +162,7 @@ async function checkTsconfigStrict(projectInfo) {
162
162
  }
163
163
  if (parsed.compilerOptions?.strict === true) return [];
164
164
  return [{
165
- ruleId: RULE_ID$4,
165
+ ruleId: RULE_ID$18,
166
166
  file: tsconfigPath,
167
167
  line: 1,
168
168
  column: 1,
@@ -173,7 +173,7 @@ async function checkTsconfigStrict(projectInfo) {
173
173
  }
174
174
  //#endregion
175
175
  //#region src/build-quality/check-vue-tsc.ts
176
- const RULE_ID$3 = "vue-doctor/build-quality/vue-tsc-in-devDeps";
176
+ const RULE_ID$17 = "vue-doctor/build-quality/vue-tsc-in-devDeps";
177
177
  const DOCS = "https://github.com/vuejs/language-tools/tree/master/packages/tsc";
178
178
  async function checkVueTsc(projectInfo) {
179
179
  if (projectInfo.packageJsonPath === null) return [];
@@ -182,7 +182,7 @@ async function checkVueTsc(projectInfo) {
182
182
  if (deps === null) return [];
183
183
  if (deps.devDependencies["vue-tsc"]) return [];
184
184
  return [{
185
- ruleId: RULE_ID$3,
185
+ ruleId: RULE_ID$17,
186
186
  file: projectInfo.packageJsonPath,
187
187
  line: 1,
188
188
  column: 1,
@@ -213,8 +213,10 @@ async function checkBuildQuality(projectInfo) {
213
213
  }
214
214
  //#endregion
215
215
  //#region src/dead-code/build-knip-config.ts
216
+ const DEMO_WORKSPACES = ["example", "playground"];
216
217
  function buildKnipConfig(projectInfo, doctorConfig) {
217
218
  const isNuxt = projectInfo.framework === "nuxt";
219
+ const usesConventionResolution = projectInfo.hasAutoImports || projectInfo.hasComponentsAutoImport || projectInfo.hasVueRouter;
218
220
  const entry = isNuxt ? [
219
221
  "app/**/*.vue",
220
222
  "app/**/*.ts",
@@ -224,7 +226,14 @@ function buildKnipConfig(projectInfo, doctorConfig) {
224
226
  "src/main.{ts,js}",
225
227
  "src/App.vue",
226
228
  "index.html",
227
- "vite.config.{ts,js}"
229
+ "vite.config.{ts,js}",
230
+ ...usesConventionResolution ? [
231
+ "src/pages/**/*.vue",
232
+ "src/layouts/**/*.vue",
233
+ "src/components/**/*.vue",
234
+ "src/composables/**/*.ts",
235
+ "src/stores/**/*.ts"
236
+ ] : []
228
237
  ];
229
238
  const config = {
230
239
  cwd: projectInfo.rootDirectory,
@@ -241,7 +250,8 @@ function buildKnipConfig(projectInfo, doctorConfig) {
241
250
  "vite-plus",
242
251
  "@geoql/vue-doctor",
243
252
  "@geoql/nuxt-doctor"
244
- ]
253
+ ],
254
+ ignoreWorkspaces: [...DEMO_WORKSPACES]
245
255
  };
246
256
  if (isNuxt) config.compilers = { nuxt: true };
247
257
  return config;
@@ -381,6 +391,21 @@ const _knipLoader = { load: async () => {
381
391
  run: runModule.run
382
392
  };
383
393
  } };
394
+ async function writeKnipConfig(rootDir, config) {
395
+ const cacheDir = join(rootDir, "node_modules", ".cache", "doctor");
396
+ await mkdir(cacheDir, { recursive: true });
397
+ const configPath = join(cacheDir, "knip.json");
398
+ const knipJson = {
399
+ entry: config.entry,
400
+ project: config.project,
401
+ ignore: config.ignoreFiles,
402
+ ignoreDependencies: config.ignoreDependencies,
403
+ ignoreWorkspaces: config.ignoreWorkspaces
404
+ };
405
+ if (config.compilers) knipJson.compilers = config.compilers;
406
+ await writeFile(configPath, JSON.stringify(knipJson, null, 2), "utf8");
407
+ return configPath;
408
+ }
384
409
  async function checkDeadCode(options) {
385
410
  if (!options.enabled) return [];
386
411
  const config = buildKnipConfig(options.projectInfo, options.doctorConfig);
@@ -394,13 +419,10 @@ async function checkDeadCode(options) {
394
419
  } catch (err) {
395
420
  throw new DeadCodeImportFailed(err);
396
421
  }
422
+ const configPath = await writeKnipConfig(options.projectInfo.rootDirectory, config);
397
423
  const knipOptions = await createOptions({
398
424
  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 } : {}
425
+ args: { config: configPath }
404
426
  });
405
427
  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
428
  diagnostics.sort((a, b) => {
@@ -520,14 +542,14 @@ async function listVueResolutions(packageJsonPath) {
520
542
  }
521
543
  //#endregion
522
544
  //#region src/deps/check-duplicate-vue.ts
523
- const RULE_ID$2 = "vue-doctor/deps/duplicate-vue-versions";
545
+ const RULE_ID$16 = "vue-doctor/deps/duplicate-vue-versions";
524
546
  async function checkDuplicateVue(projectInfo) {
525
547
  if (projectInfo.packageJsonPath === null) return [];
526
548
  const versions = await listVueResolutions(projectInfo.packageJsonPath);
527
549
  const uniqueVersions = [...new Set(versions)];
528
550
  if (uniqueVersions.length <= 1) return [];
529
551
  return [{
530
- ruleId: RULE_ID$2,
552
+ ruleId: RULE_ID$16,
531
553
  file: projectInfo.packageJsonPath,
532
554
  line: 1,
533
555
  column: 1,
@@ -539,10 +561,10 @@ async function checkDuplicateVue(projectInfo) {
539
561
  }
540
562
  //#endregion
541
563
  //#region src/deps/check-vue-major-current.ts
542
- const RULE_ID$1 = "vue-doctor/deps/vue-major-current";
564
+ const RULE_ID$15 = "vue-doctor/deps/vue-major-current";
543
565
  const DOCTOR_BUNDLED_VUE_FLOOR_MAJOR = 3;
544
566
  const DOCTOR_BUNDLED_VUE_FLOOR_MINOR = 5;
545
- function parseMajorMinor(version) {
567
+ function parseMajorMinor$1(version) {
546
568
  const match = /^v?(\d+)\.(\d+)/.exec(version.trim());
547
569
  if (!match) return null;
548
570
  return {
@@ -553,12 +575,12 @@ function parseMajorMinor(version) {
553
575
  function checkVueMajorCurrent(projectInfo) {
554
576
  if (projectInfo.packageJsonPath === null) return [];
555
577
  if (projectInfo.vueVersion === null) return [];
556
- const parsed = parseMajorMinor(projectInfo.vueVersion);
578
+ const parsed = parseMajorMinor$1(projectInfo.vueVersion);
557
579
  if (parsed === null) return [];
558
580
  if (parsed.major !== DOCTOR_BUNDLED_VUE_FLOOR_MAJOR) return [];
559
581
  if (parsed.minor >= DOCTOR_BUNDLED_VUE_FLOOR_MINOR) return [];
560
582
  return [{
561
- ruleId: RULE_ID$1,
583
+ ruleId: RULE_ID$15,
562
584
  file: projectInfo.packageJsonPath,
563
585
  line: 1,
564
586
  column: 1,
@@ -583,6 +605,382 @@ async function checkDeps(projectInfo) {
583
605
  }));
584
606
  }
585
607
  //#endregion
608
+ //#region src/project-info/parse-nuxt-config.ts
609
+ const CONFIG_FILES = [
610
+ "nuxt.config.ts",
611
+ "nuxt.config.js",
612
+ "nuxt.config.mjs"
613
+ ];
614
+ async function readConfigSource(dir) {
615
+ for (const name of CONFIG_FILES) try {
616
+ return await readFile(join(dir, name), "utf8");
617
+ } catch {
618
+ continue;
619
+ }
620
+ return null;
621
+ }
622
+ function* objectEntries(obj) {
623
+ for (const prop of obj.properties) {
624
+ if (prop.type !== "Property") continue;
625
+ const key = prop.key;
626
+ if (key.type !== "Identifier") continue;
627
+ yield [key.name, prop.value];
628
+ }
629
+ }
630
+ function findEntry(obj, name) {
631
+ for (const [key, value] of objectEntries(obj)) if (key === name) return value;
632
+ }
633
+ function stringLiteral(node) {
634
+ return node.type === "Literal" && typeof node.value === "string" ? node.value : void 0;
635
+ }
636
+ function unwrapDefault(decl) {
637
+ if (decl.type !== "CallExpression") return decl;
638
+ const callee = decl.callee;
639
+ if (callee.type !== "Identifier") return null;
640
+ if (callee.name !== "defineNuxtConfig") return null;
641
+ return decl.arguments[0] ?? null;
642
+ }
643
+ function extractConfigObject(source) {
644
+ const exported = parseSync("nuxt.config.ts", source, {
645
+ sourceType: "module",
646
+ lang: "ts"
647
+ }).program.body.find((n) => n.type === "ExportDefaultDeclaration");
648
+ if (!exported) return null;
649
+ const node = unwrapDefault(exported.declaration);
650
+ if (!node) return null;
651
+ return node.type === "ObjectExpression" ? node : null;
652
+ }
653
+ function readNitroPreset(value) {
654
+ if (value.type !== "ObjectExpression") return void 0;
655
+ const preset = findEntry(value, "preset");
656
+ return preset ? stringLiteral(preset) : void 0;
657
+ }
658
+ function readImportsAutoImport(value) {
659
+ if (value.type !== "ObjectExpression") return void 0;
660
+ const node = findEntry(value, "autoImport");
661
+ if (node?.type === "Literal" && typeof node.value === "boolean") return node.value;
662
+ }
663
+ function readModules(value) {
664
+ if (value.type !== "ArrayExpression") return void 0;
665
+ const out = [];
666
+ for (const element of value.elements) {
667
+ if (!element) continue;
668
+ const str = stringLiteral(element);
669
+ if (str !== void 0) out.push(str);
670
+ }
671
+ return out;
672
+ }
673
+ function readCompatibility(value) {
674
+ return value.type === "Literal" && typeof value.value === "number" ? value.value : void 0;
675
+ }
676
+ function readHtmlLang(value) {
677
+ if (value.type !== "ObjectExpression") return void 0;
678
+ const head = findEntry(value, "head");
679
+ if (head?.type !== "ObjectExpression") return void 0;
680
+ const htmlAttrs = findEntry(head, "htmlAttrs");
681
+ if (htmlAttrs?.type !== "ObjectExpression") return void 0;
682
+ const lang = findEntry(htmlAttrs, "lang");
683
+ return lang ? stringLiteral(lang) : void 0;
684
+ }
685
+ async function parseNuxtConfig(dir) {
686
+ const source = await readConfigSource(dir);
687
+ if (source === null) return null;
688
+ const info = {};
689
+ const config = extractConfigObject(source);
690
+ if (!config) return info;
691
+ for (const [key, value] of objectEntries(config)) if (key === "compatibilityVersion") {
692
+ const compat = readCompatibility(value);
693
+ if (compat !== void 0) info.compatibilityVersion = compat;
694
+ } else if (key === "compatibilityDate") {
695
+ const date = stringLiteral(value);
696
+ if (date !== void 0) info.compatibilityDate = date;
697
+ } else if (key === "nitro") {
698
+ const preset = readNitroPreset(value);
699
+ if (preset !== void 0) info.nitroPreset = preset;
700
+ } else if (key === "modules") {
701
+ const modules = readModules(value);
702
+ if (modules !== void 0) info.modules = modules;
703
+ } else if (key === "imports") {
704
+ const autoImport = readImportsAutoImport(value);
705
+ if (autoImport !== void 0) info.importsAutoImport = autoImport;
706
+ } else if (key === "runtimeConfig") info.hasRuntimeConfig = true;
707
+ else if (key === "app") {
708
+ const lang = readHtmlLang(value);
709
+ if (lang !== void 0) info.htmlLang = lang;
710
+ }
711
+ return info;
712
+ }
713
+ //#endregion
714
+ //#region src/nuxt/post-checks/compatibility-date-set.ts
715
+ const RULE_ID$14 = "nuxt-doctor/nitro/compatibilityDate-set";
716
+ function checkCompatibilityDateSet(projectInfo, nuxtConfig) {
717
+ if (projectInfo.packageJsonPath === null) return [];
718
+ if (nuxtConfig?.compatibilityDate !== void 0) return [];
719
+ return [{
720
+ ruleId: RULE_ID$14,
721
+ file: projectInfo.nuxtConfigPath ?? projectInfo.packageJsonPath,
722
+ line: 1,
723
+ column: 1,
724
+ severity: "error",
725
+ message: "No compatibilityDate is set. Nuxt 4 and Nitro require a compatibilityDate to pin deployment behaviour.",
726
+ recommendation: "Add compatibilityDate: 'YYYY-MM-DD' to defineNuxtConfig in nuxt.config."
727
+ }];
728
+ }
729
+ //#endregion
730
+ //#region src/nuxt/post-checks/lang-on-html.ts
731
+ const RULE_ID$13 = "nuxt-doctor/seo/lang-on-html";
732
+ function checkLangOnHtml(projectInfo, nuxtConfig) {
733
+ if (projectInfo.packageJsonPath === null) return [];
734
+ if (nuxtConfig?.htmlLang !== void 0) return [];
735
+ return [{
736
+ ruleId: RULE_ID$13,
737
+ file: projectInfo.nuxtConfigPath ?? projectInfo.packageJsonPath,
738
+ line: 1,
739
+ column: 1,
740
+ severity: "warn",
741
+ message: "No lang attribute is set on the html element. Screen readers and search engines rely on it.",
742
+ recommendation: "Set app.head.htmlAttrs.lang (e.g. 'en') in defineNuxtConfig."
743
+ }];
744
+ }
745
+ //#endregion
746
+ //#region src/nuxt/post-checks/nitro-cloudflare-preset.ts
747
+ const RULE_ID$12 = "nuxt-doctor/cloudflare/nitro-cloudflare-preset";
748
+ const CLOUDFLARE_PRESETS = new Set([
749
+ "cloudflare-pages",
750
+ "cloudflare-module",
751
+ "cloudflare"
752
+ ]);
753
+ function checkNitroCloudflarePreset(projectInfo) {
754
+ if (projectInfo.packageJsonPath === null) return [];
755
+ if (!projectInfo.hasWranglerConfig) return [];
756
+ if (projectInfo.nitroPreset === null) return [];
757
+ if (CLOUDFLARE_PRESETS.has(projectInfo.nitroPreset)) return [];
758
+ return [{
759
+ ruleId: RULE_ID$12,
760
+ file: projectInfo.nuxtConfigPath ?? projectInfo.packageJsonPath,
761
+ line: 1,
762
+ column: 1,
763
+ severity: "warn",
764
+ message: `A wrangler config is present but the Nitro preset is '${projectInfo.nitroPreset}', not a Cloudflare preset.`,
765
+ recommendation: "Set nitro.preset to 'cloudflare-module' (or 'cloudflare-pages') to deploy on Cloudflare."
766
+ }];
767
+ }
768
+ //#endregion
769
+ //#region src/nuxt/post-checks/no-modules-incompatible-with-nuxt-4.ts
770
+ const RULE_ID$11 = "nuxt-doctor/modules-deps/no-modules-incompatible-with-nuxt-4";
771
+ const INCOMPATIBLE_MODULES = new Set([
772
+ "@nuxtjs/composition-api",
773
+ "@nuxt/bridge",
774
+ "nuxt-property-decorator"
775
+ ]);
776
+ async function checkNoModulesIncompatibleWithNuxt4(projectInfo) {
777
+ if (projectInfo.packageJsonPath === null) return [];
778
+ const deps = await readDeps(projectInfo.packageJsonPath);
779
+ if (deps === null) return [];
780
+ const offenders = [];
781
+ for (const key of [...Object.keys(deps.dependencies), ...Object.keys(deps.devDependencies)]) if (INCOMPATIBLE_MODULES.has(key) && !offenders.includes(key)) offenders.push(key);
782
+ return offenders.map((name, index) => ({
783
+ ruleId: RULE_ID$11,
784
+ file: projectInfo.packageJsonPath,
785
+ line: index + 1,
786
+ column: 1,
787
+ severity: "warn",
788
+ message: `${name} is incompatible with Nuxt 4 and is no longer maintained.`,
789
+ recommendation: `Remove ${name} and adopt the Nuxt 4 native equivalent.`
790
+ }));
791
+ }
792
+ //#endregion
793
+ //#region src/nuxt/post-checks/no-node-only-modules.ts
794
+ const RULE_ID$10 = "nuxt-doctor/cloudflare/no-node-only-modules";
795
+ const NODE_ONLY_MODULES = new Set(["fs-extra", "sharp"]);
796
+ async function checkNoNodeOnlyModules(projectInfo) {
797
+ if (projectInfo.packageJsonPath === null) return [];
798
+ if (!projectInfo.hasWranglerConfig) return [];
799
+ const deps = await readDeps(projectInfo.packageJsonPath);
800
+ if (deps === null) return [];
801
+ const offenders = [];
802
+ for (const key of [...Object.keys(deps.dependencies), ...Object.keys(deps.devDependencies)]) if (NODE_ONLY_MODULES.has(key) && !offenders.includes(key)) offenders.push(key);
803
+ return offenders.map((name, index) => ({
804
+ ruleId: RULE_ID$10,
805
+ file: projectInfo.packageJsonPath,
806
+ line: index + 1,
807
+ column: 1,
808
+ severity: "warn",
809
+ message: `${name} is a Node-only module and does not run on the Cloudflare Workers runtime.`,
810
+ recommendation: `Replace ${name} with a Workers-compatible alternative or move it to a build-only step.`
811
+ }));
812
+ }
813
+ //#endregion
814
+ //#region src/nuxt/post-checks/nuxt-major-current.ts
815
+ const RULE_ID$9 = "nuxt-doctor/structure/nuxt-major-current";
816
+ const DOCTOR_BUNDLED_NUXT_FLOOR_MAJOR = 4;
817
+ const DOCTOR_BUNDLED_NUXT_FLOOR_MINOR = 4;
818
+ function parseMajorMinor(version) {
819
+ const match = /^v?(\d+)\.(\d+)/.exec(version.trim());
820
+ if (!match) return null;
821
+ return {
822
+ major: Number(match[1]),
823
+ minor: Number(match[2])
824
+ };
825
+ }
826
+ function checkNuxtMajorCurrent(projectInfo) {
827
+ if (projectInfo.packageJsonPath === null) return [];
828
+ if (projectInfo.nuxtVersion === null) return [];
829
+ const parsed = parseMajorMinor(projectInfo.nuxtVersion);
830
+ if (parsed === null) return [];
831
+ if (parsed.major !== DOCTOR_BUNDLED_NUXT_FLOOR_MAJOR) return [];
832
+ if (parsed.minor >= DOCTOR_BUNDLED_NUXT_FLOOR_MINOR) return [];
833
+ return [{
834
+ ruleId: RULE_ID$9,
835
+ file: projectInfo.packageJsonPath,
836
+ line: 1,
837
+ column: 1,
838
+ severity: "info",
839
+ 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.`,
840
+ recommendation: `Bump "nuxt" to ^${DOCTOR_BUNDLED_NUXT_FLOOR_MAJOR}.${DOCTOR_BUNDLED_NUXT_FLOOR_MINOR}.0 or later in package.json.`
841
+ }];
842
+ }
843
+ //#endregion
844
+ //#region src/nuxt/post-checks/og-image-via-satori.ts
845
+ const RULE_ID$8 = "nuxt-doctor/cloudflare/og-image-via-satori";
846
+ const OG_IMAGE_MODULES = ["@nuxtjs/og-image", "nuxt-og-image"];
847
+ async function checkOgImageViaSatori(projectInfo) {
848
+ if (projectInfo.packageJsonPath === null) return [];
849
+ if (!projectInfo.hasWranglerConfig) return [];
850
+ const deps = await readDeps(projectInfo.packageJsonPath);
851
+ if (deps === null) return [];
852
+ const installed = new Set([...Object.keys(deps.dependencies), ...Object.keys(deps.devDependencies)]);
853
+ if (OG_IMAGE_MODULES.some((name) => installed.has(name))) return [];
854
+ return [{
855
+ ruleId: RULE_ID$8,
856
+ file: projectInfo.packageJsonPath,
857
+ line: 1,
858
+ column: 1,
859
+ severity: "info",
860
+ message: "Running on Cloudflare with no OG image module installed; dynamic social images are unconfigured.",
861
+ recommendation: "Add @nuxtjs/og-image to generate Satori-based OG images that run on Cloudflare Workers."
862
+ }];
863
+ }
864
+ //#endregion
865
+ //#region src/nuxt/post-checks/preset-defined-for-deploy-target.ts
866
+ const RULE_ID$7 = "nuxt-doctor/nitro/preset-defined-for-deploy-target";
867
+ function checkPresetDefinedForDeployTarget(projectInfo) {
868
+ if (projectInfo.packageJsonPath === null) return [];
869
+ if (!projectInfo.hasWranglerConfig) return [];
870
+ if (projectInfo.nitroPreset !== null) return [];
871
+ return [{
872
+ ruleId: RULE_ID$7,
873
+ file: projectInfo.nuxtConfigPath ?? projectInfo.packageJsonPath,
874
+ line: 1,
875
+ column: 1,
876
+ severity: "warn",
877
+ message: "A wrangler config is present but no Nitro preset is defined for the deploy target.",
878
+ recommendation: "Set nitro.preset in nuxt.config (e.g. 'cloudflare-module') to match your deploy target."
879
+ }];
880
+ }
881
+ //#endregion
882
+ //#region src/nuxt/post-checks/recommended-modules-installed.ts
883
+ const RULE_ID$6 = "nuxt-doctor/modules-deps/recommended-modules-installed";
884
+ const RECOMMENDED_GROUPS = [
885
+ {
886
+ label: "@nuxt/image",
887
+ candidates: ["@nuxt/image"]
888
+ },
889
+ {
890
+ label: "@nuxtjs/seo",
891
+ candidates: ["@nuxtjs/seo", "@nuxtjs/sitemap"]
892
+ },
893
+ {
894
+ label: "@nuxt/fonts",
895
+ candidates: ["@nuxt/fonts"]
896
+ }
897
+ ];
898
+ async function checkRecommendedModulesInstalled(projectInfo) {
899
+ if (projectInfo.packageJsonPath === null) return [];
900
+ const deps = await readDeps(projectInfo.packageJsonPath);
901
+ if (deps === null) return [];
902
+ const installed = new Set([...Object.keys(deps.dependencies), ...Object.keys(deps.devDependencies)]);
903
+ return RECOMMENDED_GROUPS.filter((group) => !group.candidates.some((name) => installed.has(name))).map((group, index) => ({
904
+ ruleId: RULE_ID$6,
905
+ file: projectInfo.packageJsonPath,
906
+ line: index + 1,
907
+ column: 1,
908
+ severity: "info",
909
+ message: `Recommended Nuxt 4 module ${group.label} is not installed.`,
910
+ recommendation: `Add ${group.label} to dependencies to improve image, SEO, or font handling.`
911
+ }));
912
+ }
913
+ //#endregion
914
+ //#region src/nuxt/post-checks/runtime-config-typed.ts
915
+ const RULE_ID$5 = "nuxt-doctor/nitro/runtime-config-typed";
916
+ async function hasRuntimeConfigAugmentation(dir) {
917
+ const entries = await readdir(dir);
918
+ for (const name of entries) {
919
+ if (!name.endsWith(".d.ts")) continue;
920
+ if ((await readFile(join(dir, name), "utf8")).includes("RuntimeConfig")) return true;
921
+ }
922
+ return false;
923
+ }
924
+ async function checkRuntimeConfigTyped(projectInfo, nuxtConfig) {
925
+ if (projectInfo.packageJsonPath === null) return [];
926
+ if (nuxtConfig?.hasRuntimeConfig !== true) return [];
927
+ if (await hasRuntimeConfigAugmentation(projectInfo.rootDirectory)) return [];
928
+ return [{
929
+ ruleId: RULE_ID$5,
930
+ file: projectInfo.packageJsonPath,
931
+ line: 1,
932
+ column: 1,
933
+ severity: "info",
934
+ message: "runtimeConfig is defined but no RuntimeConfig type augmentation was found, so runtime config access is untyped.",
935
+ recommendation: "Augment 'nuxt/schema' with a RuntimeConfig interface in a .d.ts file to type useRuntimeConfig()."
936
+ }];
937
+ }
938
+ //#endregion
939
+ //#region src/nuxt/post-checks/uses-app-directory.ts
940
+ const RULE_ID$4 = "nuxt-doctor/structure/uses-app-directory";
941
+ function checkUsesAppDirectory(projectInfo) {
942
+ if (projectInfo.packageJsonPath === null) return [];
943
+ if (projectInfo.hasAppDir) return [];
944
+ return [{
945
+ ruleId: RULE_ID$4,
946
+ file: projectInfo.packageJsonPath,
947
+ line: 1,
948
+ column: 1,
949
+ severity: "warn",
950
+ message: "No app/ directory found. Nuxt 4 expects source under app/ (app/pages, app/components, app/app.vue).",
951
+ recommendation: "Create an app/ directory and move pages, components, layouts, and app.vue into it."
952
+ }];
953
+ }
954
+ //#endregion
955
+ //#region src/check-nuxt-project.ts
956
+ async function checkNuxtProject(projectInfo) {
957
+ if (projectInfo.packageJsonPath === null) return [];
958
+ if (projectInfo.framework !== "nuxt") return [];
959
+ const nuxtConfig = await parseNuxtConfig(projectInfo.rootDirectory);
960
+ return (await Promise.all([
961
+ Promise.resolve(checkUsesAppDirectory(projectInfo)),
962
+ Promise.resolve(checkNuxtMajorCurrent(projectInfo)),
963
+ checkNoModulesIncompatibleWithNuxt4(projectInfo),
964
+ checkRecommendedModulesInstalled(projectInfo),
965
+ Promise.resolve(checkCompatibilityDateSet(projectInfo, nuxtConfig)),
966
+ Promise.resolve(checkPresetDefinedForDeployTarget(projectInfo)),
967
+ checkRuntimeConfigTyped(projectInfo, nuxtConfig),
968
+ Promise.resolve(checkLangOnHtml(projectInfo, nuxtConfig)),
969
+ Promise.resolve(checkNitroCloudflarePreset(projectInfo)),
970
+ checkOgImageViaSatori(projectInfo),
971
+ checkNoNodeOnlyModules(projectInfo)
972
+ ])).flat().map((issue) => ({
973
+ file: issue.file,
974
+ line: issue.line,
975
+ column: issue.column,
976
+ ruleId: issue.ruleId,
977
+ severity: issue.severity,
978
+ message: issue.message,
979
+ source: "project",
980
+ recommendation: issue.recommendation
981
+ }));
982
+ }
983
+ //#endregion
586
984
  //#region src/disables/parse-directives.ts
587
985
  const DIRECTIVE = /doctor-(disable-next-line|disable-line|disable|enable)\b(.*)/;
588
986
  function parseRuleList(raw) {
@@ -694,6 +1092,30 @@ async function attachCodeSnippets(diagnostics) {
694
1092
  return out;
695
1093
  }
696
1094
  //#endregion
1095
+ //#region src/project-info/fs-checks.ts
1096
+ /**
1097
+ * Synchronously reports whether `target` exists and is a directory. Missing
1098
+ * paths and non-directory entries both return false rather than throwing.
1099
+ */
1100
+ function directoryExists(target) {
1101
+ try {
1102
+ return statSync(target).isDirectory();
1103
+ } catch {
1104
+ return false;
1105
+ }
1106
+ }
1107
+ /**
1108
+ * Returns the absolute path of the first existing candidate file (resolved
1109
+ * against `dir`), or null when none of the candidates exist.
1110
+ */
1111
+ function resolveExistingFile(dir, candidates) {
1112
+ for (const name of candidates) {
1113
+ const full = join(dir, name);
1114
+ if (existsSync(full)) return full;
1115
+ }
1116
+ return null;
1117
+ }
1118
+ //#endregion
697
1119
  //#region src/project-info/path-exists.ts
698
1120
  async function pathExists(target) {
699
1121
  try {
@@ -710,116 +1132,26 @@ async function detectKind(dir) {
710
1132
  if ((await readPackageJson(dir))?.workspaces) {
711
1133
  if (await pathExists(join(dir, "yarn.lock"))) return "yarn";
712
1134
  if (await pathExists(join(dir, "package-lock.json"))) return "npm";
713
- }
714
- if (await pathExists(join(dir, "turbo.json"))) return "turbo";
715
- return null;
716
- }
717
- async function findMonorepoRoot(rootDirectory) {
718
- let current = rootDirectory;
719
- for (;;) {
720
- const kind = await detectKind(current);
721
- if (kind) return {
722
- root: current,
723
- kind
724
- };
725
- const parent = dirname(current);
726
- if (parent === current) break;
727
- current = parent;
728
- }
729
- return {
730
- root: rootDirectory,
731
- kind: null
732
- };
733
- }
734
- //#endregion
735
- //#region src/project-info/parse-nuxt-config.ts
736
- const CONFIG_FILES = [
737
- "nuxt.config.ts",
738
- "nuxt.config.js",
739
- "nuxt.config.mjs"
740
- ];
741
- async function readConfigSource(dir) {
742
- for (const name of CONFIG_FILES) try {
743
- return await readFile(join(dir, name), "utf8");
744
- } catch {
745
- continue;
746
- }
747
- return null;
748
- }
749
- function* objectEntries(obj) {
750
- for (const prop of obj.properties) {
751
- if (prop.type !== "Property") continue;
752
- const key = prop.key;
753
- if (key.type !== "Identifier") continue;
754
- yield [key.name, prop.value];
755
- }
756
- }
757
- function findEntry(obj, name) {
758
- for (const [key, value] of objectEntries(obj)) if (key === name) return value;
759
- }
760
- function stringLiteral(node) {
761
- return node.type === "Literal" && typeof node.value === "string" ? node.value : void 0;
762
- }
763
- function unwrapDefault(decl) {
764
- if (decl.type !== "CallExpression") return decl;
765
- const callee = decl.callee;
766
- if (callee.type !== "Identifier") return null;
767
- if (callee.name !== "defineNuxtConfig") return null;
768
- return decl.arguments[0] ?? null;
769
- }
770
- function extractConfigObject(source) {
771
- const exported = parseSync("nuxt.config.ts", source, {
772
- sourceType: "module",
773
- lang: "ts"
774
- }).program.body.find((n) => n.type === "ExportDefaultDeclaration");
775
- if (!exported) return null;
776
- const node = unwrapDefault(exported.declaration);
777
- if (!node) return null;
778
- return node.type === "ObjectExpression" ? node : null;
779
- }
780
- function readNitroPreset(value) {
781
- if (value.type !== "ObjectExpression") return void 0;
782
- const preset = findEntry(value, "preset");
783
- return preset ? stringLiteral(preset) : void 0;
784
- }
785
- function readImportsAutoImport(value) {
786
- if (value.type !== "ObjectExpression") return void 0;
787
- const node = findEntry(value, "autoImport");
788
- if (node?.type === "Literal" && typeof node.value === "boolean") return node.value;
789
- }
790
- function readModules(value) {
791
- if (value.type !== "ArrayExpression") return void 0;
792
- const out = [];
793
- for (const element of value.elements) {
794
- if (!element) continue;
795
- const str = stringLiteral(element);
796
- if (str !== void 0) out.push(str);
797
- }
798
- return out;
799
- }
800
- function readCompatibility(value) {
801
- return value.type === "Literal" && typeof value.value === "number" ? value.value : void 0;
1135
+ }
1136
+ if (await pathExists(join(dir, "turbo.json"))) return "turbo";
1137
+ return null;
802
1138
  }
803
- async function parseNuxtConfig(dir) {
804
- const source = await readConfigSource(dir);
805
- if (source === null) return null;
806
- const info = {};
807
- const config = extractConfigObject(source);
808
- if (!config) return info;
809
- for (const [key, value] of objectEntries(config)) if (key === "compatibilityVersion") {
810
- const compat = readCompatibility(value);
811
- if (compat !== void 0) info.compatibilityVersion = compat;
812
- } else if (key === "nitro") {
813
- const preset = readNitroPreset(value);
814
- if (preset !== void 0) info.nitroPreset = preset;
815
- } else if (key === "modules") {
816
- const modules = readModules(value);
817
- if (modules !== void 0) info.modules = modules;
818
- } else if (key === "imports") {
819
- const autoImport = readImportsAutoImport(value);
820
- if (autoImport !== void 0) info.importsAutoImport = autoImport;
1139
+ async function findMonorepoRoot(rootDirectory) {
1140
+ let current = rootDirectory;
1141
+ for (;;) {
1142
+ const kind = await detectKind(current);
1143
+ if (kind) return {
1144
+ root: current,
1145
+ kind
1146
+ };
1147
+ const parent = dirname(current);
1148
+ if (parent === current) break;
1149
+ current = parent;
821
1150
  }
822
- return info;
1151
+ return {
1152
+ root: rootDirectory,
1153
+ kind: null
1154
+ };
823
1155
  }
824
1156
  //#endregion
825
1157
  //#region src/project-info/resolve-dep-version.ts
@@ -877,6 +1209,12 @@ function buildCapabilities(input) {
877
1209
  }
878
1210
  if (input.nuxtCompatibilityVersion === 4) caps.add("nuxt:4");
879
1211
  if (input.nuxtVersion && gte(input.nuxtVersion, "4.4.0")) caps.add("nuxt:4.4");
1212
+ if (input.isNuxtMajor4) caps.add("nuxt4");
1213
+ if (input.hasNuxtConfig) caps.add("nuxt-config");
1214
+ if (input.hasAppDir) caps.add("app-dir");
1215
+ if (input.hasServerDir) caps.add("server-dir");
1216
+ if (input.hasPagesDir) caps.add("pages-dir");
1217
+ if (input.hasWrangler) caps.add("wrangler");
880
1218
  if (input.hasAutoImports) caps.add("auto-imports:vue");
881
1219
  if (input.hasComponentsAutoImport) caps.add("components:auto");
882
1220
  if (input.hasPinia) caps.add("pinia");
@@ -905,6 +1243,41 @@ async function detectProject(rootDirectory) {
905
1243
  const hasPinia = hasDependency(pkg, "pinia");
906
1244
  const hasVueRouter = hasDependency(pkg, "vue-router") && !isNuxt;
907
1245
  const tsconfigExists = await pathExists(join(rootDirectory, "tsconfig.json"));
1246
+ const hasTypescript = hasDependency(pkg, "typescript") || tsconfigExists;
1247
+ const hasWranglerConfig = resolveExistingFile(rootDirectory, [
1248
+ "wrangler.toml",
1249
+ "wrangler.jsonc",
1250
+ "wrangler.json"
1251
+ ]) !== null;
1252
+ const nuxtConfigPath = resolveExistingFile(rootDirectory, [
1253
+ "nuxt.config.ts",
1254
+ "nuxt.config.js",
1255
+ "nuxt.config.mjs"
1256
+ ]);
1257
+ const appDirPath = join(rootDirectory, "app");
1258
+ const hasAppDir = directoryExists(appDirPath);
1259
+ const hasServerDir = directoryExists(join(rootDirectory, "server"));
1260
+ const hasPagesDir = hasAppDir && directoryExists(join(appDirPath, "pages")) || directoryExists(join(rootDirectory, "pages"));
1261
+ const capabilities = buildCapabilities({
1262
+ framework,
1263
+ vueVersion,
1264
+ nuxtVersion,
1265
+ typescriptVersion,
1266
+ hasTypescript,
1267
+ hasAutoImports,
1268
+ hasComponentsAutoImport,
1269
+ hasPinia,
1270
+ hasVueRouter,
1271
+ nitroPreset,
1272
+ nuxtCompatibilityVersion,
1273
+ monorepoKind,
1274
+ hasWrangler: hasWranglerConfig,
1275
+ isNuxtMajor4: nuxtVersion !== null && major(nuxtVersion) >= 4,
1276
+ hasNuxtConfig: nuxtConfigPath !== null,
1277
+ hasAppDir,
1278
+ hasServerDir,
1279
+ hasPagesDir
1280
+ });
908
1281
  return {
909
1282
  framework,
910
1283
  rootDirectory,
@@ -919,21 +1292,13 @@ async function detectProject(rootDirectory) {
919
1292
  nitroPreset,
920
1293
  nuxtCompatibilityVersion,
921
1294
  monorepoKind,
922
- capabilities: buildCapabilities({
923
- framework,
924
- vueVersion,
925
- nuxtVersion,
926
- typescriptVersion,
927
- hasTypescript: hasDependency(pkg, "typescript") || tsconfigExists,
928
- hasAutoImports,
929
- hasComponentsAutoImport,
930
- hasPinia,
931
- hasVueRouter,
932
- nitroPreset,
933
- nuxtCompatibilityVersion,
934
- monorepoKind,
935
- hasWrangler: await pathExists(join(rootDirectory, "wrangler.toml")) || await pathExists(join(rootDirectory, "wrangler.jsonc"))
936
- })
1295
+ nuxtConfigPath,
1296
+ hasAppDir,
1297
+ appDirPath: hasAppDir ? appDirPath : null,
1298
+ hasServerDir,
1299
+ hasPagesDir,
1300
+ hasWranglerConfig,
1301
+ capabilities
937
1302
  };
938
1303
  }
939
1304
  //#endregion
@@ -1001,18 +1366,86 @@ function toCanonicalDiagnostics(raws, rootDir) {
1001
1366
  }
1002
1367
  //#endregion
1003
1368
  //#region src/oxlint/generate-config.ts
1004
- const DEFAULT_RULES = {
1369
+ const VUE_DEFAULT_RULES = {
1005
1370
  "vue/no-export-in-script-setup": "error",
1006
1371
  "vue/require-typed-ref": "warn",
1372
+ "vue/no-arrow-functions-in-watch": "error",
1373
+ "vue/no-deprecated-data-object-declaration": "error",
1374
+ "vue/no-deprecated-events-api": "error",
1375
+ "vue/no-deprecated-destroyed-lifecycle": "error",
1376
+ "vue/no-deprecated-model-definition": "error",
1377
+ "vue/no-deprecated-delete-set": "error",
1378
+ "vue/no-deprecated-vue-config-keycodes": "error",
1379
+ "vue/no-lifecycle-after-await": "error",
1380
+ "vue/no-this-in-before-route-enter": "error",
1381
+ "vue/return-in-computed-property": "error",
1382
+ "vue/valid-define-emits": "error",
1383
+ "vue/valid-define-props": "error",
1384
+ "vue/no-required-prop-with-default": "warn",
1385
+ "vue/prefer-import-from-vue": "warn",
1386
+ "vue/no-import-compiler-macros": "warn",
1387
+ "vue/no-multiple-slot-args": "warn",
1388
+ "vue/require-default-export": "warn",
1007
1389
  "vue-doctor/no-em-dash-in-string": "warn",
1008
- "vue-doctor/no-destructure-props-without-to-refs": "error",
1009
- "vue-doctor/no-destructure-reactive-without-to-refs": "error",
1010
1390
  "vue-doctor/no-non-null-assertion-on-ref-value": "warn",
1011
1391
  "vue-doctor/no-imports-from-vue-when-auto-imported": "warn",
1012
1392
  "vue-doctor/reactivity/watch-without-cleanup": "warn",
1013
1393
  "vue-doctor/composition/prefer-script-setup-for-new-files": "warn",
1014
1394
  "vue-doctor/composition/defineProps-typed": "warn"
1015
1395
  };
1396
+ const NUXT_PLUGIN_RULES = {
1397
+ "nuxt-doctor/ai-slop/no-process-client-server": "error",
1398
+ "nuxt-doctor/ai-slop/no-explicit-imports-of-auto-imported": "warn",
1399
+ "nuxt-doctor/ai-slop/no-useState-for-server-data": "warn",
1400
+ "nuxt-doctor/ai-slop/no-fetch-in-setup": "warn",
1401
+ "nuxt-doctor/data-fetching/useAsyncData-key-required-in-loop": "error",
1402
+ "nuxt-doctor/server-routes/defineEventHandler-typed": "warn",
1403
+ "nuxt-doctor/server-routes/validate-body-with-h3-v2": "warn",
1404
+ "nuxt-doctor/server-routes/createError-on-failure": "warn",
1405
+ "nuxt-doctor/hydration/no-document-in-setup": "error",
1406
+ "nuxt-doctor/hydration/clientOnly-for-browser-apis": "error"
1407
+ };
1408
+ const VUE_OXLINT_RULE_IDS = new Set([
1409
+ "vue/no-export-in-script-setup",
1410
+ "vue/require-typed-ref",
1411
+ "vue/no-arrow-functions-in-watch",
1412
+ "vue/no-deprecated-data-object-declaration",
1413
+ "vue/no-deprecated-events-api",
1414
+ "vue/no-deprecated-destroyed-lifecycle",
1415
+ "vue/no-deprecated-model-definition",
1416
+ "vue/no-deprecated-delete-set",
1417
+ "vue/no-deprecated-vue-config-keycodes",
1418
+ "vue/no-lifecycle-after-await",
1419
+ "vue/no-this-in-before-route-enter",
1420
+ "vue/return-in-computed-property",
1421
+ "vue/valid-define-emits",
1422
+ "vue/valid-define-props",
1423
+ "vue/no-required-prop-with-default",
1424
+ "vue/prefer-import-from-vue",
1425
+ "vue/no-import-compiler-macros",
1426
+ "vue/no-multiple-slot-args",
1427
+ "vue/require-default-export",
1428
+ "vue/define-emits-declaration",
1429
+ "vue/define-props-declaration",
1430
+ "vue/define-props-destructuring",
1431
+ "vue/max-props",
1432
+ "vue-doctor/no-em-dash-in-string",
1433
+ "vue-doctor/no-destructure-props-without-to-refs",
1434
+ "vue-doctor/no-destructure-reactive-without-to-refs",
1435
+ "vue-doctor/no-non-null-assertion-on-ref-value",
1436
+ "vue-doctor/no-imports-from-vue-when-auto-imported",
1437
+ "vue-doctor/reactivity/watch-without-cleanup",
1438
+ "vue-doctor/reactivity/prefer-shallowRef-for-large-data",
1439
+ "vue-doctor/reactivity/prefer-readonly-for-injected",
1440
+ "vue-doctor/composition/prefer-script-setup-for-new-files",
1441
+ "vue-doctor/composition/defineProps-typed",
1442
+ "vue-doctor/performance/prefer-defineAsyncComponent-on-route"
1443
+ ]);
1444
+ const NUXT_OXLINT_RULE_IDS = new Set(Object.keys(NUXT_PLUGIN_RULES));
1445
+ function oxlintRuleAllowlist(framework) {
1446
+ if (framework !== "nuxt") return VUE_OXLINT_RULE_IDS;
1447
+ return new Set([...VUE_OXLINT_RULE_IDS, ...NUXT_OXLINT_RULE_IDS]);
1448
+ }
1016
1449
  function toOxlintSeverity(s) {
1017
1450
  if (s === "error") return "error";
1018
1451
  return "warn";
@@ -1040,9 +1473,19 @@ function resolveUserConfig(rootDir) {
1040
1473
  }
1041
1474
  async function generateOxlintConfig(input) {
1042
1475
  const { dir, removeDir } = await resolveCacheDir(input.rootDir);
1043
- const merged = { ...DEFAULT_RULES };
1044
- if (input.ruleOverrides) for (const [id, sev] of Object.entries(input.ruleOverrides)) if (sev === "off") delete merged[id];
1045
- else merged[id] = sev;
1476
+ const framework = input.framework === "nuxt" ? "nuxt" : "vue";
1477
+ const allowlist = oxlintRuleAllowlist(framework);
1478
+ let defaults = { ...VUE_DEFAULT_RULES };
1479
+ if (framework === "nuxt") defaults = {
1480
+ ...defaults,
1481
+ ...NUXT_PLUGIN_RULES
1482
+ };
1483
+ const merged = { ...defaults };
1484
+ if (input.ruleOverrides) for (const [id, sev] of Object.entries(input.ruleOverrides)) {
1485
+ if (!allowlist.has(id)) continue;
1486
+ if (sev === "off") delete merged[id];
1487
+ else merged[id] = sev;
1488
+ }
1046
1489
  const rules = {};
1047
1490
  for (const [id, sev] of Object.entries(merged)) rules[id] = toOxlintSeverity(sev);
1048
1491
  const userConfig = resolveUserConfig(input.rootDir);
@@ -1050,7 +1493,7 @@ async function generateOxlintConfig(input) {
1050
1493
  $schema: "https://raw.githubusercontent.com/oxc-project/oxc/main/npm/oxlint/configuration_schema.json",
1051
1494
  ...userConfig ? { extends: [userConfig] } : {},
1052
1495
  plugins: ["vue"],
1053
- jsPlugins: [input.pluginPath],
1496
+ jsPlugins: input.pluginPaths,
1054
1497
  rules
1055
1498
  };
1056
1499
  const configPath = join(dir, ".oxlintrc.json");
@@ -1107,11 +1550,24 @@ function resolveVueDoctorPluginPath(fromDir) {
1107
1550
  }
1108
1551
  const selfMain = resolveFromSelf("@geoql/oxlint-plugin-vue-doctor");
1109
1552
  if (selfMain) return selfMain;
1110
- return throwResolveError(fromDir);
1553
+ return throwVueResolveError(fromDir);
1554
+ }
1555
+ function resolveNuxtDoctorPluginPath(fromDir) {
1556
+ const pkgJson = lookUpwards(fromDir, "node_modules/@geoql/oxlint-plugin-nuxt-doctor/package.json");
1557
+ if (pkgJson) {
1558
+ const main = readPkgMain(pkgJson) ?? "./dist/index.js";
1559
+ return resolve(dirname(pkgJson), main);
1560
+ }
1561
+ const selfMain = resolveFromSelf("@geoql/oxlint-plugin-nuxt-doctor");
1562
+ if (selfMain) return selfMain;
1563
+ return throwNuxtResolveError(fromDir);
1111
1564
  }
1112
- function throwResolveError(fromDir) {
1565
+ function throwVueResolveError(fromDir) {
1113
1566
  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.`);
1114
1567
  }
1568
+ function throwNuxtResolveError(fromDir) {
1569
+ 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.`);
1570
+ }
1115
1571
  function resolveOxlintBin(fromDir) {
1116
1572
  const pkgJson = lookUpwards(fromDir, "node_modules/oxlint/package.json");
1117
1573
  if (pkgJson) return resolve(dirname(pkgJson), "bin/oxlint");
@@ -1147,9 +1603,10 @@ async function runOxlint(opts) {
1147
1603
  "-c",
1148
1604
  opts.configPath,
1149
1605
  "--format",
1150
- "json",
1151
- opts.targetPath
1606
+ "json"
1152
1607
  ];
1608
+ if (opts.fix) args.push("--fix");
1609
+ args.push(opts.targetPath);
1153
1610
  const child = spawn(opts.oxlintBin, args, {
1154
1611
  cwd: opts.rootDir,
1155
1612
  stdio: [
@@ -1234,12 +1691,16 @@ function parseNdjson(text) {
1234
1691
  //#endregion
1235
1692
  //#region src/oxlint/run.ts
1236
1693
  async function runScriptPass(opts) {
1237
- const pluginPath = resolveVueDoctorPluginPath(opts.rootDir);
1694
+ const vuePluginPath = resolveVueDoctorPluginPath(opts.rootDir);
1238
1695
  const oxlintBin = resolveOxlintBin(opts.rootDir);
1696
+ let pluginPaths;
1697
+ if (opts.framework === "nuxt") pluginPaths = [resolveNuxtDoctorPluginPath(opts.rootDir), vuePluginPath];
1698
+ else pluginPaths = [vuePluginPath];
1239
1699
  const { configPath, cleanup } = await generateOxlintConfig({
1240
- pluginPath,
1700
+ pluginPaths,
1241
1701
  ruleOverrides: opts.ruleOverrides,
1242
- rootDir: opts.rootDir
1702
+ rootDir: opts.rootDir,
1703
+ framework: opts.framework
1243
1704
  });
1244
1705
  try {
1245
1706
  const raw = await runOxlint({
@@ -1247,7 +1708,8 @@ async function runScriptPass(opts) {
1247
1708
  targetPath: opts.targetPath,
1248
1709
  configPath,
1249
1710
  oxlintBin,
1250
- timeoutMs: opts.timeoutMs
1711
+ timeoutMs: opts.timeoutMs,
1712
+ fix: opts.fix
1251
1713
  });
1252
1714
  return {
1253
1715
  diagnostics: toCanonicalDiagnostics(raw.diagnostics, opts.rootDir),
@@ -1330,9 +1792,9 @@ async function parseSfcDescriptor(absPath) {
1330
1792
  }
1331
1793
  //#endregion
1332
1794
  //#region src/sfc/rules/no-mixed-options-and-composition-api.ts
1333
- const RULE_ID = "vue-doctor/sfc/no-mixed-options-and-composition-api";
1334
- const MESSAGE = "Mixed Options API in a <script setup> SFC. Move data/methods/computed/watch/lifecycle into <script setup> Composition API; keep <script> only for options like name/inheritAttrs. See https://vuejs.org/api/sfc-script-setup.html#usage-alongside-normal-script";
1335
- const RECOMMENDATION = "Move this option into the <script setup> block using the Composition API, or keep <script> only for options-only config such as name or inheritAttrs.";
1795
+ const RULE_ID$3 = "vue-doctor/sfc/no-mixed-options-and-composition-api";
1796
+ 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";
1797
+ 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.";
1336
1798
  const DISALLOWED = new Set([
1337
1799
  "data",
1338
1800
  "methods",
@@ -1399,7 +1861,7 @@ function locate(content, offset, startLine, startColumn) {
1399
1861
  column
1400
1862
  };
1401
1863
  }
1402
- function check$6(ctx) {
1864
+ function check$9(ctx) {
1403
1865
  const { script, scriptSetup } = ctx.descriptor;
1404
1866
  if (!script || !scriptSetup) return { diagnostics: [] };
1405
1867
  const lang = script.lang === "ts" ? "ts" : "js";
@@ -1419,35 +1881,241 @@ function check$6(ctx) {
1419
1881
  file: ctx.file,
1420
1882
  line,
1421
1883
  column,
1422
- ruleId: RULE_ID,
1884
+ ruleId: RULE_ID$3,
1423
1885
  severity: "error",
1424
- message: MESSAGE,
1886
+ message: MESSAGE$3,
1425
1887
  source: "sfc",
1426
- recommendation: RECOMMENDATION
1888
+ recommendation: RECOMMENDATION$3
1427
1889
  });
1428
1890
  }
1429
1891
  return { diagnostics };
1430
1892
  }
1431
1893
  //#endregion
1894
+ //#region src/nuxt/file-role.ts
1895
+ const PAGE_DIR = /^(?:app\/)?pages\//;
1896
+ const LAYOUT_DIR = /^(?:app\/)?layouts\//;
1897
+ function normalize(relPath) {
1898
+ return relPath.replace(/\\/g, "/").replace(/^\.\//, "");
1899
+ }
1900
+ /**
1901
+ * True when the path points at a Nuxt page component — a `.vue` file under
1902
+ * `pages/` (Nuxt 3 layout) or `app/pages/` (Nuxt 4 layout) at the project root.
1903
+ */
1904
+ function isNuxtPageFile(relPath) {
1905
+ const path = normalize(relPath);
1906
+ return PAGE_DIR.test(path) && path.endsWith(".vue");
1907
+ }
1908
+ /**
1909
+ * True when the path points at a Nitro server file — a `.ts` file under the
1910
+ * project-root `server/` directory.
1911
+ */
1912
+ function isNuxtServerFile(relPath) {
1913
+ const path = normalize(relPath);
1914
+ return path.startsWith("server/") && path.endsWith(".ts");
1915
+ }
1916
+ /**
1917
+ * True when the path points at a Nuxt layout component — a `.vue` file under
1918
+ * `layouts/` (Nuxt 3 layout) or `app/layouts/` (Nuxt 4 layout) at the project
1919
+ * root.
1920
+ */
1921
+ function isNuxtLayoutFile(relPath) {
1922
+ const path = normalize(relPath);
1923
+ return LAYOUT_DIR.test(path) && path.endsWith(".vue");
1924
+ }
1925
+ //#endregion
1926
+ //#region src/template/walk.ts
1927
+ const NODE_ELEMENT$2 = 1;
1928
+ function isElementNode(node) {
1929
+ return node !== null && node !== void 0 && node.type === NODE_ELEMENT$2;
1930
+ }
1931
+ function walkElements(root, visit) {
1932
+ const stack = [...root.children];
1933
+ while (stack.length > 0) {
1934
+ const node = stack.pop();
1935
+ if (!node) continue;
1936
+ if (isElementNode(node)) {
1937
+ visit(node);
1938
+ if (node.children) for (const child of node.children) stack.push(child);
1939
+ }
1940
+ }
1941
+ }
1942
+ //#endregion
1943
+ //#region src/sfc/rules/nuxt/no-mixed-app-and-root-layout.ts
1944
+ const RULE_ID$2 = "nuxt-doctor/ai-slop/no-mixed-app-and-root-layout";
1945
+ 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.";
1946
+ const RECOMMENDATION$2 = "Replace <NuxtLayout> in this layout file with <slot />. The layout slot is already provided by the parent layout wrapper.";
1947
+ function check$8(ctx) {
1948
+ if (!isNuxtLayoutFile(ctx.relativePath)) return { diagnostics: [] };
1949
+ const { template } = ctx.descriptor;
1950
+ if (!template || !template.ast) return { diagnostics: [] };
1951
+ let foundNuxtLayout = false;
1952
+ let nuxtLayoutNode;
1953
+ walkElements(template.ast, (el) => {
1954
+ if (el.tag === "NuxtLayout") {
1955
+ foundNuxtLayout = true;
1956
+ nuxtLayoutNode = el;
1957
+ }
1958
+ });
1959
+ if (!foundNuxtLayout) return { diagnostics: [] };
1960
+ const node = nuxtLayoutNode;
1961
+ return { diagnostics: [{
1962
+ file: ctx.file,
1963
+ line: node.loc.start.line,
1964
+ column: node.loc.start.column,
1965
+ endLine: node.loc.end.line,
1966
+ endColumn: node.loc.end.column,
1967
+ ruleId: RULE_ID$2,
1968
+ severity: "warn",
1969
+ message: MESSAGE$2,
1970
+ source: "sfc",
1971
+ recommendation: RECOMMENDATION$2
1972
+ }] };
1973
+ }
1974
+ //#endregion
1975
+ //#region src/sfc/rules/nuxt/og-image-defined.ts
1976
+ const RULE_ID$1 = "nuxt-doctor/seo/og-image-defined";
1977
+ 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.";
1978
+ const RECOMMENDATION$1 = "Add ogImage: \"/path/to/image.png\" to useSeoMeta / useHead, or install @nuxtjs/og-image.";
1979
+ function hasOgImageInCall(program) {
1980
+ for (const stmt of program.body) {
1981
+ if (stmt.type !== "ExpressionStatement") continue;
1982
+ const call = stmt.expression;
1983
+ if (call.type !== "CallExpression") continue;
1984
+ if (call.callee.type !== "Identifier") continue;
1985
+ const name = call.callee.name;
1986
+ if (name !== "useSeoMeta" && name !== "useHead") continue;
1987
+ const firstArg = call.arguments[0];
1988
+ if (!firstArg || firstArg.type !== "ObjectExpression") continue;
1989
+ for (const prop of firstArg.properties) {
1990
+ if (prop.type !== "Property") continue;
1991
+ if (prop.key.type === "Identifier" && prop.key.name === "ogImage") return true;
1992
+ if (prop.key.type === "Literal" && prop.key.value === "og:image") return true;
1993
+ }
1994
+ }
1995
+ return false;
1996
+ }
1997
+ function hasOgImageDep(packageJsonPath) {
1998
+ try {
1999
+ const raw = readFileSync(packageJsonPath, "utf8");
2000
+ const pkg = JSON.parse(raw);
2001
+ const deps = {
2002
+ ...pkg.dependencies,
2003
+ ...pkg.devDependencies
2004
+ };
2005
+ return "@nuxtjs/og-image" in deps || "nuxt-og-image" in deps;
2006
+ } catch {
2007
+ return false;
2008
+ }
2009
+ }
2010
+ function check$7(ctx) {
2011
+ if (!isNuxtPageFile(ctx.relativePath)) return { diagnostics: [] };
2012
+ const { scriptSetup } = ctx.descriptor;
2013
+ if (!scriptSetup) return { diagnostics: [] };
2014
+ const lang = scriptSetup.lang === "ts" ? "ts" : "js";
2015
+ const { program } = parseSync(`script.${lang}`, scriptSetup.content, {
2016
+ sourceType: "module",
2017
+ lang
2018
+ });
2019
+ if (hasOgImageInCall(program)) return { diagnostics: [] };
2020
+ if (ctx.projectInfo.packageJsonPath === null) return { diagnostics: [] };
2021
+ if (hasOgImageDep(ctx.projectInfo.packageJsonPath)) return { diagnostics: [] };
2022
+ const { line, column } = scriptSetup.loc.start;
2023
+ return { diagnostics: [{
2024
+ file: ctx.file,
2025
+ line,
2026
+ column,
2027
+ ruleId: RULE_ID$1,
2028
+ severity: "warn",
2029
+ message: MESSAGE$1,
2030
+ source: "sfc",
2031
+ recommendation: RECOMMENDATION$1
2032
+ }] };
2033
+ }
2034
+ //#endregion
2035
+ //#region src/sfc/rules/nuxt/use-seo-meta-on-public-page.ts
2036
+ const RULE_ID = "nuxt-doctor/seo/useSeoMeta-on-public-page";
2037
+ const MESSAGE = "Public page component is missing SEO metadata. Add useSeoMeta, useHead, or definePageMeta with a title so search engines can index it properly.";
2038
+ const RECOMMENDATION = "Call useSeoMeta({ title: \"...\" }) in<script setup> to define page title and meta tags for search engines and social previews.";
2039
+ function hasTitleInCall(program) {
2040
+ for (const stmt of program.body) {
2041
+ if (stmt.type !== "ExpressionStatement") continue;
2042
+ const call = stmt.expression;
2043
+ if (call.type !== "CallExpression") continue;
2044
+ if (call.callee.type !== "Identifier") continue;
2045
+ const name = call.callee.name;
2046
+ if (name !== "useSeoMeta" && name !== "useHead" && name !== "definePageMeta") continue;
2047
+ const firstArg = call.arguments[0];
2048
+ if (!firstArg || firstArg.type !== "ObjectExpression") continue;
2049
+ for (const prop of firstArg.properties) {
2050
+ if (prop.type !== "Property") continue;
2051
+ if (prop.key.type === "Identifier" && prop.key.name === "title") return true;
2052
+ if (prop.key.type === "Literal" && prop.key.value === "title") return true;
2053
+ }
2054
+ }
2055
+ return false;
2056
+ }
2057
+ function check$6(ctx) {
2058
+ if (!isNuxtPageFile(ctx.relativePath)) return { diagnostics: [] };
2059
+ const { scriptSetup } = ctx.descriptor;
2060
+ if (!scriptSetup) return { diagnostics: [] };
2061
+ const lang = scriptSetup.lang === "ts" ? "ts" : "js";
2062
+ const { program } = parseSync(`script.${lang}`, scriptSetup.content, {
2063
+ sourceType: "module",
2064
+ lang
2065
+ });
2066
+ if (hasTitleInCall(program)) return { diagnostics: [] };
2067
+ const { line, column } = scriptSetup.loc.start;
2068
+ return { diagnostics: [{
2069
+ file: ctx.file,
2070
+ line,
2071
+ column,
2072
+ ruleId: RULE_ID,
2073
+ severity: "warn",
2074
+ message: MESSAGE,
2075
+ source: "sfc",
2076
+ recommendation: RECOMMENDATION
2077
+ }] };
2078
+ }
2079
+ //#endregion
1432
2080
  //#region src/sfc/rules/index.ts
1433
- const SFC_RULES = [{
1434
- id: "vue-doctor/sfc/no-mixed-options-and-composition-api",
1435
- check: check$6
1436
- }];
2081
+ const SFC_RULES = [
2082
+ {
2083
+ id: "vue-doctor/sfc/no-mixed-options-and-composition-api",
2084
+ check: check$9
2085
+ },
2086
+ {
2087
+ id: "nuxt-doctor/seo/useSeoMeta-on-public-page",
2088
+ check: check$6
2089
+ },
2090
+ {
2091
+ id: "nuxt-doctor/seo/og-image-defined",
2092
+ check: check$7
2093
+ },
2094
+ {
2095
+ id: "nuxt-doctor/ai-slop/no-mixed-app-and-root-layout",
2096
+ check: check$8
2097
+ }
2098
+ ];
1437
2099
  //#endregion
1438
2100
  //#region src/sfc/run.ts
1439
2101
  async function runSfcPass(opts) {
1440
2102
  const all = [];
2103
+ const { projectInfo } = opts;
2104
+ const rootDir = projectInfo?.rootDirectory ?? process.cwd();
1441
2105
  for (const file of opts.files) {
1442
2106
  if (!file.endsWith(".vue")) continue;
1443
2107
  const descriptor = await parseSfcDescriptor(file);
1444
2108
  if (!descriptor) continue;
2109
+ const relativePath = relative(rootDir, file).replace(/\\/g, "/");
1445
2110
  for (const rule of SFC_RULES) {
1446
2111
  const override = opts.ruleOverrides?.[rule.id];
1447
2112
  if (override === "off") continue;
1448
2113
  const { diagnostics } = rule.check({
1449
2114
  file,
1450
- descriptor
2115
+ descriptor,
2116
+ rootDirectory: rootDir,
2117
+ relativePath,
2118
+ projectInfo
1451
2119
  });
1452
2120
  for (const d of diagnostics) all.push(override ? {
1453
2121
  ...d,
@@ -1458,6 +2126,183 @@ async function runSfcPass(opts) {
1458
2126
  return all;
1459
2127
  }
1460
2128
  //#endregion
2129
+ //#region src/nuxt/cross-file/run.ts
2130
+ function extractDataFetchingKeys(content, lang) {
2131
+ const keys = [];
2132
+ try {
2133
+ const { program } = parseSync(`script.${lang}`, content, {
2134
+ sourceType: "module",
2135
+ lang
2136
+ });
2137
+ for (const stmt of program.body) if (stmt.type === "VariableDeclaration") for (const decl of stmt.declarations) {
2138
+ const call = unwrapAwait(decl.init);
2139
+ if (call?.type !== "CallExpression") continue;
2140
+ extractKeyFromCall(call, keys);
2141
+ }
2142
+ else if (stmt.type === "ExpressionStatement") {
2143
+ const expr = unwrapAwait(stmt.expression);
2144
+ if (expr?.type === "CallExpression") extractKeyFromCall(expr, keys);
2145
+ }
2146
+ } catch {}
2147
+ return keys;
2148
+ }
2149
+ function unwrapAwait(node) {
2150
+ if (node?.type === "AwaitExpression") return node.argument;
2151
+ return node;
2152
+ }
2153
+ function extractKeyFromCall(call, keys) {
2154
+ if (call.callee.type !== "Identifier") return;
2155
+ const fnName = call.callee.name;
2156
+ if (fnName !== "useAsyncData" && fnName !== "useFetch") return;
2157
+ const keyArg = call.arguments[0];
2158
+ if (keyArg?.type === "Literal" && typeof keyArg.value === "string") keys.push(keyArg.value);
2159
+ else if (keyArg?.type === "TemplateLiteral" && keyArg.quasis.length === 1) {
2160
+ const val = keyArg.quasis[0]?.value?.raw;
2161
+ if (val) keys.push(val);
2162
+ }
2163
+ if (fnName === "useFetch" && call.arguments.length >= 2) {
2164
+ const optsArg = call.arguments[1];
2165
+ if (optsArg?.type === "ObjectExpression") {
2166
+ 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);
2167
+ }
2168
+ }
2169
+ }
2170
+ async function runCrossFilePass(opts) {
2171
+ const { files, projectInfo } = opts;
2172
+ if (projectInfo.framework !== "nuxt") return [];
2173
+ const rootDir = projectInfo.rootDirectory;
2174
+ const pageFiles = files.filter((f) => isNuxtPageFile(relative(rootDir, f).replace(/\\/g, "/")));
2175
+ if (pageFiles.length === 0) return [];
2176
+ const parsed = [];
2177
+ for (const file of pageFiles) {
2178
+ const descriptor = await parseSfcDescriptor(file);
2179
+ if (!descriptor) continue;
2180
+ const { scriptSetup } = descriptor;
2181
+ if (!scriptSetup) continue;
2182
+ parsed.push({
2183
+ file,
2184
+ relativePath: relative(rootDir, file).replace(/\\/g, "/"),
2185
+ keys: extractDataFetchingKeys(scriptSetup.content, scriptSetup.lang === "ts" ? "ts" : "js"),
2186
+ scriptSetupContent: scriptSetup.content,
2187
+ scriptSetupLang: scriptSetup.lang === "ts" ? "ts" : "js",
2188
+ scriptSetupLine: scriptSetup.loc.start.line
2189
+ });
2190
+ }
2191
+ return runCrossFileRules(parsed);
2192
+ }
2193
+ function runCrossFileRules(pages) {
2194
+ const all = [];
2195
+ all.push(...ruleNoSharedKeyAcrossPages(pages));
2196
+ all.push(...ruleSsrSafeOnMountedOnlyForClient(pages));
2197
+ return all;
2198
+ }
2199
+ function ruleNoSharedKeyAcrossPages(pages) {
2200
+ const keyToFiles = /* @__PURE__ */ new Map();
2201
+ for (const page of pages) for (const key of page.keys) {
2202
+ const existing = keyToFiles.get(key) ?? [];
2203
+ if (!existing.includes(page.file)) existing.push(page.file);
2204
+ keyToFiles.set(key, existing);
2205
+ }
2206
+ const diags = [];
2207
+ for (const [key, files] of keyToFiles) {
2208
+ if (files.length < 2) continue;
2209
+ for (const file of files) diags.push({
2210
+ file,
2211
+ line: 1,
2212
+ column: 1,
2213
+ ruleId: "nuxt-doctor/data-fetching/no-shared-key-across-pages",
2214
+ severity: "warn",
2215
+ 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.`,
2216
+ source: "cross-file",
2217
+ recommendation: `Rename the key to something page-specific, e.g. "page-${key}" or "users-${key}".`
2218
+ });
2219
+ }
2220
+ return diags;
2221
+ }
2222
+ const BROWSER_GLOBALS = new Set([
2223
+ "window",
2224
+ "document",
2225
+ "navigator",
2226
+ "localStorage",
2227
+ "sessionStorage",
2228
+ "location",
2229
+ "history",
2230
+ "fetch",
2231
+ "XMLHttpRequest",
2232
+ "matchMedia",
2233
+ "IntersectionObserver",
2234
+ "MutationObserver",
2235
+ "indexedDB",
2236
+ "webkit",
2237
+ "moz",
2238
+ "onmessage"
2239
+ ]);
2240
+ function ruleSsrSafeOnMountedOnlyForClient(pages) {
2241
+ const diags = [];
2242
+ for (const page of pages) {
2243
+ const { program } = parseSync(`script.${page.scriptSetupLang}`, page.scriptSetupContent, {
2244
+ sourceType: "module",
2245
+ lang: page.scriptSetupLang
2246
+ });
2247
+ for (const stmt of program.body) if (stmt.type === "VariableDeclaration") for (const decl of stmt.declarations) {
2248
+ const init = decl.init;
2249
+ if (!init) continue;
2250
+ if (init.type === "CallExpression") {
2251
+ const call = init;
2252
+ if (isSkippedReactiveCall(call)) continue;
2253
+ checkCallForBrowserGlobal(call, page, diags);
2254
+ } else if (init.type === "MemberExpression") checkMemberForBrowserGlobal(init, page, diags);
2255
+ }
2256
+ else if (stmt.type === "ExpressionStatement") {
2257
+ const expr = stmt.expression;
2258
+ if (expr.type === "CallExpression") {
2259
+ if (isSkippedReactiveCall(expr)) continue;
2260
+ checkCallForBrowserGlobal(expr, page, diags);
2261
+ } else if (expr.type === "MemberExpression") checkMemberForBrowserGlobal(expr, page, diags);
2262
+ }
2263
+ }
2264
+ return diags;
2265
+ }
2266
+ const CLIENT_SAFE_WRAPPERS = new Set([
2267
+ "onMounted",
2268
+ "watchEffect",
2269
+ "watch"
2270
+ ]);
2271
+ function isSkippedReactiveCall(call) {
2272
+ if (call.callee.type !== "Identifier") return false;
2273
+ return CLIENT_SAFE_WRAPPERS.has(call.callee.name);
2274
+ }
2275
+ function checkCallForBrowserGlobal(call, page, diags) {
2276
+ const callee = call.callee;
2277
+ if (callee.type !== "MemberExpression") return;
2278
+ const obj = callee.object;
2279
+ if (obj.type !== "Identifier" || !BROWSER_GLOBALS.has(obj.name)) return;
2280
+ diags.push({
2281
+ file: page.file,
2282
+ line: page.scriptSetupLine,
2283
+ column: 1,
2284
+ ruleId: "nuxt-doctor/data-fetching/ssr-safe-onMounted-only-for-client",
2285
+ severity: "warn",
2286
+ 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.`,
2287
+ source: "cross-file",
2288
+ recommendation: `Wrap "${obj.name}" access in onMounted(() => { /* code */ }) or use defineNuxtPlugin to run only on the client.`
2289
+ });
2290
+ }
2291
+ function checkMemberForBrowserGlobal(mem, page, diags) {
2292
+ const obj = mem.object;
2293
+ if (obj.type !== "Identifier" || !BROWSER_GLOBALS.has(obj.name)) return;
2294
+ diags.push({
2295
+ file: page.file,
2296
+ line: page.scriptSetupLine,
2297
+ column: 1,
2298
+ ruleId: "nuxt-doctor/data-fetching/ssr-safe-onMounted-only-for-client",
2299
+ severity: "warn",
2300
+ 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.`,
2301
+ source: "cross-file",
2302
+ recommendation: `Wrap "${obj.name}" access in onMounted(() => { /* code */ }) or use defineNuxtPlugin to run only on the client.`
2303
+ });
2304
+ }
2305
+ //#endregion
1461
2306
  //#region src/template/parse-sfc.ts
1462
2307
  const cache = /* @__PURE__ */ new Map();
1463
2308
  async function parseSfc(absPath) {
@@ -1495,23 +2340,6 @@ function findStaticAttr(el, attrName) {
1495
2340
  for (const prop of el.props) if (prop.type === 6 && prop.name === attrName) return prop;
1496
2341
  }
1497
2342
  //#endregion
1498
- //#region src/template/walk.ts
1499
- const NODE_ELEMENT$2 = 1;
1500
- function isElementNode(node) {
1501
- return node !== null && node !== void 0 && node.type === NODE_ELEMENT$2;
1502
- }
1503
- function walkElements(root, visit) {
1504
- const stack = [...root.children];
1505
- while (stack.length > 0) {
1506
- const node = stack.pop();
1507
- if (!node) continue;
1508
- if (isElementNode(node)) {
1509
- visit(node);
1510
- if (node.children) for (const child of node.children) stack.push(child);
1511
- }
1512
- }
1513
- }
1514
- //#endregion
1515
2343
  //#region src/template/rules/v-for-has-key.ts
1516
2344
  function check$5(ctx) {
1517
2345
  const diagnostics = [];
@@ -1830,6 +2658,7 @@ async function audit(config = {}) {
1830
2658
  exclude
1831
2659
  });
1832
2660
  const overallStart = performance.now();
2661
+ const project = await detectProject(rootDir);
1833
2662
  const templateStart = performance.now();
1834
2663
  const templateDiagnostics = lintEnabled ? await runTemplatePass({
1835
2664
  files,
@@ -1839,7 +2668,8 @@ async function audit(config = {}) {
1839
2668
  const sfcStart = performance.now();
1840
2669
  const sfcDiagnostics = lintEnabled ? await runSfcPass({
1841
2670
  files,
1842
- ruleOverrides: config.rules
2671
+ ruleOverrides: config.rules,
2672
+ projectInfo: project
1843
2673
  }) : [];
1844
2674
  const sfcElapsed = performance.now() - sfcStart;
1845
2675
  let scriptDiagnostics = [];
@@ -1849,7 +2679,10 @@ async function audit(config = {}) {
1849
2679
  const result = await runScriptPass({
1850
2680
  rootDir,
1851
2681
  targetPath: rootDir,
1852
- ruleOverrides: config.rules
2682
+ ruleOverrides: config.rules,
2683
+ framework: project.framework === "nuxt" ? "nuxt" : "vue",
2684
+ fix: config.fix === true && config.scopeFiles === void 0,
2685
+ fixExcludes: config.fixExcludes
1853
2686
  });
1854
2687
  scriptDiagnostics = result.diagnostics;
1855
2688
  oxlintStderr = result.stderr;
@@ -1858,13 +2691,12 @@ async function audit(config = {}) {
1858
2691
  if (process.env.DOCTOR_DEBUG) process.stderr.write(`[doctor-core] script pass failed: ${oxlintStderr}\n`);
1859
2692
  }
1860
2693
  const scriptElapsed = performance.now() - scriptStart;
1861
- const project = await detectProject(rootDir);
1862
2694
  let deadCodeDiagnostics = [];
1863
2695
  let deadCodeElapsed = 0;
1864
2696
  if (config.deadCode !== false) {
1865
2697
  const deadCodeStart = performance.now();
1866
2698
  try {
1867
- const { loadDoctorConfig } = await import("./load-DiK7Zv0B.js").then((n) => n.n);
2699
+ const { loadDoctorConfig } = await import("./load-yXDi-5ti.js").then((n) => n.n);
1868
2700
  deadCodeDiagnostics = dedupeDeadCodeAgainstLint(await checkDeadCode({
1869
2701
  projectInfo: project,
1870
2702
  doctorConfig: await loadDoctorConfig(rootDir),
@@ -1874,7 +2706,9 @@ async function audit(config = {}) {
1874
2706
  ...sfcDiagnostics,
1875
2707
  ...scriptDiagnostics
1876
2708
  ]).filter((d) => config.rules?.[d.ruleId] !== "off");
1877
- } catch {}
2709
+ } catch (err) {
2710
+ if (process.env.DOCTOR_DEBUG) process.stderr.write(`[doctor-core] dead-code pass failed: ${String(err)}\n`);
2711
+ }
1878
2712
  deadCodeElapsed = performance.now() - deadCodeStart;
1879
2713
  }
1880
2714
  let buildQualityDiagnostics = [];
@@ -1897,6 +2731,29 @@ async function audit(config = {}) {
1897
2731
  } : d;
1898
2732
  });
1899
2733
  } catch {}
2734
+ let nuxtProjectDiagnostics = [];
2735
+ if (project.framework === "nuxt") try {
2736
+ nuxtProjectDiagnostics = (await checkNuxtProject(project)).filter((d) => config.rules?.[d.ruleId] !== "off").map((d) => {
2737
+ const override = config.rules?.[d.ruleId];
2738
+ return override ? {
2739
+ ...d,
2740
+ severity: override
2741
+ } : d;
2742
+ });
2743
+ } catch {}
2744
+ let crossFileDiagnostics = [];
2745
+ if (project.framework === "nuxt") try {
2746
+ crossFileDiagnostics = (await runCrossFilePass({
2747
+ files,
2748
+ projectInfo: project
2749
+ })).filter((d) => config.rules?.[d.ruleId] !== "off").map((d) => {
2750
+ const override = config.rules?.[d.ruleId];
2751
+ return override ? {
2752
+ ...d,
2753
+ severity: override
2754
+ } : d;
2755
+ });
2756
+ } catch {}
1900
2757
  const elapsedMs = performance.now() - overallStart;
1901
2758
  const timings = {
1902
2759
  template: templateElapsed,
@@ -1905,7 +2762,7 @@ async function audit(config = {}) {
1905
2762
  deadCode: deadCodeElapsed,
1906
2763
  total: elapsedMs
1907
2764
  };
1908
- let afterDisables = applyInlineDisables(mergeDiagnostics(templateDiagnostics, sfcDiagnostics, scriptDiagnostics, deadCodeDiagnostics, buildQualityDiagnostics, depsDiagnostics), { respect: config.respectInlineDisables !== false });
2765
+ let afterDisables = applyInlineDisables(mergeDiagnostics(templateDiagnostics, sfcDiagnostics, scriptDiagnostics, deadCodeDiagnostics, buildQualityDiagnostics, depsDiagnostics, nuxtProjectDiagnostics, crossFileDiagnostics), { respect: config.respectInlineDisables !== false });
1909
2766
  if (config.scopeFiles) {
1910
2767
  const scope = new Set(config.scopeFiles.map((f) => resolve(rootDir, f)));
1911
2768
  afterDisables = afterDisables.filter((d) => scope.has(resolve(rootDir, d.file)));
@@ -1960,7 +2817,111 @@ function mergeCliOverrides(resolved, cli) {
1960
2817
  threshold: cli.threshold ?? resolved.threshold,
1961
2818
  rules,
1962
2819
  source: resolved.source,
1963
- configFile: resolved.configFile
2820
+ configFile: resolved.configFile,
2821
+ ...cli.fixExcludes ?? resolved.fixExcludes ? { fixExcludes: cli.fixExcludes ?? resolved.fixExcludes } : {}
2822
+ };
2823
+ }
2824
+ //#endregion
2825
+ //#region src/init/index.ts
2826
+ function parseExcludeList(raw) {
2827
+ if (!raw) return void 0;
2828
+ const parts = raw.split(",").map((entry) => entry.trim()).filter((entry) => entry.length > 0);
2829
+ return parts.length > 0 ? parts : void 0;
2830
+ }
2831
+ function normalizeInitAnswers(raw) {
2832
+ return {
2833
+ configFormat: raw.target ?? "ts",
2834
+ preset: raw.preset ?? "recommended",
2835
+ threshold: typeof raw.threshold === "number" ? raw.threshold : void 0,
2836
+ exclude: parseExcludeList(raw.exclude)
2837
+ };
2838
+ }
2839
+ function frameworkLabel(project) {
2840
+ if (project.framework === "nuxt") return `Nuxt ${project.nuxtVersion ?? "unknown"}`;
2841
+ if (project.framework === "vue") return `Vue ${project.vueVersion ?? "unknown"}`;
2842
+ return "unknown project";
2843
+ }
2844
+ function renderDetectSummary(project, sfcCount) {
2845
+ const ts = project.capabilities.has("typescript") ? " · TS" : "";
2846
+ const noun = sfcCount === 1 ? "SFC" : "SFCs";
2847
+ return `detected: ${frameworkLabel(project)}${ts} · ${sfcCount} ${noun}`;
2848
+ }
2849
+ async function detectSummary(dir) {
2850
+ return renderDetectSummary(await detectProject(dir), (await listSourceFiles({
2851
+ rootDir: dir,
2852
+ include: ["**/*.vue"],
2853
+ exclude: ["**/node_modules/**"]
2854
+ })).length);
2855
+ }
2856
+ function buildUserConfig(options) {
2857
+ const config = { preset: options.preset };
2858
+ if (options.threshold !== void 0) config.threshold = options.threshold;
2859
+ if (options.exclude && options.exclude.length > 0) config.exclude = options.exclude;
2860
+ return config;
2861
+ }
2862
+ function renderTsConfig(options) {
2863
+ const config = buildUserConfig(options);
2864
+ const lines = [
2865
+ "import { defineConfig } from '@geoql/doctor-core';",
2866
+ "",
2867
+ "export default defineConfig({",
2868
+ ` preset: '${config.preset}',`
2869
+ ];
2870
+ if (config.threshold !== void 0) lines.push(` threshold: ${config.threshold},`);
2871
+ if (config.exclude) {
2872
+ const globs = config.exclude.map((glob) => `'${glob}'`).join(", ");
2873
+ lines.push(` exclude: [${globs}],`);
2874
+ }
2875
+ lines.push("});");
2876
+ return `${lines.join("\n")}\n`;
2877
+ }
2878
+ function renderJsonConfig(options) {
2879
+ return `${JSON.stringify(buildUserConfig(options), null, 2)}\n`;
2880
+ }
2881
+ async function readPackageJsonRaw(path) {
2882
+ try {
2883
+ return JSON.parse(await readFile(path, "utf8"));
2884
+ } catch {
2885
+ return null;
2886
+ }
2887
+ }
2888
+ async function planInit(options) {
2889
+ const writes = [];
2890
+ const packageJsonPath = join(options.dir, "package.json");
2891
+ const pkg = { ...await readPackageJsonRaw(packageJsonPath) ?? {} };
2892
+ let conflict = false;
2893
+ let conflictPath = null;
2894
+ if (options.configFormat === "package-json") {
2895
+ if (pkg.doctor !== void 0) {
2896
+ conflict = true;
2897
+ conflictPath = packageJsonPath;
2898
+ }
2899
+ pkg.doctor = buildUserConfig(options);
2900
+ } else {
2901
+ const fileName = options.configFormat === "ts" ? "doctor.config.ts" : "doctor.config.json";
2902
+ const configPath = join(options.dir, fileName);
2903
+ if (await pathExists(configPath)) {
2904
+ conflict = true;
2905
+ conflictPath = configPath;
2906
+ }
2907
+ const content = options.configFormat === "ts" ? renderTsConfig(options) : renderJsonConfig(options);
2908
+ writes.push({
2909
+ path: configPath,
2910
+ content
2911
+ });
2912
+ }
2913
+ pkg.scripts = {
2914
+ ...pkg.scripts ?? {},
2915
+ "doctor:check": options.binName
2916
+ };
2917
+ writes.push({
2918
+ path: packageJsonPath,
2919
+ content: `${JSON.stringify(pkg, null, 2)}\n`
2920
+ });
2921
+ return {
2922
+ writes,
2923
+ conflict,
2924
+ conflictPath
1964
2925
  };
1965
2926
  }
1966
2927
  //#endregion
@@ -2007,6 +2968,47 @@ async function listChangedFiles(options) {
2007
2968
  return [...new Set(absolute)].sort();
2008
2969
  }
2009
2970
  //#endregion
2971
+ //#region src/project-info/list-workspace-packages.ts
2972
+ function parsePackageGlobs(yaml) {
2973
+ const globs = [];
2974
+ let inPackages = false;
2975
+ for (const raw of yaml.split("\n")) {
2976
+ if (!inPackages) {
2977
+ if (/^packages:/.test(raw)) inPackages = true;
2978
+ continue;
2979
+ }
2980
+ const item = raw.match(/^\s*-\s*(.+?)\s*$/);
2981
+ if (item) {
2982
+ globs.push(item[1].replace(/^['"]|['"]$/g, ""));
2983
+ continue;
2984
+ }
2985
+ if (raw.trim() === "") continue;
2986
+ break;
2987
+ }
2988
+ return globs;
2989
+ }
2990
+ async function listWorkspacePackages(rootDir) {
2991
+ const { root, kind } = await findMonorepoRoot(rootDir);
2992
+ if (kind !== "pnpm") return [];
2993
+ const manifests = await glob(parsePackageGlobs(await readFile(join(root, "pnpm-workspace.yaml"), "utf8")).map((g) => `${g}/package.json`), {
2994
+ cwd: root,
2995
+ absolute: true,
2996
+ onlyFiles: true,
2997
+ dot: false
2998
+ });
2999
+ const packages = [];
3000
+ for (const manifest of manifests) {
3001
+ const dir = dirname(manifest);
3002
+ const pkg = await readPackageJson(dir);
3003
+ if (pkg?.name) packages.push({
3004
+ name: pkg.name,
3005
+ dir
3006
+ });
3007
+ }
3008
+ packages.sort((a, b) => a.name.localeCompare(b.name));
3009
+ return packages;
3010
+ }
3011
+ //#endregion
2010
3012
  //#region src/reporters/docs-url.ts
2011
3013
  function docsUrl(ruleId) {
2012
3014
  return `https://docs.doctor.geoql.in/rules/${ruleId}`;
@@ -2371,6 +3373,53 @@ function jsonCompactReport(input) {
2371
3373
  return `${JSON.stringify(buildDoctorReport(input))}\n`;
2372
3374
  }
2373
3375
  //#endregion
3376
+ //#region src/reporters/pr-comment.ts
3377
+ const DOCS_BASE = "https://docs.doctor.geoql.in/rules/";
3378
+ const MAX_PER_SECTION = 5;
3379
+ function binName(toolName) {
3380
+ return toolName.replace("@geoql/", "");
3381
+ }
3382
+ function relativeLocation(diag, rootDirectory) {
3383
+ return `${relative(rootDirectory, diag.file).replaceAll("\\", "/")}:${diag.line}`;
3384
+ }
3385
+ function findingLine(diag, rootDirectory) {
3386
+ return `- \`${relativeLocation(diag, rootDirectory)}\` ${diag.ruleId} — ${diag.message}`;
3387
+ }
3388
+ function sortBySeverityFilter(diagnostics, severity, rootDirectory) {
3389
+ return diagnostics.filter((d) => d.severity === severity).sort((a, b) => {
3390
+ const byFile = compareStrings(relative(rootDirectory, a.file).replaceAll("\\", "/"), relative(rootDirectory, b.file).replaceAll("\\", "/"));
3391
+ if (byFile !== 0) return byFile;
3392
+ if (a.line !== b.line) return a.line - b.line;
3393
+ if (a.column !== b.column) return a.column - b.column;
3394
+ return compareStrings(a.ruleId, b.ruleId);
3395
+ });
3396
+ }
3397
+ function section(title, diagnostics, rootDirectory) {
3398
+ if (diagnostics.length === 0) return [];
3399
+ const lines = ["", `### ${title}`];
3400
+ for (const d of diagnostics.slice(0, MAX_PER_SECTION)) lines.push(findingLine(d, rootDirectory));
3401
+ return lines;
3402
+ }
3403
+ function prCommentReport(input) {
3404
+ const score = input.score.score;
3405
+ const header = `## 🛡 ${input.toolName} — Score: ${score}`;
3406
+ const errors = sortBySeverityFilter(input.diagnostics, "error", input.rootDirectory);
3407
+ const warnings = sortBySeverityFilter(input.diagnostics, "warn", input.rootDirectory);
3408
+ const actionable = errors.length + warnings.length;
3409
+ if (actionable === 0) return `${header}\n\n✓ No actionable findings. Score: ${score}\n`;
3410
+ const topRule = (errors[0] ?? warnings[0]).ruleId;
3411
+ return `${[
3412
+ header,
3413
+ "",
3414
+ `**${actionable} findings** (${errors.length} errors, ${warnings.length} warnings)`,
3415
+ ...section("Errors", errors, input.rootDirectory),
3416
+ ...section("Warnings", warnings, input.rootDirectory),
3417
+ "",
3418
+ "---",
3419
+ `[View the docs](${DOCS_BASE}) · Run \`${binName(input.toolName)} explain ${topRule}\` for details.`
3420
+ ].join("\n")}\n`;
3421
+ }
3422
+ //#endregion
2374
3423
  //#region src/reporters/pretty.ts
2375
3424
  const colors = pc.createColors(true);
2376
3425
  const colorTheme = {
@@ -2421,9 +3470,10 @@ function format(input, kind = "agent", options) {
2421
3470
  if (kind === "json-compact") return jsonCompactReport(input);
2422
3471
  if (kind === "sarif") return sarifReport(input);
2423
3472
  if (kind === "html") return htmlReport(input);
3473
+ if (kind === "pr-comment") return prCommentReport(input);
2424
3474
  return agentReport(input, options);
2425
3475
  }
2426
3476
  //#endregion
2427
- export { BUILT_IN_RECOMMENDED, ConfigCycleError, ConfigFileNotFoundError, DOCTOR_REPORT_SCHEMA_VERSION, DeadCodeImportFailed, DeadCodeTimeoutError, InvalidConfigError, OxlintOutputTooLarge, OxlintSpawnFailed, RULE_REGISTRY, agentReport, applyInlineDisables, audit, buildDoctorReport, checkBuildQuality, checkDeadCode, checkDeps, dedupeDeadCodeAgainstLint, defineConfig, detectProject, docsUrl, encodeAnnotation, encodeAnnotations, format, jsonCompactReport, jsonReport, listChangedFiles, listRules, loadAllRuleDocs, loadDoctorConfig, loadRuleDoc, mergeCliOverrides, parseDirectives, prettyReport, renderVerboseTrace, sarifReport, scoreDiagnostics, validateConfig };
3477
+ 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, detectSummary, docsUrl, encodeAnnotation, encodeAnnotations, findMonorepoRoot, format, isNuxtLayoutFile, isNuxtPageFile, isNuxtServerFile, jsonCompactReport, jsonReport, listChangedFiles, listRules, listWorkspacePackages, loadAllRuleDocs, loadDoctorConfig, loadRuleDoc, mergeCliOverrides, normalizeInitAnswers, parseDirectives, parseExcludeList, planInit, prCommentReport, prettyReport, renderDetectSummary, renderVerboseTrace, runCrossFilePass, sarifReport, scoreDiagnostics, validateConfig };
2428
3478
 
2429
3479
  //# sourceMappingURL=index.js.map