Skip to content
This repository has been archived by the owner on Mar 28, 2023. It is now read-only.

feat: add doctor command to check potential problems #56

Merged
merged 10 commits into from
Jul 29, 2022
7 changes: 7 additions & 0 deletions docs/guide.md
Original file line number Diff line number Diff line change
Expand Up @@ -27,4 +27,11 @@ $ npx father build
$ npx father prebundle
```

执行项目检查:

```bash
# 检查项目的潜在问题
$ npx father doctor
```

验证产物并发布 NPM 包。
14 changes: 5 additions & 9 deletions src/builder/bundless/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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
*/
Expand Down
3 changes: 2 additions & 1 deletion src/builder/config.ts
Original file line number Diff line number Diff line change
Expand Up @@ -39,6 +39,7 @@ export interface IBundlessConfig
type: IFatherBuildTypes.BUNDLESS;
format: IFatherBundlessTypes;
input: string;
output: NonNullable<IFatherBundleConfig['output']>;
}

/**
Expand Down Expand Up @@ -161,7 +162,7 @@ export function normalizeUserConfig(
formatName === 'esm'
? IFatherPlatformTypes.BROWSER
: IFatherPlatformTypes.NODE;
const bundlessConfig: Omit<IBundlessConfig, 'input'> = {
const bundlessConfig: Omit<IBundlessConfig, 'input' | 'output'> = {
type: IFatherBuildTypes.BUNDLESS,
format: formatName as IFatherBundlessTypes,
platform: userConfig.platform || defaultPlatform,
Expand Down
45 changes: 45 additions & 0 deletions src/commands/doctor.ts
Original file line number Diff line number Diff line change
@@ -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!'));
}
},
});
};
7 changes: 7 additions & 0 deletions src/constants.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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}',
];
148 changes: 148 additions & 0 deletions src/doctor/index.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,148 @@
import { glob, lodash } from '@umijs/utils';
import fs from 'fs';
import path from 'path';
import {
IBundleConfig,
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';
import sourceParser from './parser';

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() && /(?<!\.d)\.(j|t)s$/.test(f.name))
.map((f) => path.join(ruleDir, f.name));

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<IDoctorReport> => {
// 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,
});

// collect all source files
const sourceDirs = getSourceDirs(bundleConfigs, bundlessConfigs);
const sourceFiles = sourceDirs.reduce<string[]>(
(ret, dir) =>
ret.concat(
glob.sync(`${dir}/**`, {
cwd: api.cwd,
ignore: DEFAULT_BUNDLESS_IGNORES,
nodir: true,
}),
),
[],
);

// collect all alias & externals
// TODO: split bundle & bundless checkup, because externals not work for bundle
const mergedAlias: Record<string, string[]> = {};
const mergedExternals: Record<string, true> = {};

[...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',
args: { bundleConfigs, bundlessConfigs, preBundleConfig },
});

// 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'),
},
})),
);
}

// 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),
];
};
58 changes: 58 additions & 0 deletions src/doctor/parser.ts
Original file line number Diff line number Diff line change
@@ -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<OnResolveArgs, 'pluginData'>[];
};

export default async (
fileAbsPath: string,
): Promise<IDoctorSourceParseResult> => {
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;
};
23 changes: 23 additions & 0 deletions src/doctor/rules/DUP_IN_PEER_DEPS.ts
Original file line number Diff line number Diff line change
@@ -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;
});
};
44 changes: 44 additions & 0 deletions src/doctor/rules/EFFECTS_IN_SIDE_EFFECTS.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,44 @@
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 = [];

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;
}
});
};
Loading