tools/pkglint/lib/rules.ts (536 lines of code) (raw):
import * as fs from 'fs';
import * as path from 'path';
import * as semver from 'semver';
import { LICENSE, NOTICE } from './licensing';
import { PackageJson, ValidationRule } from './packagejson';
import {
deepGet, deepSet,
expectDevDependency, expectJSON,
fileShouldBe, fileShouldContain,
fileShouldNotContain,
findInnerPackages,
monoRepoRoot,
} from './util';
const PKGLINT_VERSION = require('../package.json').version; // eslint-disable-line @typescript-eslint/no-require-imports
/**
* Verify that the package name matches the directory name
*/
export class PackageNameMatchesDirectoryName extends ValidationRule {
public readonly name = 'naming/package-matches-directory';
public validate(pkg: PackageJson): void {
const parts = pkg.packageRoot.split(path.sep);
const expectedName = parts[parts.length - 2].startsWith('@')
? parts.slice(parts.length - 2).join('/')
: parts[parts.length - 1];
expectJSON(this.name, pkg, 'name', expectedName);
}
}
/**
* Verify that all packages have a description
*/
export class DescriptionIsRequired extends ValidationRule {
public readonly name = 'package-info/require-description';
public validate(pkg: PackageJson): void {
if (!pkg.json.description) {
pkg.report({ ruleName: this.name, message: 'Description is required' });
}
}
}
/**
* Verify cdk.out directory is included in npmignore since we should not be
* publishing it.
*/
export class CdkOutMustBeNpmIgnored extends ValidationRule {
public readonly name = 'package-info/npm-ignore-cdk-out';
public validate(pkg: PackageJson): void {
const npmIgnorePath = path.join(pkg.packageRoot, '.npmignore');
if (fs.existsSync(npmIgnorePath)) {
const npmIgnore = fs.readFileSync(npmIgnorePath);
if (!npmIgnore.includes('**/cdk.out')) {
pkg.report({
ruleName: this.name,
message: `${npmIgnorePath}: Must exclude **/cdk.out`,
fix: () => fs.writeFileSync(
npmIgnorePath,
`${npmIgnore}\n# exclude cdk artifacts\n**/cdk.out`,
),
});
}
}
}
}
/**
* Repository must be our GitHub repo
*/
export class RepositoryCorrect extends ValidationRule {
public readonly name = 'package-info/repository';
public validate(pkg: PackageJson): void {
expectJSON(this.name, pkg, 'repository.type', 'git');
expectJSON(this.name, pkg, 'repository.url', 'https://github.com/aws/aws-rfdk.git');
const pkgDir = path.relative(monoRepoRoot(), pkg.packageRoot);
expectJSON(this.name, pkg, 'repository.directory', pkgDir);
}
}
/**
* Homepage must point to the GitHub repository page.
*/
export class HomepageCorrect extends ValidationRule {
public readonly name = 'package-info/homepage';
public validate(pkg: PackageJson): void {
expectJSON(this.name, pkg, 'homepage', 'https://github.com/aws/aws-rfdk');
}
}
/**
* The license must be Apache-2.0.
*/
export class License extends ValidationRule {
public readonly name = 'package-info/license';
public validate(pkg: PackageJson): void {
expectJSON(this.name, pkg, 'license', 'Apache-2.0');
}
}
/**
* There must be a license file that corresponds to the Apache-2.0 license.
*/
export class LicenseFile extends ValidationRule {
public readonly name = 'license/license-file';
public validate(pkg: PackageJson): void {
fileShouldBe(this.name, pkg, 'LICENSE', LICENSE);
}
}
/**
* There must be a NOTICE file.
*/
export class NoticeFile extends ValidationRule {
public readonly name = 'license/notice-file';
public validate(pkg: PackageJson): void {
fileShouldBe(this.name, pkg, 'NOTICE', NOTICE);
}
}
/**
* Author must be AWS (as an Organization)
*/
export class AuthorAWS extends ValidationRule {
public readonly name = 'package-info/author';
public validate(pkg: PackageJson): void {
expectJSON(this.name, pkg, 'author.name', 'Amazon Web Services');
expectJSON(this.name, pkg, 'author.url', 'https://aws.amazon.com');
expectJSON(this.name, pkg, 'author.organization', true);
}
}
/**
* There must be a README.md file.
*/
export class ReadmeFile extends ValidationRule {
public readonly name = 'package-info/README.md';
public validate(pkg: PackageJson): void {
const readmeFile = path.join(pkg.packageRoot, 'README.md');
const headline = 'Render Farm Deployment Kit on AWS';
if (!fs.existsSync(readmeFile)) {
pkg.report({
ruleName: this.name,
message: 'There must be a README.md file at the root of the package',
});
} else if (headline) {
const requiredFirstLine = `# ${headline}`;
const [firstLine, ...rest] = fs.readFileSync(readmeFile, { encoding: 'utf8' }).split('\n');
if (firstLine !== requiredFirstLine) {
pkg.report({
ruleName: this.name,
message: `The title of the README.md file must be "${headline}"`,
fix: () => fs.writeFileSync(readmeFile, [requiredFirstLine, ...rest].join('\n')),
});
}
}
}
}
/**
* Keywords must contain RFDK keywords.
*/
export class Keywords extends ValidationRule {
public readonly name = 'package-info/keywords';
public validate(pkg: PackageJson): void {
if (!pkg.json.keywords) {
pkg.report({
ruleName: this.name,
message: 'Must have keywords',
fix: () => { pkg.json.keywords = []; },
});
}
const keywords = pkg.json.keywords || [];
const requiredKeywords = [
'CDK',
'AWS',
'RFDK',
];
for (const keyword of requiredKeywords) {
const lowerKeyword = keyword.toLowerCase();
if (keywords.indexOf(lowerKeyword) === -1) {
pkg.report({
ruleName: this.name,
message: `Keywords must mention ${keyword}`,
fix: () => { pkg.json.keywords.splice(0, 0, lowerKeyword); },
});
}
}
}
}
export class JSIIPythonTarget extends ValidationRule {
public readonly name = 'jsii/python';
public validate(pkg: PackageJson): void {
if (!isJSII(pkg)) { return; }
const moduleName = rfdkModuleName(pkg.json.name);
expectJSON(this.name, pkg, 'jsii.targets.python.distName', moduleName.python.distName);
expectJSON(this.name, pkg, 'jsii.targets.python.module', moduleName.python.module);
}
}
export class RFDKPackage extends ValidationRule {
public readonly name = 'package-info/scripts/package';
public validate(pkg: PackageJson): void {
// skip private packages
if (pkg.json.private) { return; }
const merkleMarker = '.LAST_PACKAGE';
const outdir = 'dist';
// if this is
if (isJSII(pkg)) {
expectJSON(this.name, pkg, 'jsii.outdir', outdir);
}
fileShouldContain(this.name, pkg, '.npmignore', outdir);
fileShouldContain(this.name, pkg, '.gitignore', outdir);
fileShouldContain(this.name, pkg, '.npmignore', merkleMarker);
fileShouldContain(this.name, pkg, '.gitignore', merkleMarker);
}
}
export class NoTsBuildInfo extends ValidationRule {
public readonly name = 'npmignore/tsbuildinfo';
public validate(pkg: PackageJson): void {
// skip private packages
if (pkg.json.private) { return; }
// Stop 'tsconfig.tsbuildinfo' and regular '.tsbuildinfo' files from being
// published to NPM.
// We might at some point also want to strip tsconfig.json but for now,
// the TypeScript DOCS BUILD needs to it to load the typescript source.
fileShouldContain(this.name, pkg, '.npmignore', '*.tsbuildinfo');
}
}
export class NoTsConfig extends ValidationRule {
public readonly name = 'npmignore/tsconfig';
public validate(pkg: PackageJson): void {
// skip private packages
if (pkg.json.private) { return; }
fileShouldContain(this.name, pkg, '.npmignore', 'tsconfig.json');
}
}
export class IncludeJsiiInNpmTarball extends ValidationRule {
public readonly name = 'npmignore/jsii-included';
public validate(pkg: PackageJson): void {
// only jsii modules
if (!isJSII(pkg)) { return; }
// skip private packages
if (pkg.json.private) { return; }
fileShouldNotContain(this.name, pkg, '.npmignore', '.jsii');
fileShouldContain(this.name, pkg, '.npmignore', '!.jsii'); // make sure .jsii is included
}
}
/**
* Verifies that the expected versions of node will be supported.
*/
export class NodeCompatibility extends ValidationRule {
public readonly name = 'dependencies/node-version';
public validate(pkg: PackageJson): void {
const atTypesNode = pkg.getDevDependency('@types/node');
if (atTypesNode && !atTypesNode.startsWith('^18.')) {
pkg.report({
ruleName: this.name,
message: `packages must support node version 18 and up, but ${atTypesNode} is declared`,
fix: () => pkg.addDevDependency('@types/node', '^18.0.0'),
});
}
}
}
/**
* Verifies that the ``@types/`` dependencies are correctly recorded in ``devDependencies`` and not ``dependencies``.
*/
export class NoAtTypesInDependencies extends ValidationRule {
public readonly name = 'dependencies/at-types';
public validate(pkg: PackageJson): void {
const predicate = (s: string) => s.startsWith('@types/');
for (const dependency of pkg.getDependencies(predicate)) {
pkg.report({
ruleName: this.name,
message: `dependency on ${dependency.name}@${dependency.version} must be in devDependencies`,
fix: () => {
pkg.addDevDependency(dependency.name, dependency.version);
pkg.removeDependency(predicate);
},
});
}
}
}
/**
* Computes the module name for various other purposes (java package, ...)
*/
function rfdkModuleName(name: string) {
name = name.replace(/^aws-rfdk-/, '');
name = name.replace(/^@aws-rfdk\//, '');
return {
python: {
distName: 'aws-rfdk',
module: 'aws_rfdk',
},
};
}
/**
* Dependencies in both regular and peerDependencies must agree in semver
*
* In particular, verify that depVersion satisfies peerVersion. This prevents
* us from instructing NPM to construct impossible closures, where we say:
*
* peerDependency: A@1.0.0
* dependency: A@2.0.0
*
* There is no version of A that would satisfy this.
*
* The other way around is not necessary--the depVersion can be bumped without
* bumping the peerVersion (if the API didn't change this may be perfectly
* valid). This prevents us from restricting a user's potential combinations of
* libraries unnecessarily.
*/
export class RegularDependenciesMustSatisfyPeerDependencies extends ValidationRule {
public readonly name = 'dependencies/peer-dependencies-satisfied';
public validate(pkg: PackageJson): void {
for (const [depName, peerVersion] of Object.entries(pkg.peerDependencies)) {
const depVersion = pkg.dependencies[depName];
if (depVersion === undefined) { continue; }
// Make sure that depVersion satisfies peerVersion.
if (!semver.intersects(depVersion, peerVersion)) {
pkg.report({
ruleName: this.name,
message: `dependency ${depName}: concrete version ${depVersion} does not match peer version '${peerVersion}'`,
fix: () => pkg.addPeerDependency(depName, depVersion),
});
}
}
}
}
export class MustIgnoreJunitXml extends ValidationRule {
public readonly name = 'ignore/junit';
public validate(pkg: PackageJson): void {
fileShouldContain(this.name, pkg, '.npmignore', 'junit.xml');
fileShouldContain(this.name, pkg, '.gitignore', 'junit.xml');
}
}
export class NpmIgnoreForJsiiModules extends ValidationRule {
public readonly name = 'ignore/jsii';
public validate(pkg: PackageJson): void {
if (!isJSII(pkg)) { return; }
fileShouldContain(this.name, pkg, '.npmignore',
'*.ts',
'!*.d.ts',
'!*.js',
'coverage',
'.nyc_output',
'*.tgz',
);
}
}
/**
* Must have test-generated files in .gitignore
*/
export class MustIgnoreTestFiles extends ValidationRule {
public readonly name = 'package-info/scripts/test';
public validate(pkg: PackageJson): void {
if (!hasTestDirectory(pkg)) { return; }
// Tests ill calculate coverage, so have the appropriate
// files in .gitignore.
fileShouldContain(this.name, pkg, '.gitignore', '.nyc_output');
fileShouldContain(this.name, pkg, '.gitignore', 'coverage');
fileShouldContain(this.name, pkg, '.gitignore', 'nyc.config.js');
}
}
/**
* Must declare minimum node version
*/
export class MustHaveNodeEnginesDeclaration extends ValidationRule {
public readonly name = 'package-info/engines';
public validate(pkg: PackageJson): void {
expectJSON(this.name, pkg, 'engines.node', '>= 14.15.0');
}
}
export class PkgLintAsScript extends ValidationRule {
public readonly name = 'package-info/scripts/pkglint';
public validate(pkg: PackageJson): void {
const script = 'pkglint';
expectDevDependency(this.name, pkg, 'pkglint', `${PKGLINT_VERSION}`);
if (!pkg.npmScript('pkglint')) {
pkg.report({
ruleName: this.name,
message: 'a script called "pkglint" must be included to allow fixing package linting issues',
fix: () => pkg.changeNpmScript('pkglint', () => script),
});
}
if (pkg.npmScript('pkglint') !== script) {
pkg.report({
ruleName: this.name,
message: 'the pkglint script should be: ' + script,
fix: () => pkg.changeNpmScript('pkglint', () => script),
});
}
}
}
export class NoStarDeps extends ValidationRule {
public readonly name = 'dependencies/no-star';
public validate(pkg: PackageJson) {
reportStarDeps(this.name, pkg.json.depedencies);
reportStarDeps(this.name, pkg.json.devDependencies);
function reportStarDeps(ruleName: string, deps?: any) {
deps = deps || {};
Object.keys(deps).forEach(d => {
if (deps[d] === '*') {
pkg.report({
ruleName,
message: `star dependency not allowed for ${d}`,
});
}
});
}
}
}
interface VersionCount {
version: string;
count: number;
}
/**
* All consumed versions of dependencies must be the same
*
* NOTE: this rule will only be useful when validating multiple package.jsons at the same time
*/
export class AllVersionsTheSame extends ValidationRule {
public readonly name = 'dependencies/versions-consistent';
private readonly ourPackages: {[pkg: string]: string} = {};
private readonly usedDeps: {[pkg: string]: VersionCount[]} = {};
public prepare(pkg: PackageJson): void {
this.ourPackages[pkg.json.name] = pkg.json.version;
this.recordDeps(pkg.json.dependencies);
this.recordDeps(pkg.json.devDependencies);
}
public validate(pkg: PackageJson): void {
this.validateDeps(pkg, 'dependencies');
this.validateDeps(pkg, 'devDependencies');
}
private recordDeps(deps: {[pkg: string]: string} | undefined) {
if (!deps) { return; }
Object.keys(deps).forEach(dep => {
this.recordDep(dep, deps[dep]);
});
}
private validateDeps(pkg: PackageJson, section: string) {
if (!pkg.json[section]) { return; }
Object.keys(pkg.json[section]).forEach(dep => {
this.validateDep(pkg, section, dep);
});
}
private recordDep(dep: string, version: string) {
if (version === '*') {
// '*' does not give us info, so skip
return;
}
if (!(dep in this.usedDeps)) {
this.usedDeps[dep] = [];
}
const i = this.usedDeps[dep].findIndex(vc => vc.version === version);
if (i === -1) {
this.usedDeps[dep].push({ version, count: 1 });
} else {
this.usedDeps[dep][i].count += 1;
}
}
private validateDep(pkg: PackageJson, depField: string, dep: string) {
if (dep in this.ourPackages) {
expectJSON(this.name, pkg, depField + '.' + dep, this.ourPackages[dep]);
return;
}
// Otherwise, must match the majority version declaration. Might be empty if we only
// have '*', in which case that's fine.
if (!(dep in this.usedDeps)) { return; }
const versions = this.usedDeps[dep];
versions.sort((a, b) => b.count - a.count);
expectJSON(this.name, pkg, depField + '.' + dep, versions[0].version);
}
}
export class AwsLint extends ValidationRule {
public readonly name = 'awslint';
public validate(pkg: PackageJson) {
if (!isJSII(pkg)) {
return;
}
expectJSON(this.name, pkg, 'scripts.awslint', 'awslint');
}
}
export class JestCoverageTarget extends ValidationRule {
public readonly name = 'jest-coverage-target';
public validate(pkg: PackageJson) {
if (pkg.json.jest) {
// We enforce the key exists, but the value is just a default
const defaults: { [key: string]: number } = {
branches: 80,
statements: 80,
};
for (const key of Object.keys(defaults)) {
const deepPath = ['coverageThreshold', 'global', key];
const setting = deepGet(pkg.json.jest, deepPath);
if (setting == null) {
pkg.report({
ruleName: this.name,
message: `When jest is used, jest.coverageThreshold.global.${key} must be set`,
fix: () => {
deepSet(pkg.json.jest, deepPath, defaults[key]);
},
});
}
}
}
}
}
/**
* Packages inside JSII packages (typically used for embedding Lambda handles)
* must only have dev dependencies and their node_modules must have been
* blacklisted for publishing
*
* We might loosen this at some point but we'll have to bundle all runtime dependencies
* and we don't have good transitive license checks.
*/
export class PackageInJsiiPackageNoRuntimeDeps extends ValidationRule {
public readonly name = 'lambda-packages-no-runtime-deps';
public validate(pkg: PackageJson) {
if (!isJSII(pkg)) { return; }
for (const inner of findInnerPackages(pkg.packageRoot)) {
const innerPkg = PackageJson.fromDirectory(inner);
if (Object.keys(innerPkg.dependencies).length > 0) {
pkg.report({
ruleName: `${this.name}:1`,
message: `NPM Package '${innerPkg.packageName}' inside jsii package '${pkg.packageName}', can only have devDepencencies`,
});
}
const nodeModulesRelPath = path.relative(pkg.packageRoot, innerPkg.packageRoot) + '/node_modules';
fileShouldContain(`${this.name}:2`, pkg, '.npmignore', nodeModulesRelPath);
}
}
}
/**
* Requires packages to have fast-fail build scripts, allowing to combine build, test and package in a single command.
* This involves two targets: `build+test:pack` and `build+test` (to skip the pack).
*/
export class FastFailingBuildScripts extends ValidationRule {
public readonly name = 'fast-failing-build-scripts';
public validate(pkg: PackageJson) {
const scripts = pkg.json.scripts || {};
const hasTest = 'test' in scripts;
const hasPack = 'package' in scripts;
const cmdBuild = 'yarn run build';
expectJSON(this.name, pkg, 'scripts.build+test', hasTest ? [cmdBuild, 'yarn test'].join(' && ') : cmdBuild);
const cmdBuildTest = 'yarn run build+test';
expectJSON(this.name, pkg, 'scripts.build+test+package', hasPack ? [cmdBuildTest, 'yarn run package'].join(' && ') : cmdBuildTest);
}
}
export class YarnNohoistBundledDependencies extends ValidationRule {
public readonly name = 'yarn/nohoist-bundled-dependencies';
public validate(pkg: PackageJson) {
const bundled: string[] = pkg.json.bundleDependencies || pkg.json.bundledDependencies || [];
if (bundled.length === 0) { return; }
const repoPackageJson = path.resolve(__dirname, '../../../package.json');
const nohoist: string[] = require(repoPackageJson).workspaces.nohoist; // eslint-disable-line @typescript-eslint/no-require-imports
const missing = new Array<string>();
for (const dep of bundled) {
for (const entry of [`${pkg.packageName}/${dep}`, `${pkg.packageName}/${dep}/**`]) {
if (nohoist.indexOf(entry) >= 0) { continue; }
missing.push(entry);
}
}
if (missing.length > 0) {
pkg.report({
ruleName: this.name,
message: `Repository-level 'workspaces.nohoist' directive is missing: ${missing.join(', ')}`,
fix: () => {
const packageJson = require(repoPackageJson); // eslint-disable-line @typescript-eslint/no-require-imports
packageJson.workspaces.nohoist = [...packageJson.workspaces.nohoist, ...missing].sort();
fs.writeFileSync(repoPackageJson, `${JSON.stringify(packageJson, null, 2)}\n`, { encoding: 'utf8' });
},
});
}
}
}
export class ConstructsDependency extends ValidationRule {
public readonly name = 'constructs/dependency';
public validate(pkg: PackageJson) {
const REQUIRED_VERSION = '^10.0.0';
if (pkg.devDependencies?.constructs && pkg.devDependencies?.constructs !== REQUIRED_VERSION) {
pkg.report({
ruleName: this.name,
message: `"constructs" must have a version requirement ${REQUIRED_VERSION}`,
fix: () => {
pkg.addDevDependency('constructs', REQUIRED_VERSION);
},
});
}
if (pkg.dependencies.constructs && pkg.dependencies.constructs !== REQUIRED_VERSION) {
pkg.report({
ruleName: this.name,
message: `"constructs" must have a version requirement ${REQUIRED_VERSION}`,
fix: () => {
pkg.addDependency('constructs', REQUIRED_VERSION);
},
});
if (!pkg.peerDependencies.constructs || pkg.peerDependencies.constructs !== REQUIRED_VERSION) {
pkg.report({
ruleName: this.name,
message: `"constructs" must have a version requirement ${REQUIRED_VERSION} in peerDependencies`,
fix: () => {
pkg.addPeerDependency('constructs', REQUIRED_VERSION);
},
});
}
}
}
}
export class EslintSetup extends ValidationRule {
public readonly name = 'package-info/eslint';
public validate(pkg: PackageJson) {
const eslintrcFilename = '.eslintrc.js';
if (!fs.existsSync(eslintrcFilename)) {
pkg.report({
ruleName: this.name,
message: 'There must be a .eslintrc.js file at the root of the package',
});
}
fileShouldContain(this.name, pkg, '.gitignore', '!.eslintrc.js');
fileShouldContain(this.name, pkg, '.npmignore', '.eslintrc.js');
}
}
export class JestSetup extends ValidationRule {
public readonly name = 'package-info/jest.config';
public validate(pkg: PackageJson): void {
const jestConfigFilename = 'jest.config.js';
if (!fs.existsSync(jestConfigFilename)) {
pkg.report({
ruleName: this.name,
message: 'There must be a jest.config.js file at the root of the package',
});
}
fileShouldContain(this.name, pkg, '.gitignore', '!jest.config.js');
fileShouldContain(this.name, pkg, '.npmignore', 'jest.config.js');
if (!(pkg.json.devDependencies ?? {})['@types/jest']) {
pkg.report({
ruleName: `${this.name}.types`,
message: 'There must be a devDependency on \'@types/jest\' if you use jest testing',
});
}
}
}
/**
* Determine whether this is a JSII package
*
* A package is a JSII package if there is 'jsii' section in the package.json
*/
function isJSII(pkg: PackageJson): boolean {
return (pkg.json.jsii !== undefined);
}
/**
* Determine whether the package has tests
*
* A package has tests if the root/test directory exists
*/
function hasTestDirectory(pkg: PackageJson) {
return fs.existsSync(path.join(pkg.packageRoot, 'test'));
}