From ed7ce5f4dfe9534b041c1a027522dd08e7bb4c5f Mon Sep 17 00:00:00 2001 From: PeachScript Date: Thu, 28 Jul 2022 17:02:02 +0800 Subject: [PATCH 01/10] feat: add basic doctor command --- src/commands/doctor.ts | 45 ++++++++++++++++++++++++++++++++++++++++++ src/doctor/index.ts | 27 +++++++++++++++++++++++++ src/preset.ts | 1 + 3 files changed, 73 insertions(+) create mode 100644 src/commands/doctor.ts create mode 100644 src/doctor/index.ts diff --git a/src/commands/doctor.ts b/src/commands/doctor.ts new file mode 100644 index 0000000..0a34792 --- /dev/null +++ b/src/commands/doctor.ts @@ -0,0 +1,45 @@ +import { chalk } from '@umijs/utils'; +import doctor, { registerRules } from '../doctor'; +import { IApi } from '../types'; + +function getSummaryLog(summary: { error: number; warn: number }) { + const color = summary.error ? chalk.red : chalk.yellow; + const total = summary.error + summary.warn; + + return color.bold(` +💊 ${total} problems (${summary.error} errors ${summary.warn} warnings)`); +} + +export default (api: IApi) => { + registerRules(api); + + api.registerCommand({ + name: 'doctor', + description: 'check your project for potential problems', + async fn() { + const report = await doctor(api); + const summary = { error: 0, warn: 0 }; + + report + .sort((p) => (p.type === 'error' ? -1 : 1)) + .forEach((item) => { + summary[item.type] += 1; + console.log(` +${chalk[item.type === 'error' ? 'red' : 'yellow']( + `${item.type.toUpperCase()}`.padStart(8), +)} ${item.problem} +${chalk.green('SOLUTION')} ${item.solution}`); + }); + + if (summary.error || summary.warn) { + console.log(getSummaryLog(summary)); + + if (summary.error) { + process.exit(1); + } + } else { + console.log(chalk.bold.green('🎉 This project looks fine!')); + } + }, + }); +}; diff --git a/src/doctor/index.ts b/src/doctor/index.ts new file mode 100644 index 0000000..5e923be --- /dev/null +++ b/src/doctor/index.ts @@ -0,0 +1,27 @@ +import fs from 'fs'; +import path from 'path'; +import { type IApi } from '../types'; + +export type IDoctorReport = { + type: 'error' | 'warn'; + problem: string; + solution: string; +}[]; + +/** + * register all built-in rules + */ +export function registerRules(api: IApi) { + const ruleDir = path.join(__dirname, 'rules'); + const rules = fs + .readdirSync(ruleDir, { withFileTypes: true }) + .filter((f) => f.isFile() && /(? path.join(ruleDir, f.name)); + + api.registerPlugins(rules); +} + +export default async (api: IApi): Promise => { + api; + return []; +}; diff --git a/src/preset.ts b/src/preset.ts index e80f263..40abb49 100644 --- a/src/preset.ts +++ b/src/preset.ts @@ -8,6 +8,7 @@ export default (api: IApi) => { // commands require.resolve('./commands/dev'), + require.resolve('./commands/doctor'), require.resolve('./commands/build'), require.resolve('./commands/changelog'), require.resolve('./commands/prebundle'), From ea1cbc609b7c649124bdddf1b921b9cc529d9ac1 Mon Sep 17 00:00:00 2001 From: PeachScript Date: Thu, 28 Jul 2022 17:05:56 +0800 Subject: [PATCH 02/10] feat: doctor support regular checkup --- src/builder/config.ts | 3 ++- src/doctor/index.ts | 40 +++++++++++++++++++++++++++++++++++++--- src/prebundler/config.ts | 2 +- src/registerMethods.ts | 2 +- src/types.ts | 17 ++++++++++++++++- 5 files changed, 57 insertions(+), 7 deletions(-) diff --git a/src/builder/config.ts b/src/builder/config.ts index 94e1764..017882c 100644 --- a/src/builder/config.ts +++ b/src/builder/config.ts @@ -39,6 +39,7 @@ export interface IBundlessConfig type: IFatherBuildTypes.BUNDLESS; format: IFatherBundlessTypes; input: string; + output: NonNullable; } /** @@ -161,7 +162,7 @@ export function normalizeUserConfig( formatName === 'esm' ? IFatherPlatformTypes.BROWSER : IFatherPlatformTypes.NODE; - const bundlessConfig: Omit = { + const bundlessConfig: Omit = { type: IFatherBuildTypes.BUNDLESS, format: formatName as IFatherBundlessTypes, platform: userConfig.platform || defaultPlatform, diff --git a/src/doctor/index.ts b/src/doctor/index.ts index 5e923be..fb51ca9 100644 --- a/src/doctor/index.ts +++ b/src/doctor/index.ts @@ -1,6 +1,12 @@ import fs from 'fs'; import path from 'path'; -import { type IApi } from '../types'; +import { + IBundleConfig, + IBundlessConfig, + normalizeUserConfig as getBuildConfig, +} from '../builder/config'; +import { getConfig as getPreBundleConfig } from '../prebundler/config'; +import { IFatherBuildTypes, type IApi } from '../types'; export type IDoctorReport = { type: 'error' | 'warn'; @@ -22,6 +28,34 @@ export function registerRules(api: IApi) { } export default async (api: IApi): Promise => { - api; - return []; + // generate configs + const [bundleConfigs, bundlessConfigs] = getBuildConfig( + api.config, + api.pkg, + ).reduce<[IBundleConfig[], IBundlessConfig[]]>( + (ret, config) => { + if (config.type === IFatherBuildTypes.BUNDLE) { + ret[0].push(config); + } else { + ret[1].push(config); + } + + return ret; + }, + [[], []], + ); + const preBundleConfig = getPreBundleConfig({ + userConfig: api.config.prebundle || {}, + pkg: api.pkg, + cwd: api.cwd, + }); + + // regular checkup + const regularReport: IDoctorReport = await api.applyPlugins({ + key: 'addRegularCheckup', + type: api.ApplyPluginsType.add, + args: { bundleConfigs, bundlessConfigs, preBundleConfig }, + }); + + return [...regularReport.filter(Boolean)]; }; diff --git a/src/prebundler/config.ts b/src/prebundler/config.ts index 3c2cb16..91b1f0a 100644 --- a/src/prebundler/config.ts +++ b/src/prebundler/config.ts @@ -11,7 +11,7 @@ import { getNestedTypeDepsForPkg, } from '../utils'; -interface IPreBundleConfig { +export interface IPreBundleConfig { deps: Record; dts: Record< string, diff --git a/src/registerMethods.ts b/src/registerMethods.ts index ae41d8b..27b4afa 100644 --- a/src/registerMethods.ts +++ b/src/registerMethods.ts @@ -1,7 +1,7 @@ import { IApi } from './types'; export default (api: IApi) => { - ['addJSTransformer'].forEach((name) => { + ['addJSTransformer', 'addRegularCheckup'].forEach((name) => { api.registerMethod({ name }); }); }; diff --git a/src/types.ts b/src/types.ts index fc81d79..c8aec8b 100644 --- a/src/types.ts +++ b/src/types.ts @@ -2,8 +2,11 @@ import type { Compiler } from '@umijs/bundler-webpack'; import type Autoprefixer from '@umijs/bundler-webpack/compiled/autoprefixer'; import type IWebpackChain from '@umijs/bundler-webpack/compiled/webpack-5-chain'; import type { IConfig as IBundlerWebpackConfig } from '@umijs/bundler-webpack/dist/types'; -import type { IServicePluginAPI, PluginAPI } from '@umijs/core'; +import type { IAdd, IServicePluginAPI, PluginAPI } from '@umijs/core'; import type { ITransformerItem } from './builder/bundless/loaders/javascript'; +import type { IBundleConfig, IBundlessConfig } from './builder/config'; +import type { IDoctorReport } from './doctor'; +import type { IPreBundleConfig } from './prebundler/config'; export type { IBundlessLoader, @@ -16,6 +19,18 @@ export type IApi = PluginAPI & * add bundless js transformer */ addJSTransformer: (item: ITransformerItem) => void; + + /** + * checkup for doctor + */ + addRegularCheckup: IAdd< + { + bundleConfigs: IBundleConfig[]; + bundlessConfigs: IBundlessConfig[]; + preBundleConfig: IPreBundleConfig; + }, + IDoctorReport | IDoctorReport[0] | void + >; }; export enum IFatherBuildTypes { From fa0ea88864fdd4f24d75a19dcb7211edb8787055 Mon Sep 17 00:00:00 2001 From: PeachScript Date: Thu, 28 Jul 2022 17:06:29 +0800 Subject: [PATCH 03/10] feat: add regular checkup rules --- src/doctor/rules/DUP_IN_PEER_DEPS.ts | 23 +++++++++++ src/doctor/rules/EFFECTS_IN_SIDE_EFFECTS.ts | 23 +++++++++++ src/doctor/rules/PACK_FILES_MISSING.ts | 42 +++++++++++++++++++++ src/doctor/rules/PREFER_BABEL_RUNTIME.ts | 26 +++++++++++++ src/doctor/rules/PREFER_PACK_FILES.ts | 15 ++++++++ 5 files changed, 129 insertions(+) create mode 100644 src/doctor/rules/DUP_IN_PEER_DEPS.ts create mode 100644 src/doctor/rules/EFFECTS_IN_SIDE_EFFECTS.ts create mode 100644 src/doctor/rules/PACK_FILES_MISSING.ts create mode 100644 src/doctor/rules/PREFER_BABEL_RUNTIME.ts create mode 100644 src/doctor/rules/PREFER_PACK_FILES.ts diff --git a/src/doctor/rules/DUP_IN_PEER_DEPS.ts b/src/doctor/rules/DUP_IN_PEER_DEPS.ts new file mode 100644 index 0000000..ad407d8 --- /dev/null +++ b/src/doctor/rules/DUP_IN_PEER_DEPS.ts @@ -0,0 +1,23 @@ +import type { IDoctorReport } from '..'; +import type { IApi } from '../../types'; + +export default (api: IApi) => { + api.addRegularCheckup(() => { + const warns: IDoctorReport = []; + + if (api.pkg.peerDependencies && api.pkg.dependencies) { + Object.keys(api.pkg.peerDependencies).forEach((pkg) => { + if (api.pkg.dependencies![pkg]) { + warns.push({ + type: 'warn', + problem: `The package \`${pkg}\` is both a peerDependency and a dependency`, + solution: + 'Remove one from the package.json file base on project requirements', + }); + } + }); + } + + return warns; + }); +}; diff --git a/src/doctor/rules/EFFECTS_IN_SIDE_EFFECTS.ts b/src/doctor/rules/EFFECTS_IN_SIDE_EFFECTS.ts new file mode 100644 index 0000000..6a54330 --- /dev/null +++ b/src/doctor/rules/EFFECTS_IN_SIDE_EFFECTS.ts @@ -0,0 +1,23 @@ +import type { IDoctorReport } from '..'; +import type { IApi } from '../../types'; + +export default (api: IApi) => { + api.addRegularCheckup(() => { + if (Array.isArray(api.pkg.sideEffects)) { + const result: IDoctorReport = []; + + api.pkg.sideEffects.forEach((s) => { + if (s.startsWith('*.')) { + result.push({ + type: 'warn', + problem: `The \`${s}\` sideEffect syntax only match top-level files in Rollup.js, but match all in Webpack`, + solution: + 'Prefix `**/` for this sideEffect value in the package.json file', + }); + } + }); + + return result; + } + }); +}; diff --git a/src/doctor/rules/PACK_FILES_MISSING.ts b/src/doctor/rules/PACK_FILES_MISSING.ts new file mode 100644 index 0000000..ad44fa1 --- /dev/null +++ b/src/doctor/rules/PACK_FILES_MISSING.ts @@ -0,0 +1,42 @@ +import path from 'path'; +import type { IDoctorReport } from '..'; +import type { IApi } from '../../types'; + +export default (api: IApi) => { + api.addRegularCheckup( + ({ bundleConfigs, bundlessConfigs, preBundleConfig }) => { + if (api.pkg.files) { + const files: string[] = api.pkg.files.map((f: string) => + path.resolve(f), + ); + const entities: string[] = []; + const errors: IDoctorReport = []; + + // dist entities + bundleConfigs.forEach((c) => entities.push(c.output.path)); + bundlessConfigs.forEach((c) => entities.push(c.output)); + Object.values(preBundleConfig.deps).forEach((c) => + entities.push(path.basename(c.output)), + ); + Object.values(preBundleConfig.dts).forEach((c) => + entities.push(path.basename(c.output)), + ); + + // TODO: main/module entities + // TODO: outside import entities (eg: template) + + entities.forEach((output) => { + if (files.every((f) => !output.startsWith(f))) { + errors.push({ + type: 'error', + problem: `The output entity \'${output}\` is not in the \`files\` field of the package.json file, it will not be published`, + solution: 'Add it to the `files` field of the package.json file', + }); + } + }); + + return errors; + } + }, + ); +}; diff --git a/src/doctor/rules/PREFER_BABEL_RUNTIME.ts b/src/doctor/rules/PREFER_BABEL_RUNTIME.ts new file mode 100644 index 0000000..beb4fad --- /dev/null +++ b/src/doctor/rules/PREFER_BABEL_RUNTIME.ts @@ -0,0 +1,26 @@ +import { + IApi, + IFatherJSTransformerTypes, + IFatherPlatformTypes, +} from '../../types'; + +export default (api: IApi) => { + api.addRegularCheckup(({ bundlessConfigs }) => { + if ( + bundlessConfigs.find( + (c) => + c.transformer === IFatherJSTransformerTypes.BABEL && + c.platform === IFatherPlatformTypes.BROWSER, + ) && + !api.pkg.dependencies?.['@babel/runtime'] + ) { + return { + type: 'warn', + problem: + '@babel/runtime is not installed, the inline runtime helpers will increase dist file size', + solution: + 'Declare @babel/runtime as a dependency in the package.json file', + }; + } + }); +}; diff --git a/src/doctor/rules/PREFER_PACK_FILES.ts b/src/doctor/rules/PREFER_PACK_FILES.ts new file mode 100644 index 0000000..1f6b308 --- /dev/null +++ b/src/doctor/rules/PREFER_PACK_FILES.ts @@ -0,0 +1,15 @@ +import type { IApi } from '../../types'; + +export default (api: IApi) => { + api.addRegularCheckup(() => { + if (!api.pkg.files) { + return { + type: 'warn', + problem: + 'No `files` field in the package.json file, all the non-gitignore files will be published', + solution: + 'Describe the entries that need to be published in `files` field of the package.json file', + }; + } + }); +}; From ade972652283e07604d1afeeb863827556a26db7 Mon Sep 17 00:00:00 2001 From: PeachScript Date: Fri, 29 Jul 2022 10:41:52 +0800 Subject: [PATCH 04/10] feat: doctor support source checkup --- src/builder/bundless/index.ts | 14 ++++------ src/constants.ts | 7 +++++ src/doctor/index.ts | 51 +++++++++++++++++++++++++++++++++-- src/registerMethods.ts | 8 +++--- src/types.ts | 7 +++++ 5 files changed, 73 insertions(+), 14 deletions(-) diff --git a/src/builder/bundless/index.ts b/src/builder/bundless/index.ts index 1e89192..7f0a051 100644 --- a/src/builder/bundless/index.ts +++ b/src/builder/bundless/index.ts @@ -10,21 +10,17 @@ import { } from '@umijs/utils'; import fs from 'fs'; import path from 'path'; -import { DEBUG_BUNDLESS_NAME, WATCH_DEBOUNCE_STEP } from '../../constants'; +import { + DEBUG_BUNDLESS_NAME, + DEFAULT_BUNDLESS_IGNORES, + WATCH_DEBOUNCE_STEP, +} from '../../constants'; import type { BundlessConfigProvider } from '../config'; import getDeclarations from './dts'; import runLoaders from './loaders'; const debugLog = debug(DEBUG_BUNDLESS_NAME); -const DEFAULT_BUNDLESS_IGNORES = [ - '**/*.md', - '**/*.d.ts', - '**/fixtures/**', - '**/__{test,tests,snapshots}__/**', - '**/*.{test,e2e,spec}.{js,jsx,ts,tsx}', -]; - /** * replace extension for path */ diff --git a/src/constants.ts b/src/constants.ts index 24c9887..83d0360 100644 --- a/src/constants.ts +++ b/src/constants.ts @@ -6,3 +6,10 @@ export const DEV_COMMAND = 'dev'; export const BUILD_COMMANDS = ['build', 'prebundle']; export const DEBUG_BUNDLESS_NAME = 'father:bundless'; export const CACHE_PATH = 'node_modules/.cache/father'; +export const DEFAULT_BUNDLESS_IGNORES = [ + '**/*.md', + '**/*.d.ts', + '**/fixtures/**', + '**/__{test,tests,snapshots}__/**', + '**/*.{test,e2e,spec}.{js,jsx,ts,tsx}', +]; diff --git a/src/doctor/index.ts b/src/doctor/index.ts index fb51ca9..a27ec1d 100644 --- a/src/doctor/index.ts +++ b/src/doctor/index.ts @@ -1,3 +1,4 @@ +import { glob, lodash } from '@umijs/utils'; import fs from 'fs'; import path from 'path'; import { @@ -5,6 +6,7 @@ import { IBundlessConfig, normalizeUserConfig as getBuildConfig, } from '../builder/config'; +import { DEFAULT_BUNDLESS_IGNORES } from '../constants'; import { getConfig as getPreBundleConfig } from '../prebundler/config'; import { IFatherBuildTypes, type IApi } from '../types'; @@ -27,6 +29,23 @@ export function registerRules(api: IApi) { api.registerPlugins(rules); } +/** + * get top-level source dirs from configs + */ +export function getSourceDirs( + bundleConfigs: IBundleConfig[], + bundlessConfigs: IBundlessConfig[], +) { + const configDirs = lodash.uniq([ + ...bundleConfigs.map((c) => path.dirname(c.entry)), + ...bundlessConfigs.map((c) => c.input), + ]); + + return [...configDirs].filter((d, i) => + configDirs.every((dir, j) => i === j || !d.startsWith(dir)), + ); +} + export default async (api: IApi): Promise => { // generate configs const [bundleConfigs, bundlessConfigs] = getBuildConfig( @@ -50,12 +69,40 @@ export default async (api: IApi): Promise => { cwd: api.cwd, }); + // collect all source files + const sourceDirs = getSourceDirs(bundleConfigs, bundlessConfigs); + const sourceFiles = sourceDirs.reduce( + (ret, dir) => + ret.concat( + glob.sync(`${dir}/**`, { + cwd: api.cwd, + ignore: DEFAULT_BUNDLESS_IGNORES, + nodir: true, + }), + ), + [], + ); + // regular checkup const regularReport: IDoctorReport = await api.applyPlugins({ key: 'addRegularCheckup', - type: api.ApplyPluginsType.add, args: { bundleConfigs, bundlessConfigs, preBundleConfig }, }); - return [...regularReport.filter(Boolean)]; + // source checkup + const sourceReport: IDoctorReport = []; + + for (const file of sourceFiles) { + sourceReport.push( + ...(await api.applyPlugins({ + key: 'addSourceCheckup', + args: { + file, + content: fs.readFileSync(path.join(api.cwd, file), 'utf-8'), + }, + })), + ); + } + + return [...regularReport.filter(Boolean), ...sourceReport.filter(Boolean)]; }; diff --git a/src/registerMethods.ts b/src/registerMethods.ts index 27b4afa..5079a7d 100644 --- a/src/registerMethods.ts +++ b/src/registerMethods.ts @@ -1,7 +1,9 @@ import { IApi } from './types'; export default (api: IApi) => { - ['addJSTransformer', 'addRegularCheckup'].forEach((name) => { - api.registerMethod({ name }); - }); + ['addJSTransformer', 'addRegularCheckup', 'addSourceCheckup'].forEach( + (name) => { + api.registerMethod({ name }); + }, + ); }; diff --git a/src/types.ts b/src/types.ts index c8aec8b..54440fd 100644 --- a/src/types.ts +++ b/src/types.ts @@ -31,6 +31,13 @@ export type IApi = PluginAPI & }, IDoctorReport | IDoctorReport[0] | void >; + addSourceCheckup: IAdd< + { + file: string; + content: string; + }, + IDoctorReport | IDoctorReport[0] | void + >; }; export enum IFatherBuildTypes { From be1d1db64fc47d9a7a8161ca6eab6dd458708265 Mon Sep 17 00:00:00 2001 From: PeachScript Date: Fri, 29 Jul 2022 10:42:26 +0800 Subject: [PATCH 05/10] feat: add source checkup rules --- src/doctor/rules/EFFECTS_IN_SIDE_EFFECTS.ts | 21 +++++++++++++++++++ src/doctor/rules/PREFER_NO_CSS_MODULES.ts | 23 +++++++++++++++++++++ 2 files changed, 44 insertions(+) create mode 100644 src/doctor/rules/PREFER_NO_CSS_MODULES.ts diff --git a/src/doctor/rules/EFFECTS_IN_SIDE_EFFECTS.ts b/src/doctor/rules/EFFECTS_IN_SIDE_EFFECTS.ts index 6a54330..1b3249a 100644 --- a/src/doctor/rules/EFFECTS_IN_SIDE_EFFECTS.ts +++ b/src/doctor/rules/EFFECTS_IN_SIDE_EFFECTS.ts @@ -2,6 +2,27 @@ import type { IDoctorReport } from '..'; import type { IApi } from '../../types'; export default (api: IApi) => { + let hasStyleProblem = false; + + api.addSourceCheckup(({ file, content }) => { + if ( + api.pkg.sideEffects === false && + !hasStyleProblem && + /\.(j|t)sx?$/.test(file) && + /\simport\s+['"]\.[^'"]+\.(less|css|sass|scss)/.test(content) + ) { + hasStyleProblem = true; + + return { + type: 'error', + problem: + 'Source file contains style imports, and the `"sideEffects": false` will causes styles lost after tree-shaking', + solution: + 'Correct `sideEffects` config in the package.json file, such as `"sideEffects": ["**/*.less"]`', + }; + } + }); + api.addRegularCheckup(() => { if (Array.isArray(api.pkg.sideEffects)) { const result: IDoctorReport = []; diff --git a/src/doctor/rules/PREFER_NO_CSS_MODULES.ts b/src/doctor/rules/PREFER_NO_CSS_MODULES.ts new file mode 100644 index 0000000..af230a6 --- /dev/null +++ b/src/doctor/rules/PREFER_NO_CSS_MODULES.ts @@ -0,0 +1,23 @@ +import type { IApi } from '../../types'; + +export default (api: IApi) => { + let hasProblem = false; + + api.addSourceCheckup(({ file, content }) => { + if ( + !hasProblem && + /\.(j|t)sx?$/.test(file) && + /\sfrom\s+['"]\.[^'"]+\.(less|css|sass|scss)/.test(content) + ) { + hasProblem = true; + + return { + type: 'warn', + problem: + 'To make it easier for users to override component styles, CSS Modules is not recommended', + solution: + "Do not use CSS Modules, and `import './example.less'` directly", + }; + } + }); +}; From ae1e82db871e04dc640f9de3115d9f4f252bb995 Mon Sep 17 00:00:00 2001 From: PeachScript Date: Fri, 29 Jul 2022 12:03:23 +0800 Subject: [PATCH 06/10] feat: doctor support source imports checkup --- src/doctor/index.ts | 42 +++++++++++++++++++++++++++++- src/doctor/parser.ts | 58 ++++++++++++++++++++++++++++++++++++++++++ src/registerMethods.ts | 13 ++++++---- src/types.ts | 10 ++++++++ 4 files changed, 117 insertions(+), 6 deletions(-) create mode 100644 src/doctor/parser.ts diff --git a/src/doctor/index.ts b/src/doctor/index.ts index a27ec1d..5b98f7b 100644 --- a/src/doctor/index.ts +++ b/src/doctor/index.ts @@ -9,6 +9,7 @@ import { import { DEFAULT_BUNDLESS_IGNORES } from '../constants'; import { getConfig as getPreBundleConfig } from '../prebundler/config'; import { IFatherBuildTypes, type IApi } from '../types'; +import sourceParser from './parser'; export type IDoctorReport = { type: 'error' | 'warn'; @@ -83,6 +84,24 @@ export default async (api: IApi): Promise => { [], ); + // collect all alias & externals + // TODO: split bundle & bundless checkup, because externals not work for bundle + const mergedAlias: Record = {}; + const mergedExternals: Record = {}; + + [...bundleConfigs, ...bundlessConfigs].forEach((c) => { + Object.entries(c.alias || {}).forEach(([k, v]) => { + mergedAlias[k] ??= []; + mergedAlias[k].push(v); + }); + + if ('externals' in c) { + Object.entries(c.externals || {}).forEach(([k, v]) => { + mergedExternals[k] = true; + }); + } + }); + // regular checkup const regularReport: IDoctorReport = await api.applyPlugins({ key: 'addRegularCheckup', @@ -104,5 +123,26 @@ export default async (api: IApi): Promise => { ); } - return [...regularReport.filter(Boolean), ...sourceReport.filter(Boolean)]; + // imports checkup + const importsReport: IDoctorReport = []; + + for (const file of sourceFiles) { + importsReport.push( + ...(await api.applyPlugins({ + key: 'addImportsCheckup', + args: { + file, + imports: (await sourceParser(path.join(api.cwd, file))).imports, + mergedAlias, + mergedExternals, + }, + })), + ); + } + + return [ + ...regularReport.filter(Boolean), + ...sourceReport.filter(Boolean), + ...importsReport.filter(Boolean), + ]; }; diff --git a/src/doctor/parser.ts b/src/doctor/parser.ts new file mode 100644 index 0000000..224639b --- /dev/null +++ b/src/doctor/parser.ts @@ -0,0 +1,58 @@ +import { + build, + type OnResolveArgs, +} from '@umijs/bundler-utils/compiled/esbuild'; +import fs from 'fs'; +import path from 'path'; +import { getCache } from '../utils'; + +export type IDoctorSourceParseResult = { + imports: Omit[]; +}; + +export default async ( + fileAbsPath: string, +): Promise => { + const cache = getCache('doctor-parser'); + // format: {path:mtime} + const cacheKey = [fileAbsPath, fs.statSync(fileAbsPath).mtimeMs].join(':'); + const cacheRet = cache.getSync(cacheKey, ''); + const ret: IDoctorSourceParseResult = { imports: [] }; + + if (cacheRet) return cacheRet; + + await build({ + // do not emit file + write: false, + // enable bundle for trigger onResolve hook, but all deps will be externalized + bundle: true, + logLevel: 'silent', + format: 'esm', + target: 'esnext', + // esbuild need relative entry path + entryPoints: [path.basename(fileAbsPath)], + absWorkingDir: path.dirname(fileAbsPath), + plugins: [ + { + name: 'plugin-father-doctor', + setup: (builder) => { + builder.onResolve({ filter: /.*/ }, ({ pluginData, ...args }) => { + if (args.kind !== 'entry-point') { + ret.imports.push(args); + + return { + path: args.path, + // make all deps external + external: true, + }; + } + }); + }, + }, + ], + }); + + cache.set(cacheKey, ret); + + return ret; +}; diff --git a/src/registerMethods.ts b/src/registerMethods.ts index 5079a7d..9525eab 100644 --- a/src/registerMethods.ts +++ b/src/registerMethods.ts @@ -1,9 +1,12 @@ import { IApi } from './types'; export default (api: IApi) => { - ['addJSTransformer', 'addRegularCheckup', 'addSourceCheckup'].forEach( - (name) => { - api.registerMethod({ name }); - }, - ); + [ + 'addJSTransformer', + 'addRegularCheckup', + 'addSourceCheckup', + 'addImportsCheckup', + ].forEach((name) => { + api.registerMethod({ name }); + }); }; diff --git a/src/types.ts b/src/types.ts index 54440fd..d9bae36 100644 --- a/src/types.ts +++ b/src/types.ts @@ -6,6 +6,7 @@ import type { IAdd, IServicePluginAPI, PluginAPI } from '@umijs/core'; import type { ITransformerItem } from './builder/bundless/loaders/javascript'; import type { IBundleConfig, IBundlessConfig } from './builder/config'; import type { IDoctorReport } from './doctor'; +import type { IDoctorSourceParseResult } from './doctor/parser'; import type { IPreBundleConfig } from './prebundler/config'; export type { @@ -38,6 +39,15 @@ export type IApi = PluginAPI & }, IDoctorReport | IDoctorReport[0] | void >; + addImportsCheckup: IAdd< + { + file: string; + imports: IDoctorSourceParseResult['imports']; + mergedAlias: Record; + mergedExternals: Record; + }, + IDoctorReport | IDoctorReport[0] | void + >; }; export enum IFatherBuildTypes { From 25453f83b7baefb7ada8e370c10e65a1afe3b510 Mon Sep 17 00:00:00 2001 From: PeachScript Date: Fri, 29 Jul 2022 12:03:40 +0800 Subject: [PATCH 07/10] feat: add source imports checkup rules --- src/doctor/rules/PHANTOM_DEPS.ts | 30 ++++++++++++++++++++++++++++++ 1 file changed, 30 insertions(+) create mode 100644 src/doctor/rules/PHANTOM_DEPS.ts diff --git a/src/doctor/rules/PHANTOM_DEPS.ts b/src/doctor/rules/PHANTOM_DEPS.ts new file mode 100644 index 0000000..d171af2 --- /dev/null +++ b/src/doctor/rules/PHANTOM_DEPS.ts @@ -0,0 +1,30 @@ +import type { IDoctorReport } from '..'; +import type { IApi } from '../../types'; + +export default (api: IApi) => { + api.addImportsCheckup(({ imports, mergedAlias, mergedExternals }) => { + const errors: IDoctorReport = []; + + imports.forEach((i) => { + const pkgName = i.path.match(/^(?:@[\w-]+\/)?[\w-]+/i)?.[0]; + const aliasKeys = Object.keys(mergedAlias); + + if ( + pkgName && + !api.pkg.dependencies?.[pkgName] && + !api.pkg.peerDependencies?.[pkgName] && + aliasKeys.every((k) => k !== i.path && !i.path.startsWith(`${k}/`)) && + !mergedExternals[i.path] + ) { + errors.push({ + type: 'error', + problem: `Source depend on \`${pkgName}\` but it is not in the \`dependencies\` or \`peerDependencies\``, + solution: + 'Add it to the `dependencies` or `peerDependencies` of the package.json file', + }); + } + }); + + return errors; + }); +}; From 11abfebc4bb1f3d2607cab1617280dbd8074768a Mon Sep 17 00:00:00 2001 From: PeachScript Date: Fri, 29 Jul 2022 14:34:51 +0800 Subject: [PATCH 08/10] test: add cases for doctor command & rules --- tests/doctor.test.ts | 81 ++++++++++++++++++++++ tests/fixtures/doctor/errors/.fatherrc.ts | 14 ++++ tests/fixtures/doctor/errors/package.json | 4 ++ tests/fixtures/doctor/errors/src/index.ts | 10 +++ tests/fixtures/doctor/errors/tsconfig.json | 1 + tests/fixtures/doctor/health/.fatherrc.ts | 0 tests/fixtures/doctor/health/pacakge.json | 1 + tests/fixtures/doctor/warns/.fatherrc.ts | 3 + tests/fixtures/doctor/warns/package.json | 11 +++ tests/fixtures/doctor/warns/src/index.ts | 1 + tests/fixtures/doctor/warns/tsconfig.json | 1 + 11 files changed, 127 insertions(+) create mode 100644 tests/doctor.test.ts create mode 100644 tests/fixtures/doctor/errors/.fatherrc.ts create mode 100644 tests/fixtures/doctor/errors/package.json create mode 100644 tests/fixtures/doctor/errors/src/index.ts create mode 100644 tests/fixtures/doctor/errors/tsconfig.json create mode 100644 tests/fixtures/doctor/health/.fatherrc.ts create mode 100644 tests/fixtures/doctor/health/pacakge.json create mode 100644 tests/fixtures/doctor/warns/.fatherrc.ts create mode 100644 tests/fixtures/doctor/warns/package.json create mode 100644 tests/fixtures/doctor/warns/src/index.ts create mode 100644 tests/fixtures/doctor/warns/tsconfig.json diff --git a/tests/doctor.test.ts b/tests/doctor.test.ts new file mode 100644 index 0000000..9adb695 --- /dev/null +++ b/tests/doctor.test.ts @@ -0,0 +1,81 @@ +import { mockProcessExit } from 'jest-mock-process'; +import path from 'path'; +import * as cli from '../src/cli/cli'; + +const CASES_DIR = path.join(__dirname, 'fixtures/doctor'); +const mockExit = mockProcessExit(); +const logSpy = jest.spyOn(console, 'log'); + +afterAll(() => { + logSpy.mockRestore(); + mockExit.mockRestore(); + delete process.env.APP_ROOT; +}); + +test('doctor: warn checkups', async () => { + process.env.APP_ROOT = path.join(CASES_DIR, 'warns'); + await cli.run({ + args: { _: ['doctor'], $0: 'node' }, + }); + + // DUP_IN_PEER_DEPS + expect(console.log).toHaveBeenCalledWith( + expect.stringContaining('peerDependency'), + ); + + // EFFECTS_IN_SIDE_EFFECTS + expect(console.log).toHaveBeenCalledWith( + expect.stringContaining('sideEffect syntax'), + ); + + // PREFER_BABEL_RUNTIME + expect(console.log).toHaveBeenCalledWith( + expect.stringContaining('@babel/runtime'), + ); + + // PREFER_PACK_FILES + expect(console.log).toHaveBeenCalledWith( + expect.stringContaining('No `files` field'), + ); + + // PREFER_NO_CSS_MODULES + expect(console.log).toHaveBeenCalledWith( + expect.stringContaining('CSS Modules'), + ); +}); + +test('doctor: error checkups', async () => { + process.env.APP_ROOT = path.join(CASES_DIR, 'errors'); + await cli.run({ + args: { _: ['doctor'], $0: 'node' }, + }); + + // PHANTOM_DEPS + expect(console.log).toHaveBeenCalledWith( + expect.stringContaining('Source depend on'), + ); + + // EFFECTS_IN_SIDE_EFFECTS + expect(console.log).toHaveBeenCalledWith( + expect.stringContaining('styles lost'), + ); + + // PACK_FILES_MISSING + expect(console.log).toHaveBeenCalledWith( + expect.stringContaining('will not be published'), + ); + + // process.exit(1) + expect(mockExit).toHaveBeenCalledWith(1); +}); + +test('doctor: health', async () => { + process.env.APP_ROOT = path.join(CASES_DIR, 'health'); + await cli.run({ + args: { _: ['doctor'], $0: 'node' }, + }); + + expect(console.log).toHaveBeenCalledWith( + expect.stringContaining('looks fine'), + ); +}); diff --git a/tests/fixtures/doctor/errors/.fatherrc.ts b/tests/fixtures/doctor/errors/.fatherrc.ts new file mode 100644 index 0000000..06026ee --- /dev/null +++ b/tests/fixtures/doctor/errors/.fatherrc.ts @@ -0,0 +1,14 @@ +export default { + umd: { + externals: { + externals: 'Externals', + '@org/externals': 'OrgExternals', + }, + }, + cjs: { + alias: { + alias: 'alias', + '@org/alias': '@org/alias', + }, + }, +}; diff --git a/tests/fixtures/doctor/errors/package.json b/tests/fixtures/doctor/errors/package.json new file mode 100644 index 0000000..20953b2 --- /dev/null +++ b/tests/fixtures/doctor/errors/package.json @@ -0,0 +1,4 @@ +{ + "sideEffects": false, + "files": [] +} diff --git a/tests/fixtures/doctor/errors/src/index.ts b/tests/fixtures/doctor/errors/src/index.ts new file mode 100644 index 0000000..a9f44a4 --- /dev/null +++ b/tests/fixtures/doctor/errors/src/index.ts @@ -0,0 +1,10 @@ +// @ts-nocheck +import orgAlias from '@org/alias'; +import orgExternals from '@org/externals'; +import alias from 'alias'; +import externals from 'externals'; +import hello from 'hello'; +import './index.less'; + +// to avoid esbuild tree-shaking +console.log(hello, alias, orgAlias, externals, orgExternals); diff --git a/tests/fixtures/doctor/errors/tsconfig.json b/tests/fixtures/doctor/errors/tsconfig.json new file mode 100644 index 0000000..0967ef4 --- /dev/null +++ b/tests/fixtures/doctor/errors/tsconfig.json @@ -0,0 +1 @@ +{} diff --git a/tests/fixtures/doctor/health/.fatherrc.ts b/tests/fixtures/doctor/health/.fatherrc.ts new file mode 100644 index 0000000..e69de29 diff --git a/tests/fixtures/doctor/health/pacakge.json b/tests/fixtures/doctor/health/pacakge.json new file mode 100644 index 0000000..0967ef4 --- /dev/null +++ b/tests/fixtures/doctor/health/pacakge.json @@ -0,0 +1 @@ +{} diff --git a/tests/fixtures/doctor/warns/.fatherrc.ts b/tests/fixtures/doctor/warns/.fatherrc.ts new file mode 100644 index 0000000..50b0486 --- /dev/null +++ b/tests/fixtures/doctor/warns/.fatherrc.ts @@ -0,0 +1,3 @@ +export default { + esm: {}, +}; diff --git a/tests/fixtures/doctor/warns/package.json b/tests/fixtures/doctor/warns/package.json new file mode 100644 index 0000000..d2bdbbd --- /dev/null +++ b/tests/fixtures/doctor/warns/package.json @@ -0,0 +1,11 @@ +{ + "sideEffects": [ + "*.css" + ], + "dependencies": { + "hello": "0.0.0" + }, + "peerDependencies": { + "hello": "0.0.0" + } +} diff --git a/tests/fixtures/doctor/warns/src/index.ts b/tests/fixtures/doctor/warns/src/index.ts new file mode 100644 index 0000000..11dce55 --- /dev/null +++ b/tests/fixtures/doctor/warns/src/index.ts @@ -0,0 +1 @@ +// @ts-ignore diff --git a/tests/fixtures/doctor/warns/tsconfig.json b/tests/fixtures/doctor/warns/tsconfig.json new file mode 100644 index 0000000..0967ef4 --- /dev/null +++ b/tests/fixtures/doctor/warns/tsconfig.json @@ -0,0 +1 @@ +{} From 08b7de841c40c9917120eb176818f53a01bd5d2f Mon Sep 17 00:00:00 2001 From: PeachScript Date: Fri, 29 Jul 2022 14:48:45 +0800 Subject: [PATCH 09/10] test: correct case --- tests/fixtures/doctor/warns/src/index.ts | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/tests/fixtures/doctor/warns/src/index.ts b/tests/fixtures/doctor/warns/src/index.ts index 11dce55..02d52c1 100644 --- a/tests/fixtures/doctor/warns/src/index.ts +++ b/tests/fixtures/doctor/warns/src/index.ts @@ -1 +1,5 @@ // @ts-ignore +import styles from './index.less'; + +// to avoid prettier removed +console.log(styles); From f5bf66a43f92e39a8660f4fb4117fbdef150187a Mon Sep 17 00:00:00 2001 From: PeachScript Date: Fri, 29 Jul 2022 15:05:07 +0800 Subject: [PATCH 10/10] docs: add simple doctor command description --- docs/guide.md | 7 +++++++ 1 file changed, 7 insertions(+) diff --git a/docs/guide.md b/docs/guide.md index 7871b6c..bbd0d3a 100644 --- a/docs/guide.md +++ b/docs/guide.md @@ -27,4 +27,11 @@ $ npx father build $ npx father prebundle ``` +执行项目检查: + +```bash +# 检查项目的潜在问题 +$ npx father doctor +``` + 验证产物并发布 NPM 包。