tools/@aws-cdk/pkglint/lib/rules.ts (1,329 lines of code) (raw):
import * as fs from 'fs';
import * as path from 'path';
import { Bundle } from '@aws-cdk/node-bundle';
import * as caseUtils from 'case';
import * as glob from 'glob';
import * as semver from 'semver';
import { LICENSE, NOTICE } from './licensing';
import { PackageJson, ValidationRule } from './packagejson';
import { cfnOnlyReadmeContents } from './readme-contents';
import {
deepGet, deepSet,
expectDevDependency, expectJSON,
fileShouldBe, fileShouldBeginWith, fileShouldContain,
fileShouldNotContain,
findInnerPackages,
monoRepoRoot,
} from './util';
const AWS_SERVICE_NAMES = require('./aws-service-official-names.json'); // eslint-disable-line @typescript-eslint/no-require-imports
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 that all packages have a publishConfig with a publish tag set.
*/
export class PublishConfigTagIsRequired extends ValidationRule {
public readonly name = 'package-info/publish-config-tag';
public validate(pkg: PackageJson): void {
if (pkg.json.private) { return; }
const defaultPublishTag = 'latest';
if (pkg.json.publishConfig?.tag !== defaultPublishTag) {
pkg.report({
ruleName: this.name,
message: `publishConfig.tag must be ${defaultPublishTag}`,
fix: (() => {
const publishConfig = pkg.json.publishConfig ?? {};
publishConfig.tag = defaultPublishTag;
pkg.json.publishConfig = publishConfig;
}),
});
}
}
}
/**
* 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-cdk.git');
const pkgDir = path.relative(monoRepoRoot(), pkg.packageRoot);
// Enforcing '/' separator for builds to work in Windows.
const osPkgDir = pkgDir.split(path.sep).join('/');
expectJSON(this.name, pkg, 'repository.directory', osPkgDir);
}
}
/**
* 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-cdk');
}
}
/**
* 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 {
fileShouldBeginWith(this.name, pkg, 'NOTICE', ...NOTICE.split('\n'));
}
}
/**
* NOTICE files must contain 3rd party attributions
*/
export class ThirdPartyAttributions extends ValidationRule {
public readonly name = 'license/3p-attributions';
public validate(pkg: PackageJson): void {
const alwaysCheck = ['aws-cdk-lib'];
if (pkg.json.private && !alwaysCheck.includes(pkg.json.name)) {
return;
}
const bundled = pkg.getAllBundledDependencies().filter(dep => !dep.startsWith('@aws-cdk'));
const attribution = pkg.json.pkglint?.attribution ?? [];
const noticePath = path.join(pkg.packageRoot, 'NOTICE');
const lines = fs.existsSync(noticePath)
? fs.readFileSync(noticePath, { encoding: 'utf8' }).split('\n')
: [];
const re = /^\*\* (\S+)/;
const attributions = lines.filter(l => re.test(l)).map(l => l.match(re)![1]);
for (const dep of bundled) {
if (!attributions.includes(dep)) {
pkg.report({
message: `Missing attribution for bundled dependency '${dep}' in NOTICE file.`,
ruleName: this.name,
});
}
}
for (const attr of attributions) {
if (!bundled.includes(attr) && !attribution.includes(attr)) {
pkg.report({
message: `Unnecessary attribution found for dependency '${attr}' in NOTICE file. Attribution is determined from package.json (all "bundledDependencies" or the list in "pkglint.attribution")`,
ruleName: this.name,
});
}
}
}
}
export class NodeBundleValidation extends ValidationRule {
public readonly name = '@aws-cdk/node-bundle';
public validate(pkg: PackageJson): void {
const bundleConfig = pkg.json['cdk-package']?.bundle;
if (bundleConfig == null) {
return;
}
const bundle = new Bundle({
...bundleConfig,
packageDir: pkg.packageRoot,
});
const result = bundle.validate({ fix: false });
if (result.success) {
return;
}
for (const violation of result.violations) {
pkg.report({
fix: violation.fix,
message: violation.message,
ruleName: `${this.name} => ${violation.type}`,
});
}
}
}
/**
* 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 scopes = pkg.json['cdk-build'] && pkg.json['cdk-build'].cloudformation;
if (!scopes) {
return;
}
// elasticsearch is renamed to opensearch service, so its readme does not follow these rules
if (pkg.packageName === '@aws-cdk/core' || pkg.packageName === '@aws-cdk/aws-elasticsearch') {
return;
}
const scope: string = typeof scopes === 'string' ? scopes : scopes[0];
const serviceName = AWS_SERVICE_NAMES[scope];
// If this is a 'cfn-only' package, we fix the README to specific file contents, and
// don't do any other checks.
if (pkg.json.maturity === 'cfn-only') {
fileShouldBe(this.name, pkg, 'README.md', cfnOnlyReadmeContents({
cfnNamespace: scope,
packageName: pkg.packageName,
}));
return;
}
// Otherwise, the cfn-specific disclaimer in it MUST NOT exist.
const disclaimerRegex = beginEndRegex('CFNONLY DISCLAIMER');
const currentReadme = readIfExists(readmeFile);
if (currentReadme && disclaimerRegex.test(currentReadme)) {
pkg.report({
ruleName: this.name,
message: 'README must not include CFNONLY DISCLAIMER section',
fix: () => fs.writeFileSync(readmeFile, currentReadme.replace(disclaimerRegex, '')),
});
}
const headline = serviceName && `${serviceName} Construct Library`;
if (!fs.existsSync(readmeFile)) {
pkg.report({
ruleName: this.name,
message: 'There must be a README.md file at the root of the package',
fix: () => fs.writeFileSync(
readmeFile,
[
`# ${headline || pkg.json.description}`,
'This module is part of the[AWS Cloud Development Kit](https://github.com/aws/aws-cdk) project.',
].join('\n'),
),
});
} 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')),
});
}
}
}
}
/**
* All packages must have a "maturity" declaration.
*
* The banner in the README must match the package maturity.
*
* As a way to seed the settings, if 'maturity' is missing but can
* be auto-derived from 'stability', that will be the fix (otherwise
* there is no fix).
*/
export class MaturitySetting extends ValidationRule {
public readonly name = 'package-info/maturity';
public validate(pkg: PackageJson): void {
if (pkg.json.private) {
// Does not apply to private packages!
return;
}
if (pkg.json.features) {
// Skip this in favour of the FeatureStabilityRule.
return;
}
let maturity = pkg.json.maturity as string | undefined;
const stability = pkg.json.stability as string | undefined;
if (!maturity) {
let fix;
if (stability && ['stable', 'deprecated'].includes(stability)) {
// We can autofix!
fix = () => pkg.json.maturity = stability;
maturity = stability;
}
pkg.report({
ruleName: this.name,
message: `Package is missing "maturity" setting (expected one of ${Object.keys(MATURITY_TO_STABILITY)})`,
fix,
});
}
if (pkg.json.deprecated && maturity !== 'deprecated') {
pkg.report({
ruleName: this.name,
message: `Package is deprecated, but is marked with maturity "${maturity}"`,
fix: () => pkg.json.maturity = 'deprecated',
});
maturity = 'deprecated';
}
const packageLevels = this.determinePackageLevels(pkg);
const hasL1s = packageLevels.some(level => level === 'l1');
const hasL2s = packageLevels.some(level => level === 'l2');
if (hasL2s) {
// validate that a package that contains L2s does not declare a 'cfn-only' maturity
if (maturity === 'cfn-only') {
pkg.report({
ruleName: this.name,
message: "Package that contains any L2s cannot declare a 'cfn-only' maturity",
fix: () => pkg.json.maturity = 'experimental',
});
}
} else if (hasL1s) {
// validate that a package that contains only L1s declares a 'cfn-only' maturity
if (maturity !== 'cfn-only') {
pkg.report({
ruleName: this.name,
message: "Package that contains only L1s cannot declare a maturity other than 'cfn-only'",
fix: () => pkg.json.maturity = 'cfn-only',
});
}
}
if (maturity) {
this.validateReadmeHasBanner(pkg, maturity, packageLevels);
}
}
private validateReadmeHasBanner(pkg: PackageJson, maturity: string, levelsPresent: string[]) {
if (pkg.packageName === '@aws-cdk/aws-elasticsearch') {
// Special case for elasticsearch, which is labeled as stable in package.json
// but all APIs are now marked 'deprecated'
return;
}
const badge = this.readmeBadge(maturity, levelsPresent);
if (!badge) {
// Somehow, we don't have a badge for this stability level
return;
}
const readmeFile = path.join(pkg.packageRoot, 'README.md');
if (!fs.existsSync(readmeFile)) {
// Presence of the file is asserted by another rule
return;
}
const readmeContent = fs.readFileSync(readmeFile, { encoding: 'utf8' });
const badgeRegex = toRegExp(badge);
if (!badgeRegex.test(readmeContent)) {
// Removing a possible old, now invalid stability indication from the README.md before adding a new one
const [title, ...body] = readmeContent.replace(/<!--BEGIN STABILITY BANNER-->(?:.|\n)+<!--END STABILITY BANNER-->\n+/m, '').split('\n');
pkg.report({
ruleName: this.name,
message: `Missing stability banner for ${maturity} in README.md file`,
fix: () => fs.writeFileSync(readmeFile, [title, badge, ...body].join('\n')),
});
}
}
private readmeBadge(maturity: string, levelsPresent: string[]) {
const bannerContents = levelsPresent
.map(level => fs.readFileSync(path.join(__dirname, 'banners', `${level}.${maturity}.md`), { encoding: 'utf-8' }).trim())
.join('\n\n')
.trim();
const bannerLines = bannerContents.split('\n').map(s => s.trimRight());
return [
'<!--BEGIN STABILITY BANNER-->',
'',
'---',
'',
...bannerLines,
'',
'---',
'',
'<!--END STABILITY BANNER-->',
'',
].join('\n');
}
private determinePackageLevels(pkg: PackageJson): string[] {
// Used to determine L1 by the presence of a .generated.ts file, but that depends
// on the source having been built. Much more robust to look at the build INSTRUCTIONS
// to see if this package has L1s.
const hasL1 = !!pkg.json['cdk-build']?.cloudformation;
const libFiles = glob.sync('lib/**/*.ts', {
ignore: 'lib/**/*.d.ts', // ignore the generated TS declaration files
});
const hasL2 = libFiles.some(f => !f.endsWith('.generated.ts') && !f.endsWith('index.ts'));
return [
...hasL1 ? ['l1'] : [],
// If we don't have L1, then at least always paste in the L2 banner
...hasL2 || !hasL1 ? ['l2'] : [],
];
}
}
const MATURITY_TO_STABILITY: Record<string, string> = {
'cfn-only': 'experimental',
'experimental': 'experimental',
'developer-preview': 'experimental',
'stable': 'stable',
'deprecated': 'deprecated',
};
/**
* There must be a stability setting, and it must match the package maturity.
*
* Maturity setting is leading here (as there are more options than the
* stability setting), but the stability setting must be present for `jsii`
* to properly read and encode it into the assembly.
*/
export class StabilitySetting extends ValidationRule {
public readonly name = 'package-info/stability';
public validate(pkg: PackageJson): void {
if (pkg.json.private) {
// Does not apply to private packages!
return;
}
if (pkg.json.features) {
// Skip this in favour of the FeatureStabilityRule.
return;
}
const maturity = pkg.json.maturity as string | undefined;
const stability = pkg.json.stability as string | undefined;
const expectedStability = maturity ? MATURITY_TO_STABILITY[maturity] : undefined;
if (!stability || (expectedStability && stability !== expectedStability)) {
pkg.report({
ruleName: this.name,
message: `stability is '${stability}', but based on maturity is expected to be '${expectedStability}'`,
fix: expectedStability ? (() => pkg.json.stability = expectedStability) : undefined,
});
}
}
}
export class FeatureStabilityRule extends ValidationRule {
public readonly name = 'package-info/feature-stability';
private readonly badges: { [key: string]: string } = {
'Not Implemented': 'https://img.shields.io/badge/not--implemented-black.svg?style=for-the-badge',
'Experimental': 'https://img.shields.io/badge/experimental-important.svg?style=for-the-badge',
'Developer Preview': 'https://img.shields.io/badge/developer--preview-informational.svg?style=for-the-badge',
'Stable': 'https://img.shields.io/badge/stable-success.svg?style=for-the-badge',
};
public validate(pkg: PackageJson): void {
if (pkg.json.private || !pkg.json.features) {
return;
}
const featuresColumnWitdh = Math.max(
13, // 'CFN Resources'.length
...pkg.json.features.map((feat: { name: string; }) => feat.name.length),
);
const stabilityBanner: string = [
'<!--BEGIN STABILITY BANNER-->',
'',
'---',
'',
`Features${' '.repeat(featuresColumnWitdh - 8)} | Stability`,
`--------${'-'.repeat(featuresColumnWitdh - 8)}-|-----------${'-'.repeat(Math.max(0, 100 - featuresColumnWitdh - 13))}`,
...this.featureEntries(pkg, featuresColumnWitdh),
'',
...this.bannerNotices(pkg),
'---',
'',
'<!--END STABILITY BANNER-->',
'',
].join('\n');
const readmeFile = path.join(pkg.packageRoot, 'README.md');
if (!fs.existsSync(readmeFile)) {
// Presence of the file is asserted by another rule
return;
}
const readmeContent = fs.readFileSync(readmeFile, { encoding: 'utf8' });
const stabilityRegex = toRegExp(stabilityBanner);
if (!stabilityRegex.test(readmeContent)) {
const [title, ...body] = readmeContent.replace(/<!--BEGIN STABILITY BANNER-->(?:.|\n)+<!--END STABILITY BANNER-->\n+/m, '').split('\n');
pkg.report({
ruleName: this.name,
message: 'Stability banner does not match as expected',
fix: () => fs.writeFileSync(readmeFile, [title, stabilityBanner, ...body].join('\n')),
});
}
}
private featureEntries(pkg: PackageJson, featuresColumnWitdh: number): string[] {
const entries: string[] = [];
if (pkg.json['cdk-build']?.cloudformation) {
entries.push(`CFN Resources${' '.repeat(featuresColumnWitdh - 13)} | `);
}
pkg.json.features.forEach((feature: { [key: string]: string }) => {
const badge = this.badges[feature.stability];
if (!badge) {
throw new Error(`Unknown stability - ${feature.stability}`);
}
entries.push(`${feature.name}${' '.repeat(featuresColumnWitdh - feature.name.length)} | `);
});
return entries;
}
private bannerNotices(pkg: PackageJson): string[] {
const notices: string[] = [];
if (pkg.json['cdk-build']?.cloudformation) {
notices.push(readBannerFile('features-cfn-stable.md'));
notices.push('');
}
const noticeOrder = ['Experimental', 'Developer Preview', 'Stable'];
const stabilities = pkg.json.features.map((f: { [k: string]: string }) => f.stability);
const filteredNotices = noticeOrder.filter(v => stabilities.includes(v));
for (const notice of filteredNotices) {
if (notices.length !== 0) {
// This delimiter helps ensure proper parsing & rendering with various parsers
notices.push('<!-- -->', '');
}
const lowerTrainCase = notice.toLowerCase().replace(/\s/g, '-');
notices.push(readBannerFile(`features-${lowerTrainCase}.md`));
notices.push('');
}
return notices;
}
}
/**
* Keywords must contain CDK keywords and be sorted
*/
export class CDKKeywords 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 || [];
if (keywords.indexOf('cdk') === -1) {
pkg.report({
ruleName: this.name,
message: 'Keywords must mention CDK',
fix: () => { pkg.json.keywords.splice(0, 0, 'cdk'); },
});
}
if (keywords.indexOf('aws') === -1) {
pkg.report({
ruleName: this.name,
message: 'Keywords must mention AWS',
fix: () => { pkg.json.keywords.splice(0, 0, 'aws'); },
});
}
}
}
/**
* Requires projectReferences to be set in the jsii configuration.
*/
export class JSIIProjectReferences extends ValidationRule {
public readonly name = 'jsii/project-references';
public validate(pkg: PackageJson): void {
if (!isJSII(pkg)) {
return;
}
}
}
export class NoPeerDependenciesAwsCdkLib extends ValidationRule {
public readonly name = 'aws-cdk-lib/no-peer';
private readonly allowedPeer = ['constructs'];
private readonly modules = ['aws-cdk-lib'];
public validate(pkg: PackageJson): void {
if (!this.modules.includes(pkg.packageName)) {
return;
}
const peers = Object.keys(pkg.peerDependencies).filter(peer => !this.allowedPeer.includes(peer));
if (peers.length > 0) {
pkg.report({
ruleName: this.name,
message: `Adding a peer dependency to the monolithic package ${pkg.packageName} is a breaking change, and thus not allowed.
Added ${peers.join(' ')}`,
});
}
}
}
/**
* Validates that the same version of `constructs` is used wherever a dependency
* is specified, so that they must all be udpated at the same time (through an
* update to this rule).
*
* Note: v1 and v2 use different versions respectively.
*/
export class ConstructsVersion extends ValidationRule {
public static readonly VERSION = cdkMajorVersion() === 2
? '^10.0.0'
: '^3.3.69';
public readonly name = 'deps/constructs';
public validate(pkg: PackageJson) {
const toCheck = new Array<string>();
if ('constructs' in pkg.dependencies) {
toCheck.push('dependencies');
}
if ('constructs' in pkg.devDependencies) {
toCheck.push('devDependencies');
}
if ('constructs' in pkg.peerDependencies) {
toCheck.push('peerDependencies');
}
for (const cfg of toCheck) {
expectJSON(this.name, pkg, `${cfg}.constructs`, ConstructsVersion.VERSION);
}
}
}
/**
* JSII Java package is required and must look sane
*/
export class JSIIJavaPackageIsRequired extends ValidationRule {
public readonly name = 'jsii/java';
public validate(pkg: PackageJson): void {
if (!isJSII(pkg)) { return; }
const moduleName = cdkModuleName(pkg.json.name);
expectJSON(this.name, pkg, 'jsii.targets.java.maven.groupId', 'software.amazon.awscdk');
expectJSON(this.name, pkg, 'jsii.targets.java.maven.artifactId', moduleName.mavenArtifactId, /-/g);
const java = deepGet(pkg.json, ['jsii', 'targets', 'java', 'package']) as string | undefined;
expectJSON(this.name, pkg, 'jsii.targets.java.package', moduleName.javaPackage, /\./g);
if (java) {
const expectedPrefix = moduleName.javaPackage.split('.').slice(0, 3).join('.');
const actualPrefix = java.split('.').slice(0, 3).join('.');
if (expectedPrefix !== actualPrefix) {
pkg.report({
ruleName: this.name,
message: `JSII "java" package must share the first 3 elements of the expected one: ${expectedPrefix} vs ${actualPrefix}`,
fix: () => deepSet(pkg.json, ['jsii', 'targets', 'java', 'package'], moduleName.javaPackage),
});
}
}
}
}
export class JSIIPythonTarget extends ValidationRule {
public readonly name = 'jsii/python';
public validate(pkg: PackageJson): void {
if (!isJSII(pkg)) { return; }
const moduleName = cdkModuleName(pkg.json.name);
// See: https://aws.github.io/jsii/user-guides/lib-author/configuration/targets/python/
expectJSON(this.name, pkg, 'jsii.targets.python.distName', moduleName.python.distName);
expectJSON(this.name, pkg, 'jsii.targets.python.module', moduleName.python.module);
expectJSON(this.name, pkg, 'jsii.targets.python.classifiers', ['Framework :: AWS CDK', `Framework :: AWS CDK :: ${cdkMajorVersion()}`]);
}
}
export class CDKPackage 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';
if (!shouldUseCDKBuildTools(pkg)) { return; }
expectJSON(this.name, pkg, 'scripts.package', 'cdk-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 NoTestsInNpmPackage extends ValidationRule {
public readonly name = 'npmignore/test';
public validate(pkg: PackageJson): void {
// skip private packages
if (pkg.json.private) { return; }
// Skip the CLI package, as its 'test' subdirectory is used at runtime.
if (pkg.packageName === 'aws-cdk') { return; }
// Exclude 'test/' directories from being packaged
fileShouldContain(this.name, pkg, '.npmignore', 'test/');
}
}
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 there is no dependency on "jsii" since it's defined at the repo
* level.
*/
export class NoJsiiDep extends ValidationRule {
public readonly name = 'dependencies/no-jsii';
public validate(pkg: PackageJson): void {
const predicate = (s: string) => s.startsWith('jsii');
if (pkg.getDevDependency(predicate)) {
pkg.report({
ruleName: this.name,
message: 'packages should not have a devDep on jsii since it is defined at the repo level',
fix: () => pkg.removeDevDependency(predicate),
});
}
}
}
function isCdkModuleName(name: string) {
return !!name.match(/^@aws-cdk\//);
}
/**
* Computes the module name for various other purposes (java package, ...)
*/
function cdkModuleName(name: string) {
const isCdkPkg = name === '@aws-cdk/core';
const isLegacyCdkPkg = name === '@aws-cdk/cdk';
let suffix = name;
suffix = suffix.replace(/^aws-cdk-/, '');
suffix = suffix.replace(/^@aws-cdk\//, '');
const dotnetSuffix = suffix.split('-')
.map(s => s === 'aws' ? 'AWS' : caseUtils.pascal(s))
.join('.');
const pythonName = suffix.replace(/^@/g, '').replace(/\//g, '.').split('.').map(caseUtils.kebab).join('.');
// list of packages with special-cased Maven ArtifactId.
const mavenIdMap: Record<string, string> = {
'@aws-cdk/core': 'core',
'@aws-cdk/cdk': 'cdk',
'@aws-cdk/assertions': 'assertions',
'@aws-cdk/assertions-alpha': 'assertions-alpha',
};
/* eslint-disable @stylistic/indent */
const mavenArtifactId =
name in mavenIdMap ? mavenIdMap[name] :
(suffix.startsWith('aws-') || suffix.startsWith('alexa-')) ? suffix.replace(/aws-/, '') :
suffix.startsWith('cdk-') ? suffix : `cdk-${suffix}`;
/* eslint-enable @stylistic/indent */
return {
javaPackage: `software.amazon.awscdk${isLegacyCdkPkg ? '' : `.${suffix.replace(/aws-/, 'services-').replace(/-/g, '.')}`}`,
mavenArtifactId,
dotnetNamespace: `Amazon.CDK${isCdkPkg ? '' : `.${dotnetSuffix}`}`,
dotnetPackageId: `Amazon.CDK${isCdkPkg ? '' : `.${dotnetSuffix}`}`,
python: {
distName: `aws-cdk.${pythonName}`,
module: `aws_cdk.${pythonName.replace(/-/g, '_')}`,
},
};
}
/**
* JSII .NET namespace is required and must look sane
*/
export class JSIIDotNetNamespaceIsRequired extends ValidationRule {
public readonly name = 'jsii/dotnet';
public validate(pkg: PackageJson): void {
if (!isJSII(pkg)) { return; }
const dotnet = deepGet(pkg.json, ['jsii', 'targets', 'dotnet', 'namespace']) as string | undefined;
const moduleName = cdkModuleName(pkg.json.name);
expectJSON(this.name, pkg, 'jsii.targets.dotnet.namespace', moduleName.dotnetNamespace, /\./g, /*case insensitive*/ true);
if (dotnet) {
const actualPrefix = dotnet.split('.').slice(0, 2).join('.');
const expectedPrefix = moduleName.dotnetNamespace.split('.').slice(0, 2).join('.');
if (actualPrefix !== expectedPrefix) {
pkg.report({
ruleName: this.name,
message: `.NET namespace must share the first two segments of the default namespace, '${expectedPrefix}' vs '${actualPrefix}'`,
fix: () => deepSet(pkg.json, ['jsii', 'targets', 'dotnet', 'namespace'], moduleName.dotnetNamespace),
});
}
}
}
}
/**
* JSII .NET packageId is required and must look sane
*/
export class JSIIDotNetPackageIdIsRequired extends ValidationRule {
public readonly name = 'jsii/dotnet';
public validate(pkg: PackageJson): void {
if (!isJSII(pkg)) { return; }
const dotnet = deepGet(pkg.json, ['jsii', 'targets', 'dotnet', 'namespace']) as string | undefined;
const moduleName = cdkModuleName(pkg.json.name);
expectJSON(this.name, pkg, 'jsii.targets.dotnet.packageId', moduleName.dotnetPackageId, /\./g, /*case insensitive*/ true);
if (dotnet) {
const actualPrefix = dotnet.split('.').slice(0, 2).join('.');
const expectedPrefix = moduleName.dotnetPackageId.split('.').slice(0, 2).join('.');
if (actualPrefix !== expectedPrefix) {
pkg.report({
ruleName: this.name,
message: `.NET packageId must share the first two segments of the default namespace, '${expectedPrefix}' vs '${actualPrefix}'`,
fix: () => deepSet(pkg.json, ['jsii', 'targets', 'dotnet', 'packageId'], moduleName.dotnetPackageId),
});
}
}
}
}
/**
* JSII .NET icon url is required and must look sane
*/
export class JSIIDotNetIconUrlIsRequired extends ValidationRule {
public readonly name = 'jsii/dotnet/icon-url';
public validate(pkg: PackageJson): void {
if (!isJSII(pkg)) { return; }
const CDK_LOGO_URL = 'https://raw.githubusercontent.com/aws/aws-cdk/main/logo/default-256-dark.png';
expectJSON(this.name, pkg, 'jsii.targets.dotnet.iconUrl', CDK_LOGO_URL);
}
}
/**
* The package must depend on cdk-build-tools
*/
export class MustDependOnBuildTools extends ValidationRule {
public readonly name = 'dependencies/build-tools';
public validate(pkg: PackageJson): void {
if (!shouldUseCDKBuildTools(pkg)) { return; }
// We can't ACTUALLY require cdk-build-tools/package.json here,
// because WE don't depend on cdk-build-tools and we don't know if
// the package does.
expectDevDependency(this.name,
pkg,
'@aws-cdk/cdk-build-tools',
`${PKGLINT_VERSION}`); // eslint-disable-line @typescript-eslint/no-require-imports
}
}
/**
* Build script must be 'cdk-build'
*/
export class MustUseCDKBuild extends ValidationRule {
public readonly name = 'package-info/scripts/build';
public validate(pkg: PackageJson): void {
if (!shouldUseCDKBuildTools(pkg)) { return; }
if (pkg.packageName !== '@aws-cdk/custom-resource-handlers') {
expectJSON(this.name, pkg, 'scripts.build', 'cdk-build');
}
// cdk-build will write a hash file that we have to ignore.
const merkleMarker = '.LAST_BUILD';
fileShouldContain(this.name, pkg, '.gitignore', merkleMarker);
fileShouldContain(this.name, pkg, '.npmignore', merkleMarker);
}
}
/**
* 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, peerRange] of Object.entries(pkg.peerDependencies)) {
const depRange = pkg.dependencies[depName];
if (depRange === undefined) { continue; }
// Make sure that depVersion satisfies peerVersion.
if (!semver.intersects(depRange, peerRange, { includePrerelease: true })) {
pkg.report({
ruleName: this.name,
message: `dependency ${depName}: concrete version ${depRange} does not match peer version '${peerRange}'`,
fix: () => pkg.addPeerDependency(depName, depRange),
});
}
}
}
}
/**
* Check that dependencies on @aws-cdk/ packages use point versions (not version ranges)
* and that they are also defined in `peerDependencies`.
*/
export class MustDependonCdkByPointVersions extends ValidationRule {
public readonly name = 'dependencies/cdk-point-dependencies';
public validate(pkg: PackageJson): void {
// yes, ugly, but we have a bunch of references to other files in the repo.
// we use the root package.json to determine what should be the version
// across the repo: in local builds, this should be 0.0.0 and in CI builds
// this would be the actual version of the repo after it's been aligned
// using scripts/align-version.sh
const expectedVersion = require(path.join(monoRepoRoot(), 'package.json')).version; // eslint-disable-line @typescript-eslint/no-require-imports
const ignore = [
'@aws-cdk/aws-service-spec',
'@aws-cdk/service-spec-importers',
'@aws-cdk/service-spec-types',
'@aws-cdk/cloudformation-diff',
'@aws-cdk/cx-api',
'@aws-cdk/cloud-assembly-schema',
'@aws-cdk/region-info',
// Private packages
...fs.readdirSync(path.join(monoRepoRoot(), 'tools', '@aws-cdk')).map((name) => `@aws-cdk/${name}`),
// Packages in the @aws-cdk namespace that are vended outside of the monorepo
'@aws-cdk/asset-kubectl-v20',
'@aws-cdk/asset-node-proxy-agent-v6',
'@aws-cdk/asset-awscli-v1',
'@aws-cdk/cdk-cli-wrapper',
];
for (const [depName, depVersion] of Object.entries(pkg.dependencies)) {
if (!isCdkModuleName(depName) || ignore.includes(depName)) {
continue;
}
const peerDep = pkg.peerDependencies[depName];
if (!peerDep) {
pkg.report({
ruleName: this.name,
message: `dependency ${depName} must also appear in peerDependencies`,
fix: () => pkg.addPeerDependency(depName, expectedVersion),
});
}
if (peerDep !== expectedVersion) {
pkg.report({
ruleName: this.name,
message: `peer dependency ${depName} should have the version ${expectedVersion}`,
fix: () => pkg.addPeerDependency(depName, expectedVersion),
});
}
if (depVersion !== expectedVersion) {
pkg.report({
ruleName: this.name,
message: `dependency ${depName}: dependency version must be ${expectedVersion}`,
fix: () => pkg.addDependency(depName, expectedVersion),
});
}
}
}
}
export class MustIgnoreSNK extends ValidationRule {
public readonly name = 'ignore/strong-name-key';
public validate(pkg: PackageJson): void {
fileShouldContain(this.name, pkg, '.npmignore', '*.snk');
fileShouldContain(this.name, pkg, '.gitignore', '*.snk');
}
}
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',
'!*.lit.ts', // <- This is part of the module's documentation!
'coverage',
'.nyc_output',
'*.tgz',
);
}
}
/**
* Must use 'cdk-watch' command
*/
export class MustUseCDKWatch extends ValidationRule {
public readonly name = 'package-info/scripts/watch';
public validate(pkg: PackageJson): void {
if (!shouldUseCDKBuildTools(pkg)) { return; }
expectJSON(this.name, pkg, 'scripts.watch', 'cdk-watch');
}
}
/**
* Must have 'rosetta:extract' command if this package is JSII-enabled.
*/
export class MustHaveRosettaExtract extends ValidationRule {
public readonly name = 'package-info/scripts/rosetta:extract';
public validate(pkg: PackageJson): void {
if (!isJSII(pkg)) { return; }
expectJSON(this.name, pkg, 'scripts.rosetta:extract', 'yarn --silent jsii-rosetta extract');
}
}
/**
* Must use 'cdk-test' command
*/
export class MustUseCDKTest extends ValidationRule {
public readonly name = 'package-info/scripts/test';
public validate(pkg: PackageJson): void {
if (!shouldUseCDKBuildTools(pkg)) { return; }
if (!hasTestDirectory(pkg)) { return; }
if (pkg.packageName !== '@aws-cdk/custom-resource-handlers') {
expectJSON(this.name, pkg, 'scripts.test', 'cdk-test');
}
// 'cdk-test' will 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 {
if (cdkMajorVersion() === 2) {
expectJSON(this.name, pkg, 'engines.node', '>= 14.15.0');
} else {
expectJSON(this.name, pkg, 'engines.node', '>= 10.13.0 <13 || >=13.7.0');
}
}
}
/**
* Scripts that run integ tests must also have the individual 'integ' script to update them
*
* This commands comes from the dev-dependency cdk-integ-tools.
*/
export class MustHaveIntegCommand extends ValidationRule {
public readonly name = 'package-info/scripts/integ';
public validate(pkg: PackageJson): void {
if (!hasIntegTests(pkg)) { return; }
expectJSON(this.name, pkg, 'scripts.integ', /integ-runner/, undefined, false, true);
// We can't ACTUALLY require cdk-build-tools/package.json here,
// because WE don't depend on cdk-build-tools and we don't know if
// the package does.
expectDevDependency(this.name,
pkg,
'@aws-cdk/integ-runner',
'*'); // eslint-disable-line @typescript-eslint/no-require-imports
}
}
/**
* Checks API backwards compatibility against the latest released version.
*/
export class CompatScript extends ValidationRule {
public readonly name = 'package-info/scripts/compat';
public validate(pkg: PackageJson): void {
if (!isJSII(pkg)) { return ; }
expectJSON(this.name, pkg, 'scripts.compat', 'cdk-compat');
}
}
export class PkgLintAsScript extends ValidationRule {
public readonly name = 'package-info/scripts/pkglint';
public validate(pkg: PackageJson): void {
const script = 'pkglint -f';
expectDevDependency(this.name, pkg, '@aws-cdk/pkglint', `${PKGLINT_VERSION}`); // eslint-disable-line @typescript-eslint/no-require-imports
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}`,
});
}
});
}
}
}
export class NoMixedDeps extends ValidationRule {
public readonly name = 'dependencies/no-mixed-deps';
public validate(pkg: PackageJson) {
const deps = Object.keys(pkg.json.dependencies ?? {});
const devDeps = Object.keys(pkg.json.devDependencies ?? {});
const shared = deps.filter((dep) => devDeps.includes(dep));
for (const dep of shared) {
pkg.report({
ruleName: this.name,
message: `dependency may not be both in dependencies and devDependencies: ${dep}`,
fix: () => pkg.removeDevDependency(dep),
});
}
}
}
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;
}
if (!isAWS(pkg)) {
return;
}
expectJSON(this.name, pkg, 'scripts.awslint', 'cdk-awslint');
}
}
/**
* Packages inside JSII packages (typically used for embedding Lambda handles)
* must only have dev dependencies and their node_modules must not be published.
*
* 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) || pkg.packageName === '@aws-cdk/cli-lib-alpha') { 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 devDependencies`,
});
}
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/extract in a single command.
* This involves multiple targets: `build+test`, `build+extract`, `build+test+extract`, and `build+test+package`
*/
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 hasExtract = 'rosetta:extract' in scripts;
const cmdBuild = 'yarn build';
expectJSON(this.name, pkg, 'scripts.build+test', hasTest ? [cmdBuild, 'yarn test'].join(' && ') : cmdBuild);
expectJSON(this.name, pkg, 'scripts.build+extract', hasExtract ? [cmdBuild, 'yarn rosetta:extract'].join(' && ') : cmdBuild);
const cmdBuildTest = 'yarn build+test';
expectJSON(this.name, pkg, 'scripts.build+test+package', hasPack ? [cmdBuildTest, 'yarn package'].join(' && ') : cmdBuildTest);
expectJSON(this.name, pkg, 'scripts.build+test+extract', hasExtract ? [cmdBuildTest, 'yarn rosetta:extract'].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(monoRepoRoot(), '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 = ConstructsVersion.VERSION;;
// require a "constructs" dependency if there's a @aws-cdk/core dependency
const requiredDev = pkg.getDevDependency('@aws-cdk/core') && !pkg.getDevDependency('constructs');
if (requiredDev || (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);
},
});
}
const requiredDep = pkg.dependencies?.['@aws-cdk/core'] && !pkg.dependencies?.constructs;
if (requiredDep || (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);
},
});
}
}
}
}
/**
* Peer dependencies should be a range, not a point version, to maximize compatibility
*/
export class PeerDependencyRange extends ValidationRule {
public readonly name = 'peerdependency/range';
public validate(pkg: PackageJson) {
const packages = ['aws-cdk-lib'];
for (const [name, version] of Object.entries(pkg.peerDependencies)) {
if (packages.includes(name) && version.match(/^[0-9]/)) {
pkg.report({
ruleName: this.name,
message: `peerDependency on" ${name}" should be a range, not a point version: "${version}"`,
fix: () => {
pkg.addPeerDependency(name, '^' + version);
},
});
}
}
}
}
/**
* Do not announce new versions of AWS CDK modules in awscdk.io because it is very very spammy
* and actually causes the @awscdkio twitter account to be blocked.
*
* https://github.com/construct-catalog/catalog/issues/24
* https://github.com/construct-catalog/catalog/pull/22
*/
export class DoNotAnnounceInCatalog extends ValidationRule {
public readonly name = 'catalog/no-announce';
public validate(pkg: PackageJson) {
if (!isJSII(pkg)) { return; }
if (pkg.json.awscdkio?.announce !== false) {
pkg.report({
ruleName: this.name,
message: 'missing "awscdkio.announce: false" in package.json',
fix: () => {
pkg.json.awscdkio = pkg.json.awscdkio ?? { };
pkg.json.awscdkio.announce = false;
},
});
}
}
}
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',
fix: () => {
const rootRelative = path.relative(pkg.packageRoot, repoRoot(pkg.packageRoot));
fs.writeFileSync(
eslintrcFilename,
[
`const baseConfig = require('${rootRelative}/tools/@aws-cdk/cdk-build-tools/config/eslintrc');`,
"baseConfig.parserOptions.project = __dirname + '/tsconfig.json';",
'module.exports = baseConfig;',
].join('\n') + '\n',
);
},
});
}
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 cdkBuild = pkg.json['cdk-build'] || {};
// check whether the package.json contains the "jest" key,
// which we no longer use
if (pkg.json.jest) {
pkg.report({
ruleName: this.name,
message: 'Using Jest is set through a flag in the "cdk-build" key in package.json, the "jest" key is ignored',
fix: () => {
delete pkg.json.jest;
cdkBuild.jest = true;
pkg.json['cdk-build'] = cdkBuild;
},
});
}
// this rule should only be enforced for packages that use Jest for testing
if (!cdkBuild.jest) {
return;
}
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',
fix: () => {
const rootRelative = path.relative(pkg.packageRoot, repoRoot(pkg.packageRoot));
fs.writeFileSync(
jestConfigFilename,
[
`const baseConfig = require('${rootRelative}/tools/@aws-cdk/cdk-build-tools/config/jest.config');`,
'module.exports = baseConfig;',
].join('\n') + '\n',
);
},
});
}
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',
});
}
}
}
export class UbergenPackageVisibility extends ValidationRule {
public readonly name = 'ubergen/package-visibility';
// The ONLY (non-alpha) packages that should be published for v2.
// These include dependencies of the CDK CLI (aws-cdk).
private readonly v2PublicPackages = [
'@aws-cdk/cli-plugin-contract',
'@aws-cdk/cloudformation-diff',
'@aws-cdk/cx-api',
'@aws-cdk/region-info',
'aws-cdk-lib',
'aws-cdk',
'awslint',
'cdk',
'@aws-cdk/integ-runner',
'@aws-cdk-testing/cli-integ',
];
public validate(pkg: PackageJson): void {
if (cdkMajorVersion() === 2) {
// Only alpha packages and packages in the publicPackages list should be "public". Everything else should be private.
if (this.v2PublicPackages.includes(pkg.json.name) && pkg.json.private === true) {
pkg.report({
ruleName: this.name,
message: 'Package must be public',
fix: () => {
delete pkg.json.private;
},
});
} else if (!this.v2PublicPackages.includes(pkg.json.name) && pkg.json.private !== true && !pkg.packageName.endsWith('-alpha')) {
pkg.report({
ruleName: this.name,
message: 'Package must not be public',
fix: () => {
delete pkg.json.private;
pkg.json.private = true;
},
});
}
}
}
}
/**
* No experimental dependencies.
* In v2 all experimental modules will be released separately from aws-cdk-lib. This means that:
* 1. Stable modules can't depend on experimental modules as it will creates a cyclic dependency.
* 2. Experimental modules shouldn't depend on experimental modules as it will create a coupling between their graduation (cause of 1).
* 2 specify "shouldn't" as in some cases we might allow it (using the `excludedDependencies` map), but the default is to not allow it.
*/
export class NoExperimentalDependents extends ValidationRule {
public name = 'no-experimental-dependencies';
// experimental -> experimental dependencies that are allowed for now.
private readonly excludedDependencies = new Map([
['@aws-cdk/aws-secretsmanager', ['@aws-cdk/aws-sam']],
['@aws-cdk/aws-kinesisanalytics-flink', ['@aws-cdk/aws-kinesisanalytics']],
['@aws-cdk/aws-apigatewayv2-integrations', ['@aws-cdk/aws-apigatewayv2']],
['@aws-cdk/aws-apigatewayv2-authorizers', ['@aws-cdk/aws-apigatewayv2']],
['@aws-cdk/aws-events-targets', ['@aws-cdk/aws-kinesisfirehose']],
['@aws-cdk/aws-kinesisfirehose-destinations', ['@aws-cdk/aws-kinesisfirehose']],
['@aws-cdk/aws-iot-actions', ['@aws-cdk/aws-iot', '@aws-cdk/aws-kinesisfirehose', '@aws-cdk/aws-iotevents']],
['@aws-cdk/aws-iotevents-actions', ['@aws-cdk/aws-iotevents']],
]);
private readonly excludedModules = ['@aws-cdk/cloudformation-include'];
public validate(pkg: PackageJson): void {
if (this.excludedModules.includes(pkg.packageName)) {
return;
}
if (!isCdkModuleName(pkg.packageName)) {
return;
}
if (!isIncludedInMonolith(pkg)) {
return;
}
Object.keys(pkg.dependencies).forEach(dep => {
if (!isCdkModuleName(dep)) {
return;
}
// eslint-disable-next-line @typescript-eslint/no-require-imports
const maturity = require(`${dep}/package.json`).maturity;
if (maturity === 'experimental') {
if (this.excludedDependencies.get(pkg.packageName)?.includes(dep)) {
return;
}
pkg.report({
ruleName: this.name,
message: `It is not allowed to depend on experimental modules. ${pkg.packageName} added a dependency on experimental module ${dep}`,
});
}
});
}
}
/**
* Enforces that the aws-cdk's package.json on the V2 branch does not have the "main"
* and "types" keys filled.
*/
export class CdkCliV2MissesMainAndTypes extends ValidationRule {
public readonly name = 'aws-cdk/cli/v2/package.json/main';
public validate(pkg: PackageJson): void {
// this rule only applies to the CLI
if (pkg.json.name !== 'aws-cdk') { return; }
// this only applies to V2
if (cdkMajorVersion() === 1) { return; }
if (pkg.json.main || pkg.json.types) {
pkg.report({
ruleName: this.name,
message: 'The package.json file for the aws-cdk CLI package in V2 cannot have "main" and "types" keys',
fix: () => {
delete pkg.json.main;
delete pkg.json.types;
},
});
}
}
}
/**
* 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);
}
/**
* Indicates that this is an "AWS" package (i.e. that it it has a cloudformation source)
* @param pkg
*/
function isAWS(pkg: PackageJson): boolean {
return pkg.json['cdk-build']?.cloudformation != null;
}
/**
* 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'));
}
/**
* Whether this package has integ tests
*
* A package has integ tests if it mentions 'cdk-integ' in the "test" script.
*/
function hasIntegTests(pkg: PackageJson) {
if (!hasTestDirectory(pkg)) { return false; }
const files = fs.readdirSync(path.join(pkg.packageRoot, 'test'));
return files.some(p => p.startsWith('integ.'));
}
/**
* Return whether this package should use CDK build tools
*/
function shouldUseCDKBuildTools(pkg: PackageJson) {
const exclude = [
'@aws-cdk/cdk-build-tools',
'@aws-cdk/script-tests',
'awslint',
];
return !exclude.includes(pkg.packageName);
}
function repoRoot(dir: string) {
let root = dir;
for (let i = 0; i < 50 && !fs.existsSync(path.join(root, 'yarn.lock')); i++) {
root = path.dirname(root);
}
return root;
}
function toRegExp(str: string): RegExp {
return new RegExp(str.replace(/[.*+?^${}()|[\]\\]/g, '\\$&').replace(/\w+/g, '\\w+'));
}
function readBannerFile(file: string): string {
return fs.readFileSync(path.join(__dirname, 'banners', file), { encoding: 'utf-8' }).trim();
}
function cdkMajorVersion(): number {
// eslint-disable-next-line @typescript-eslint/no-require-imports
const releaseJson = require(`${monoRepoRoot()}/release.json`);
return releaseJson.majorVersion as number;
}
/**
* Should this package be included in the monolithic package.
*/
function isIncludedInMonolith(pkg: PackageJson): boolean {
if (pkg.json.ubergen?.exclude) {
return false;
} else if (!isJSII(pkg)) {
return false;
} else if (pkg.json.deprecated) {
return false;
}
return true;
}
function beginEndRegex(label: string) {
return new RegExp(`(<\!--BEGIN ${label}-->)([\\s\\S]+)(<\!--END ${label}-->)`, 'm');
}
function readIfExists(filename: string): string | undefined {
return fs.existsSync(filename) ? fs.readFileSync(filename, { encoding: 'utf8' }) : undefined;
}