@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/README.md +70 -10
- package/dist/index.d.ts +108 -3
- package/dist/index.js +1245 -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,
|
|
@@ -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
|
-
|
|
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$
|
|
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$
|
|
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$
|
|
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$
|
|
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
|
|
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;
|
|
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
|
|
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
|
-
|
|
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
|
-
})
|
|
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
|
|
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
|
|
1044
|
-
|
|
1045
|
-
|
|
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:
|
|
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
|
|
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
|
|
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
|
|
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
|
-
|
|
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$
|
|
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
|
-
|
|
1435
|
-
|
|
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-
|
|
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
|