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