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)} | ![Stable](${this.badges.Stable})`); } 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)} | ![${feature.stability}](${badge})`); }); 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; } expectJSON( this.name, pkg, 'jsii.projectReferences', pkg.json.name !== 'aws-cdk-lib', ); } } 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 @typescript-eslint/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 @typescript-eslint/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; } 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; } 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', `${PKGLINT_VERSION}`); // 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)) { 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/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; }