@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/README.md +70 -10
- package/dist/index.d.ts +108 -3
- package/dist/index.js +1250 -200
- package/dist/index.js.map +1 -1
- package/dist/{load-DiK7Zv0B.js → load-yXDi-5ti.js} +337 -2
- package/dist/load-yXDi-5ti.js.map +1 -0
- package/package.json +3 -2
- package/dist/load-DiK7Zv0B.js.map +0 -1
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-
|
|
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$
|
|
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$
|
|
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$
|
|
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$
|
|
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$
|
|
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$
|
|
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$
|
|
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$
|
|
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
|
-
|
|
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$
|
|
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$
|
|
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$
|
|
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$
|
|
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
|
|
804
|
-
|
|
805
|
-
|
|
806
|
-
|
|
807
|
-
|
|
808
|
-
|
|
809
|
-
|
|
810
|
-
|
|
811
|
-
|
|
812
|
-
|
|
813
|
-
|
|
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
|
|
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
|
-
|
|
923
|
-
|
|
924
|
-
|
|
925
|
-
|
|
926
|
-
|
|
927
|
-
|
|
928
|
-
|
|
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
|
|
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
|
|
1044
|
-
|
|
1045
|
-
|
|
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:
|
|
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
|
|
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
|
|
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
|
|
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
|
-
|
|
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$
|
|
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
|
-
|
|
1435
|
-
|
|
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-
|
|
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
|