From cd9c19decbe183ec8a9677d5f50eb592ef0bf55c Mon Sep 17 00:00:00 2001 From: AWS CDK Automation <43080478+aws-cdk-automation@users.noreply.github.com> Date: Thu, 16 May 2024 02:56:15 -0700 Subject: [PATCH] feat: user-provided TypeScript config (backport #931) (#980) # Backport This will backport the following commits from `main` to `maintenance/v5.3`: - [feat: user-provided TypeScript config (#931)](https://github.com/aws/jsii-compiler/pull/931) ### Questions ? Please refer to the [Backport tool documentation](https://github.com/sorenlouv/backport) Co-authored-by: Momo Kornher --- build-tools/code-gen.ts | 2 +- src/case.ts | 1 + src/compiler.ts | 269 ++++++------ src/jsii-diagnostic.ts | 20 +- src/main.ts | 66 ++- src/project-info.ts | 31 ++ src/tsconfig/compiler-options.ts | 155 +++++++ src/tsconfig/index.ts | 19 + src/tsconfig/rulesets/configurable.ts | 22 + src/tsconfig/rulesets/generated.public.ts | 31 ++ src/tsconfig/rulesets/incompatible-options.ts | 10 + src/tsconfig/rulesets/minimal.public.ts | 10 + src/tsconfig/rulesets/strict.public.ts | 32 ++ src/tsconfig/tsconfig-validator.ts | 51 +++ src/tsconfig/validator.ts | 388 ++++++++++++++++++ test/compiler.test.ts | 314 +++++++++----- test/project-info.test.ts | 60 +++ test/tsconfig/helpers.ts | 364 ++++++++++++++++ test/tsconfig/tsconfig-validator.test.ts | 51 +++ test/tsconfig/validator.test.ts | 193 +++++++++ yarn.lock | 31 +- 21 files changed, 1869 insertions(+), 251 deletions(-) create mode 100644 src/tsconfig/compiler-options.ts create mode 100644 src/tsconfig/index.ts create mode 100644 src/tsconfig/rulesets/configurable.ts create mode 100644 src/tsconfig/rulesets/generated.public.ts create mode 100644 src/tsconfig/rulesets/incompatible-options.ts create mode 100644 src/tsconfig/rulesets/minimal.public.ts create mode 100644 src/tsconfig/rulesets/strict.public.ts create mode 100644 src/tsconfig/tsconfig-validator.ts create mode 100644 src/tsconfig/validator.ts create mode 100644 test/tsconfig/helpers.ts create mode 100644 test/tsconfig/tsconfig-validator.test.ts create mode 100644 test/tsconfig/validator.test.ts diff --git a/build-tools/code-gen.ts b/build-tools/code-gen.ts index 2d717112..c599fe8d 100644 --- a/build-tools/code-gen.ts +++ b/build-tools/code-gen.ts @@ -44,7 +44,7 @@ const { commit, suffix } = (function () { writeFileSync( join(__dirname, '..', 'src', 'version.ts'), [ - "import { versionMajorMinor } from 'typescript'", + "import { versionMajorMinor } from 'typescript';", '', '// GENERATED: This file is generated by build-tools/code-gen.ts -- Do not edit by hand!', '', diff --git a/src/case.ts b/src/case.ts index 1a0f34da..3308b223 100644 --- a/src/case.ts +++ b/src/case.ts @@ -9,6 +9,7 @@ export const camel = withCache(Case.camel); export const constant = withCache(Case.constant); export const pascal = withCache(Case.pascal); export const snake = withCache(Case.snake); +export const kebab = withCache(Case.kebab); class Cache { public static fetch(text: string, func: (text: string) => string): string { diff --git a/src/compiler.ts b/src/compiler.ts index e85793ad..e62bfe12 100644 --- a/src/compiler.ts +++ b/src/compiler.ts @@ -5,38 +5,18 @@ import * as log4js from 'log4js'; import * as ts from 'typescript'; import { Assembler } from './assembler'; -import * as Case from './case'; import { findDependencyDirectory } from './common/find-utils'; import { emitDownleveledDeclarations, TYPES_COMPAT } from './downlevel-dts'; import { Emitter } from './emitter'; import { JsiiDiagnostic } from './jsii-diagnostic'; import { ProjectInfo } from './project-info'; import { WARNINGSCODE_FILE_NAME } from './transforms/deprecation-warnings'; +import { TypeScriptConfig, TypeScriptConfigValidationRuleSet } from './tsconfig'; +import { BASE_COMPILER_OPTIONS, convertForJson } from './tsconfig/compiler-options'; +import { TypeScriptConfigValidator } from './tsconfig/tsconfig-validator'; +import { ValidationError } from './tsconfig/validator'; import * as utils from './utils'; -const BASE_COMPILER_OPTIONS: ts.CompilerOptions = { - alwaysStrict: true, - declaration: true, - experimentalDecorators: true, - incremental: true, - lib: ['lib.es2020.d.ts'], - module: ts.ModuleKind.CommonJS, - noEmitOnError: true, - noFallthroughCasesInSwitch: true, - noImplicitAny: true, - noImplicitReturns: true, - noImplicitThis: true, - noUnusedLocals: true, - noUnusedParameters: true, - resolveJsonModule: true, - skipLibCheck: true, - strict: true, - strictNullChecks: true, - strictPropertyInitialization: true, - stripInternal: false, - target: ts.ScriptTarget.ES2020, -}; - const LOG = log4js.getLogger('jsii/compiler'); export const DIAGNOSTICS = 'diagnostics'; export const JSII_DIAGNOSTICS_CODE = 9999; @@ -57,10 +37,23 @@ export interface CompilerOptions { /** Whether to add warnings for deprecated elements */ addDeprecationWarnings?: boolean; /** - * The name of the tsconfig file to generate + * The name of the tsconfig file to generate. + * Cannot be used at the same time as `typeScriptConfig`. * @default "tsconfig.json" */ generateTypeScriptConfig?: string; + /** + * The name of the tsconfig file to use. + * Cannot be used at the same time as `generateTypeScriptConfig`. + * @default - generate the tsconfig file + */ + typeScriptConfig?: string; + /** + * The ruleset to validate the provided tsconfig file against. + * Can only be used when `typeScriptConfig` is provided. + * @default TypeScriptConfigValidationRuleSet.STRICT - if `typeScriptConfig` is provided + */ + validateTypeScriptConfig?: TypeScriptConfigValidationRuleSet; /** * Whether to compress the assembly * @default false @@ -68,50 +61,45 @@ export interface CompilerOptions { compressAssembly?: boolean; } -export interface TypescriptConfig { - compilerOptions: ts.CompilerOptions; - include?: string[]; - exclude?: string[]; - references?: ts.ProjectReference[]; -} - export class Compiler implements Emitter { private readonly system: ts.System; private readonly compilerHost: ts.CompilerHost; - private typescriptConfig?: TypescriptConfig; + private readonly userProvidedTypeScriptConfig: boolean; + private readonly tsConfig: TypeScriptConfig; private rootFiles: string[] = []; private readonly configPath: string; - private readonly projectReferences: boolean; + private readonly projectRoot: string; public constructor(private readonly options: CompilerOptions) { - const rootDir = this.options.projectInfo.projectRoot; + if (options.generateTypeScriptConfig != null && options.typeScriptConfig != null) { + throw new Error( + 'Cannot use `generateTypeScriptConfig` and `typeScriptConfig` together. Provide only one of them.', + ); + } + + this.projectRoot = this.options.projectInfo.projectRoot; + const configFileName = options.typeScriptConfig ?? options.generateTypeScriptConfig ?? 'tsconfig.json'; + this.configPath = path.join(this.projectRoot, configFileName); + this.userProvidedTypeScriptConfig = Boolean(options.typeScriptConfig); + this.tsConfig = this.configureTypeScript(); + this.system = { ...ts.sys, - getCurrentDirectory: () => rootDir, - createDirectory: (pth) => ts.sys.createDirectory(path.resolve(rootDir, pth)), - deleteFile: ts.sys.deleteFile && ((pth) => ts.sys.deleteFile!(path.join(rootDir, pth))), - fileExists: (pth) => ts.sys.fileExists(path.resolve(rootDir, pth)), - getFileSize: ts.sys.getFileSize && ((pth) => ts.sys.getFileSize!(path.resolve(rootDir, pth))), - readFile: (pth, encoding) => ts.sys.readFile(path.resolve(rootDir, pth), encoding), + getCurrentDirectory: () => this.projectRoot, + createDirectory: (pth) => ts.sys.createDirectory(path.resolve(this.projectRoot, pth)), + deleteFile: ts.sys.deleteFile && ((pth) => ts.sys.deleteFile!(path.join(this.projectRoot, pth))), + fileExists: (pth) => ts.sys.fileExists(path.resolve(this.projectRoot, pth)), + getFileSize: ts.sys.getFileSize && ((pth) => ts.sys.getFileSize!(path.resolve(this.projectRoot, pth))), + readFile: (pth, encoding) => ts.sys.readFile(path.resolve(this.projectRoot, pth), encoding), watchFile: ts.sys.watchFile && ((pth, callback, pollingInterval, watchOptions) => - ts.sys.watchFile!(path.resolve(rootDir, pth), callback, pollingInterval, watchOptions)), + ts.sys.watchFile!(path.resolve(this.projectRoot, pth), callback, pollingInterval, watchOptions)), writeFile: (pth, data, writeByteOrderMark) => - ts.sys.writeFile(path.resolve(rootDir, pth), data, writeByteOrderMark), + ts.sys.writeFile(path.resolve(this.projectRoot, pth), data, writeByteOrderMark), }; - this.compilerHost = ts.createIncrementalCompilerHost(BASE_COMPILER_OPTIONS, this.system); - const configFileName = options.generateTypeScriptConfig ?? 'tsconfig.json'; - - this.configPath = path.join(this.options.projectInfo.projectRoot, configFileName); - - this.projectReferences = - options.projectReferences !== undefined - ? options.projectReferences - : options.projectInfo.projectReferences !== undefined - ? options.projectInfo.projectReferences - : false; + this.compilerHost = ts.createIncrementalCompilerHost(this.tsConfig.compilerOptions, this.system); } /** @@ -120,8 +108,8 @@ export class Compiler implements Emitter { * @param files can be specified to override the standard source code location logic. Useful for example when testing "negatives". */ public emit(...files: string[]): ts.EmitResult { - this._prepareForBuild(...files); - return this._buildOnce(); + this.prepareForBuild(...files); + return this.buildOnce(); } /** @@ -137,21 +125,19 @@ export class Compiler implements Emitter { */ public async watch(): Promise; public async watch(opts?: NonBlockingWatchOptions): Promise | never> { - this._prepareForBuild(); + this.prepareForBuild(); - const pi = this.options.projectInfo; - const projectRoot = pi.projectRoot; const host = ts.createWatchCompilerHost( this.configPath, { - ...pi.tsc, - ...BASE_COMPILER_OPTIONS, + ...this.tsConfig.compilerOptions, noEmitOnError: false, }, this.system, ts.createEmitAndSemanticDiagnosticsBuilderProgram, opts?.reportDiagnostics, opts?.reportWatchStatus, + this.tsConfig.watchOptions, ); if (!host.getDefaultLibLocation) { throw new Error('No default library location was found on the TypeScript compiler host!'); @@ -162,10 +148,10 @@ export class Compiler implements Emitter { // // eslint-disable-next-line @typescript-eslint/no-misused-promises host.afterProgramCreate = (builderProgram) => { - const emitResult = this._consumeProgram(builderProgram.getProgram(), host.getDefaultLibLocation!()); + const emitResult = this.consumeProgram(builderProgram.getProgram(), host.getDefaultLibLocation!()); for (const diag of emitResult.diagnostics.filter((d) => d.code === JSII_DIAGNOSTICS_CODE)) { - utils.logDiagnostic(diag, projectRoot); + utils.logDiagnostic(diag, this.projectRoot); } if (orig) { @@ -191,29 +177,80 @@ export class Compiler implements Emitter { * * @param files the files that were specified as input in the CLI invocation. */ - private _prepareForBuild(...files: string[]) { - this.buildTypeScriptConfig(); - this.writeTypeScriptConfig(); + private configureTypeScript(): TypeScriptConfig { + if (this.userProvidedTypeScriptConfig) { + const config = this.readTypeScriptConfig(); + + // emit a warning if validation is disabled + const rules = this.options.validateTypeScriptConfig ?? TypeScriptConfigValidationRuleSet.NONE; + if (rules === TypeScriptConfigValidationRuleSet.NONE) { + utils.logDiagnostic( + JsiiDiagnostic.JSII_4009_DISABLED_TSCONFIG_VALIDATION.create(undefined, this.configPath), + this.projectRoot, + ); + } + + // validate the user provided config + if (rules !== TypeScriptConfigValidationRuleSet.NONE) { + try { + const validator = new TypeScriptConfigValidator(rules); + validator.validate({ + ...config, + // convert the internal format to the user format which is what the validator operates on + compilerOptions: convertForJson(config.compilerOptions), + }); + } catch (error: unknown) { + if (error instanceof ValidationError) { + utils.logDiagnostic( + JsiiDiagnostic.JSII_4000_FAILED_TSCONFIG_VALIDATION.create(undefined, this.configPath, error.violations), + this.projectRoot, + ); + } + + const configName = path.relative(this.projectRoot, this.configPath); + throw new Error( + `Failed validation of tsconfig "compilerOptions" in "${configName}" against rule set "${rules}"!`, + ); + } + } + + return config; + } + + // generated config if none is provided by the user + return this.buildTypeScriptConfig(); + } + + /** + * Final preparations of the project for build. + * + * These are preparations that either + * - must happen immediately before the build, or + * - can be different for every build like assigning the relevant root file(s). + * + * @param files the files that were specified as input in the CLI invocation. + */ + private prepareForBuild(...files: string[]) { + if (!this.userProvidedTypeScriptConfig) { + this.writeTypeScriptConfig(); + } + this.rootFiles = this.determineSources(files); } /** * Do a single build */ - private _buildOnce(): ts.EmitResult { + private buildOnce(): ts.EmitResult { if (!this.compilerHost.getDefaultLibLocation) { throw new Error('No default library location was found on the TypeScript compiler host!'); } - const tsconf = this.typescriptConfig!; - const pi = this.options.projectInfo; + const tsconf = this.tsConfig!; const prog = ts.createIncrementalProgram({ rootNames: this.rootFiles.concat(_pathOfLibraries(this.compilerHost)), - options: { - ...pi.tsc, - ...(tsconf?.compilerOptions ?? BASE_COMPILER_OPTIONS), - }, + options: tsconf.compilerOptions, // Make the references absolute for the compiler projectReferences: tsconf.references?.map((ref) => ({ path: path.resolve(path.dirname(this.configPath), ref.path), @@ -221,10 +258,10 @@ export class Compiler implements Emitter { host: this.compilerHost, }); - return this._consumeProgram(prog.getProgram(), this.compilerHost.getDefaultLibLocation()); + return this.consumeProgram(prog.getProgram(), this.compilerHost.getDefaultLibLocation()); } - private _consumeProgram(program: ts.Program, stdlib: string): ts.EmitResult { + private consumeProgram(program: ts.Program, stdlib: string): ts.EmitResult { const diagnostics = [...ts.getPreEmitDiagnostics(program)]; let hasErrors = false; @@ -298,24 +335,32 @@ export class Compiler implements Emitter { } /** - * Build the TypeScript config object + * Build the TypeScript config object from jsii config * - * This is the object that will be written to disk. + * This is the object that will be written to disk + * unless an existing tsconfig was provided. */ - private buildTypeScriptConfig() { + private buildTypeScriptConfig(): TypeScriptConfig { let references: string[] | undefined; - if (this.projectReferences) { + + const isComposite = + this.options.projectReferences !== undefined + ? this.options.projectReferences + : this.options.projectInfo.projectReferences !== undefined + ? this.options.projectInfo.projectReferences + : false; + if (isComposite) { references = this.findProjectReferences(); } const pi = this.options.projectInfo; - this.typescriptConfig = { + return { compilerOptions: { ...pi.tsc, ...BASE_COMPILER_OPTIONS, // Enable composite mode if project references are enabled - composite: this.projectReferences, + composite: isComposite, // When incremental, configure a tsbuildinfo file tsBuildInfoFile: path.join(pi.tsc?.outDir ?? '.', 'tsconfig.tsbuildinfo'), }, @@ -337,6 +382,26 @@ export class Compiler implements Emitter { }; } + /** + * Load the TypeScript config object from a provided file + */ + private readTypeScriptConfig(): TypeScriptConfig { + const projectRoot = this.options.projectInfo.projectRoot; + const { config, error } = ts.readConfigFile(this.configPath, ts.sys.readFile); + if (error) { + utils.logDiagnostic(error, projectRoot); + throw new Error(`Failed to load tsconfig at ${this.configPath}`); + } + const extended = ts.parseJsonConfigFileContent(config, ts.sys, projectRoot); + // the tsconfig parser adds this in, but it is not an expected compilerOption + delete extended.options.configFilePath; + + return { + compilerOptions: extended.options, + watchOptions: extended.watchOptions, + }; + } + /** * Creates a `tsconfig.json` file to improve the IDE experience. * @@ -346,7 +411,7 @@ export class Compiler implements Emitter { const commentKey = '_generated_by_jsii_'; const commentValue = 'Generated by jsii - safe to delete, and ideally should be in .gitignore'; - (this.typescriptConfig as any)[commentKey] = commentValue; + (this.tsConfig as any)[commentKey] = commentValue; if (fs.existsSync(this.configPath)) { const currentConfig = JSON.parse(fs.readFileSync(this.configPath, 'utf-8')); @@ -358,46 +423,12 @@ export class Compiler implements Emitter { } const outputConfig = { - ...this.typescriptConfig, - compilerOptions: { - ...this.typescriptConfig?.compilerOptions, - lib: this.typescriptConfig?.compilerOptions?.lib?.map((lib) => - // Drop the "lib." prefix and ".d.ts" suffix before writing up the tsconfig.json file - lib.slice(4, lib.length - 5), - ), - // Re-write the module, targets & jsx to be the JSON format instead of Programmatic API - module: (this.typescriptConfig?.compilerOptions?.module && - ts.ModuleKind[this.typescriptConfig.compilerOptions.module]) as any, - newLine: newLineForTsconfigJson(this.typescriptConfig?.compilerOptions.newLine), - target: (this.typescriptConfig?.compilerOptions?.target && - ts.ScriptTarget[this.typescriptConfig.compilerOptions.target]) as any, - jsx: (this.typescriptConfig?.compilerOptions?.jsx && - Case.snake(ts.JsxEmit[this.typescriptConfig.compilerOptions.jsx])) as any, - }, + ...this.tsConfig, + compilerOptions: convertForJson(this.tsConfig?.compilerOptions), }; LOG.debug(`Creating or updating ${chalk.blue(this.configPath)}`); fs.writeFileSync(this.configPath, JSON.stringify(outputConfig, null, 2), 'utf8'); - - /** - * This is annoying - the values expected in the tsconfig.json file are not - * the same as the enum constant names, or their values. So we need this - * function to map the "compiler API version" to the "tsconfig.json version" - * - * @param newLine the compiler form of the new line configuration - * - * @return the requivalent value to put in tsconfig.json - */ - function newLineForTsconfigJson(newLine: ts.NewLineKind | undefined) { - switch (newLine) { - case ts.NewLineKind.CarriageReturnLineFeed: - return 'crlf'; - case ts.NewLineKind.LineFeed: - return 'lf'; - default: - return undefined; - } - } } /** @@ -464,7 +495,7 @@ export class Compiler implements Emitter { } else { const parseConfigHost = parseConfigHostFromCompilerHost(this.compilerHost); const parsed = ts.parseJsonConfigFileContent( - this.typescriptConfig, + this.tsConfig, parseConfigHost, this.options.projectInfo.projectRoot, ); diff --git a/src/jsii-diagnostic.ts b/src/jsii-diagnostic.ts index 393f776d..b9f4a0f9 100644 --- a/src/jsii-diagnostic.ts +++ b/src/jsii-diagnostic.ts @@ -4,6 +4,7 @@ import * as ts from 'typescript'; import { TypeSystemHints } from './docs'; import { WARNINGSCODE_FILE_NAME } from './transforms/deprecation-warnings'; +import { Violation } from './tsconfig/validator'; import { JSII_DIAGNOSTICS_CODE, _formatDiagnostic } from './utils'; /** @@ -427,7 +428,24 @@ export class JsiiDiagnostic implements ts.Diagnostic { }); ////////////////////////////////////////////////////////////////////////////// - // 4000 => 4999 -- RESERVED + // 4000 => 4999 -- TYPESCRIPT & JSII CONFIG ERRORS + + public static readonly JSII_4000_FAILED_TSCONFIG_VALIDATION = Code.error({ + code: 4000, + formatter: (config: string, violations: Array) => { + return `Typescript compiler options in "${config}" are not passing validation, found the following rule violations:\n${violations + .map((v) => ` - ${v.field}: ${v.message}`) + .join('\n')}`; + }, + name: 'typescript-config/invalid-tsconfig', + }); + + public static readonly JSII_4009_DISABLED_TSCONFIG_VALIDATION = Code.warning({ + code: 4009, + formatter: (config: string) => + `Validation of typescript config "${config}" is disabled. This is intended for experimental setups only. Compilation might fail or produce incompatible artifacts.`, + name: 'typescript-config/disabled-tsconfig-validation', + }); ////////////////////////////////////////////////////////////////////////////// // 5000 => 5999 -- LANGUAGE COMPATIBILITY ERRORS diff --git a/src/main.ts b/src/main.ts index 0714f49e..eeedcb80 100644 --- a/src/main.ts +++ b/src/main.ts @@ -10,12 +10,44 @@ import { Compiler } from './compiler'; import { configureCategories } from './jsii-diagnostic'; import { loadProjectInfo } from './project-info'; import { emitSupportPolicyInformation } from './support'; +import { TypeScriptConfigValidationRuleSet } from './tsconfig'; import * as utils from './utils'; import { VERSION } from './version'; import { enabledWarnings } from './warnings'; const warningTypes = Object.keys(enabledWarnings); +function choiceWithDesc( + choices: { [choice: string]: string }, + desc: string, +): { + choices: string[]; + desc: string; +} { + return { + choices: Object.keys(choices), + desc: [desc, ...Object.entries(choices).map(([choice, docs]) => `${choice}: ${docs}`)].join('\n'), + }; +} + +enum OPTION_GROUP { + JSII = 'jsii compiler options:', + TS = 'TypeScript config options:', +} + +const ruleSets: { + [choice in TypeScriptConfigValidationRuleSet]: string; +} = { + [TypeScriptConfigValidationRuleSet.STRICT]: + 'Validates the provided config against a strict rule set designed for maximum backwards-compatibility.', + [TypeScriptConfigValidationRuleSet.GENERATED]: + 'Enforces a config as created by --generate-tsconfig. Use this to stay compatible with the generated config, but have full ownership over the file.', + [TypeScriptConfigValidationRuleSet.MINIMAL]: + 'Only enforce options that are known to be incompatible with jsii. This rule set is likely to be incomplete and new rules will be added without notice as incompatibilities emerge.', + [TypeScriptConfigValidationRuleSet.NONE]: + 'Disables all config validation, including options that are known to be incompatible with jsii. Intended for experimentation only. Use at your own risk.', +}; + (async () => { await emitSupportPolicyInformation(); @@ -38,9 +70,10 @@ const warningTypes = Object.keys(enabledWarnings); desc: 'Watch for file changes and recompile automatically', }) .option('project-references', { + group: OPTION_GROUP.JSII, alias: 'r', type: 'boolean', - desc: 'Generate TypeScript project references (also [package.json].jsii.projectReferences)', + desc: 'Generate TypeScript project references (also [package.json].jsii.projectReferences)\nHas no effect if --tsconfig is provided', }) .option('fix-peer-dependencies', { type: 'boolean', @@ -49,30 +82,51 @@ const warningTypes = Object.keys(enabledWarnings); hidden: true, }) .options('fail-on-warnings', { + group: OPTION_GROUP.JSII, alias: 'Werr', type: 'boolean', desc: 'Treat warnings as errors', }) .option('silence-warnings', { + group: OPTION_GROUP.JSII, type: 'array', default: [], desc: `List of warnings to silence (warnings: ${warningTypes.join(',')})`, }) .option('strip-deprecated', { + group: OPTION_GROUP.JSII, type: 'string', desc: '[EXPERIMENTAL] Hides all @deprecated members from the API (implementations remain). If an optional file name is given, only FQNs present in the file will be stripped.', }) .option('add-deprecation-warnings', { + group: OPTION_GROUP.JSII, type: 'boolean', default: false, desc: '[EXPERIMENTAL] Injects warning statements for all deprecated elements, to be printed at runtime', }) .option('generate-tsconfig', { + group: OPTION_GROUP.TS, type: 'string', - default: 'tsconfig.json', + defaultDescription: 'tsconfig.json', desc: 'Name of the typescript configuration file to generate with compiler settings', }) + .option('tsconfig', { + group: OPTION_GROUP.TS, + alias: 'c', + type: 'string', + desc: '[EXPERIMENTAL] Use this typescript configuration file to compile the jsii project.', + }) + .conflicts('tsconfig', ['generate-tsconfig', 'project-references']) + .option('validate-tsconfig', { + group: OPTION_GROUP.TS, + ...choiceWithDesc( + ruleSets, + '[EXPERIMENTAL] Validate the provided typescript configuration file against a set of rules.', + ), + default: TypeScriptConfigValidationRuleSet.STRICT, + }) .option('compress-assembly', { + group: OPTION_GROUP.JSII, type: 'boolean', default: false, desc: 'Emit a compressed version of the assembly', @@ -86,6 +140,10 @@ const warningTypes = Object.keys(enabledWarnings); async (argv) => { _configureLog4js(argv.verbose); + if (argv['generate-tsconfig'] != null && argv.tsconfig != null) { + throw new Error('Options --generate-tsconfig and --tsconfig are mutually exclusive'); + } + const projectRoot = path.normalize(path.resolve(process.cwd(), argv.PROJECT_ROOT)); const { projectInfo, diagnostics: projectInfoDiagnostics } = loadProjectInfo(projectRoot); @@ -109,6 +167,10 @@ const warningTypes = Object.keys(enabledWarnings); stripDeprecatedAllowListFile: argv['strip-deprecated'], addDeprecationWarnings: argv['add-deprecation-warnings'], generateTypeScriptConfig: argv['generate-tsconfig'], + typeScriptConfig: argv.tsconfig ?? projectInfo.packageJson.jsii?.tsconfig, + validateTypeScriptConfig: + (argv['validate-tsconfig'] as TypeScriptConfigValidationRuleSet) ?? + projectInfo.packageJson.jsii?.validateTsConfig, compressAssembly: argv['compress-assembly'], }); diff --git a/src/project-info.ts b/src/project-info.ts index 2228feea..35d8ad79 100644 --- a/src/project-info.ts +++ b/src/project-info.ts @@ -8,6 +8,7 @@ import * as ts from 'typescript'; import { findDependencyDirectory } from './common/find-utils'; import { JsiiDiagnostic } from './jsii-diagnostic'; +import { TypeScriptConfigValidationRuleSet } from './tsconfig'; import { parsePerson, parseRepository } from './utils'; // eslint-disable-next-line @typescript-eslint/no-var-requires, @typescript-eslint/no-require-imports @@ -77,6 +78,10 @@ export interface ProjectInfo { readonly exports?: { readonly [name: string]: string | { readonly [name: string]: string }; }; + + // user-provided tsconfig + readonly tsconfig?: string; + readonly validateTsConfig?: TypeScriptConfigValidationRuleSet; } export interface PackageJson { @@ -112,15 +117,22 @@ export interface PackageJson { readonly bundledDependencies?: readonly string[]; readonly jsii?: { + // main jsii config readonly diagnostics?: { readonly [id: string]: 'error' | 'warning' | 'suggestion' | 'message' }; readonly metadata?: { readonly [key: string]: unknown }; readonly targets?: { readonly [name: string]: unknown }; readonly versionFormat?: 'short' | 'full'; + // Either user-provided config ... + readonly tsconfig?: string; + readonly validateTsConfig?: string; + + // ... or configure tsc here readonly excludeTypescript?: readonly string[]; readonly projectReferences?: boolean; readonly tsc?: TSCompilerOptions; + // unexpected options readonly [key: string]: unknown; }; @@ -255,6 +267,10 @@ export function loadProjectInfo(projectRoot: string): ProjectInfoResult { bin: pkg.bin, exports: pkg.exports, diagnostics: _loadDiagnostics(pkg.jsii?.diagnostics), + + // user-provided tsconfig + tsconfig: pkg.jsii?.tsconfig, + validateTsConfig: _validateTsConfigRuleSet(pkg.jsii?.validateTsConfig ?? 'strict'), }; return { projectInfo, diagnostics }; } @@ -480,6 +496,21 @@ function _validateStability(stability: string | undefined, deprecated: string | return stability as spec.Stability; } +function _validateTsConfigRuleSet(ruleSet: string): TypeScriptConfigValidationRuleSet | undefined { + if (ruleSet == null) { + return undefined; + } + if (!Object.values(TypeScriptConfigValidationRuleSet).includes(ruleSet as any)) { + throw new Error( + `Invalid validateTsConfig "${ruleSet}", it must be one of ${Object.values(TypeScriptConfigValidationRuleSet).join( + ', ', + )}`, + ); + } + + return ruleSet as TypeScriptConfigValidationRuleSet; +} + /** * Resolves an NPM package specifier to a version range * diff --git a/src/tsconfig/compiler-options.ts b/src/tsconfig/compiler-options.ts new file mode 100644 index 00000000..3a98db56 --- /dev/null +++ b/src/tsconfig/compiler-options.ts @@ -0,0 +1,155 @@ +import * as ts from 'typescript'; +import * as Case from '../case'; + +export const BASE_COMPILER_OPTIONS: ts.CompilerOptions = { + alwaysStrict: true, + declaration: true, + experimentalDecorators: true, + incremental: true, + lib: ['lib.es2020.d.ts'], + module: ts.ModuleKind.CommonJS, + noEmitOnError: true, + noFallthroughCasesInSwitch: true, + noImplicitAny: true, + noImplicitReturns: true, + noImplicitThis: true, + noUnusedLocals: true, + noUnusedParameters: true, + resolveJsonModule: true, + skipLibCheck: true, + strict: true, + strictNullChecks: true, + strictPropertyInitialization: true, + stripInternal: false, + target: ts.ScriptTarget.ES2020, +}; + +/** + * Helper function to convert a TS enum into a list of allowed values, + * converting everything to camel case. + * This is used for example for the watch options + */ +export function enumAsCamel(enumData: Record): string[] { + return Object.keys(enumData) + .filter((v) => isNaN(Number(v))) + .map(Case.camel); +} + +/** + * Helper function to convert a TS enum into a list of allowed values, + * converting everything to lower case. + * This is used for example for the "target" compiler option + */ +export function enumAsLower(enumData: Record): string[] { + return Object.keys(enumData) + .filter((v) => isNaN(Number(v)) && v !== 'None') + .map((v) => v.toLowerCase()); +} + +/** + * Helper function to convert a TS enum into a list of allowed values, + * converting everything to kebab case. + * This is used for example for the "jsx" compiler option + */ +export function enumAsKebab(enumData: Record): string[] { + return Object.keys(enumData) + .filter((v) => isNaN(Number(v)) && v !== 'None') + .map(Case.kebab); +} + +/** + * The compilerOptions in the programmatic API are slightly differently than the format used in tsconfig.json + * This helper performs the necessary conversion from the programmatic API format the one used in tsconfig.json + * + * @param opt compilerOptions in programmatic API format + * @returns compilerOptions ready to be written on disk + */ +export function convertForJson(opt: ts.CompilerOptions): ts.CompilerOptions { + return { + ...opt, + + // Drop the "lib." prefix and ".d.ts" suffix before writing up the tsconfig.json file + ...valueHelper('lib', opt.lib, convertLibForJson), + + // Re-write the module, targets & jsx to be the JSON format instead of Programmatic API + ...enumHelper('importsNotUsedAsValues', opt.importsNotUsedAsValues, ts.ImportsNotUsedAsValues), + ...enumHelper('jsx', opt.jsx, ts.JsxEmit, Case.kebab), + ...enumHelper('module', opt.module, ts.ModuleKind), + ...enumHelper('moduleResolution', opt.moduleResolution, ts.ModuleResolutionKind), + ...enumHelper('moduleDetection', opt.moduleDetection, ts.ModuleDetectionKind), + ...enumHelper('target', opt.target, ts.ScriptTarget), + + // rewrite newline to be the JSON format instead of Programmatic API + ...valueHelper('newLine', opt.newLine, convertNewLineForJson), + }; +} + +function valueHelper( + name: string, + value: T | undefined, + converter: (value: T) => U, +): { + [name: string]: U; +} { + if (!value) { + return {}; + } + return { [name]: converter(value) }; +} + +function enumHelper( + name: string, + value: any, + enumObj: T, + converter?: (value: string) => string, +): { + [name: string]: string; +} { + if (!value) { + return {}; + } + return { [name]: convertEnumToJson(value, enumObj, converter) }; +} + +/** + * Convert an internal enum value to what a user would write in tsconfig.json + * Possibly using a converter function to adjust casing. + * @param value The internal enum value + * @param enumObj The enum object to convert from + * @param converter The converter function, defaults to lowercase + * @returns The humanized version of the enum value + */ +export function convertEnumToJson( + value: keyof T, + enumObj: T, + converter: (value: string) => string = (v) => v.toLowerCase(), +): string { + return converter(enumObj[value] as any); +} + +/** + * Convert the internal lib strings to what a user would write in tsconfig.json + * @param input The input libs array + * @returns The humanized version lib array + */ +export function convertLibForJson(input: string[]): string[] { + return input.map((lib) => lib.slice(4, lib.length - 5)); +} + +/** + * This is annoying - the values expected in the tsconfig.json file are not + * the same as the enum constant names, or their values. So we need this + * function to map the "compiler API version" to the "tsconfig.json version" + * + * @param newLine the compiler form of the new line configuration + * + * @return the equivalent value to put in tsconfig.json + */ +export function convertNewLineForJson(newLine: ts.NewLineKind): string { + switch (newLine) { + case ts.NewLineKind.CarriageReturnLineFeed: + return 'crlf'; + case ts.NewLineKind.LineFeed: + return 'lf'; + } +} diff --git a/src/tsconfig/index.ts b/src/tsconfig/index.ts new file mode 100644 index 00000000..5460dfd7 --- /dev/null +++ b/src/tsconfig/index.ts @@ -0,0 +1,19 @@ +import * as ts from 'typescript'; + +export interface TypeScriptConfig { + files?: string[]; + extends?: string | string[]; + include?: string[]; + exclude?: string[]; + references?: ts.ProjectReference[]; + compilerOptions: ts.CompilerOptions; + watchOptions?: ts.WatchOptions; + typeAcquisition?: ts.TypeAcquisition; +} + +export enum TypeScriptConfigValidationRuleSet { + STRICT = 'strict', + GENERATED = 'generated', + MINIMAL = 'minimal', + NONE = 'off', +} diff --git a/src/tsconfig/rulesets/configurable.ts b/src/tsconfig/rulesets/configurable.ts new file mode 100644 index 00000000..4421b1a0 --- /dev/null +++ b/src/tsconfig/rulesets/configurable.ts @@ -0,0 +1,22 @@ +import { Match, RuleSet } from '../validator'; + +// A rule set defining all compilerOptions that can be configured by users with or without constraints. +// This is an internal rule set, that may be used by other rule sets. + +// Settings previously configurable via the jsii field in package.json +// We accept all value for these +const configurable = new RuleSet(); +configurable.shouldPass('outdir', Match.ANY); +configurable.shouldPass('rootDir', Match.ANY); +configurable.shouldPass('forceConsistentCasingInFileNames', Match.ANY); +configurable.shouldPass('declarationMap', Match.ANY); +configurable.shouldPass('inlineSourceMap', Match.ANY); +configurable.shouldPass('inlineSources', Match.ANY); +configurable.shouldPass('sourceMap', Match.ANY); +configurable.shouldPass('types', Match.ANY); +configurable.shouldPass('baseUrl', Match.ANY); +configurable.shouldPass('paths', Match.ANY); +configurable.shouldPass('composite', Match.ANY); // configured via projectReferences +configurable.shouldPass('tsBuildInfoFile', Match.ANY); + +export default configurable; diff --git a/src/tsconfig/rulesets/generated.public.ts b/src/tsconfig/rulesets/generated.public.ts new file mode 100644 index 00000000..2953c1ef --- /dev/null +++ b/src/tsconfig/rulesets/generated.public.ts @@ -0,0 +1,31 @@ +import configurable from './configurable'; +import { BASE_COMPILER_OPTIONS, convertForJson } from '../compiler-options'; +import { Match, RuleSet, RuleType } from '../validator'; + +// The public rule set used for the "generated" tsconfig validation setting.\ +// The goal of this rule set is to ensure a tsconfig is compatible to the one jsii would generate for the user. +// It is explicitly enforcing option values that are used for the generated tsconfig, +// as well as options that can be configured via jsii settings. All other options are disallowed. +const generated = new RuleSet({ + unexpectedFields: RuleType.FAIL, +}); + +// import all options that are configurable via jsii settings +generated.import(configurable); + +// ... and all generated options +for (const [field, value] of Object.entries(convertForJson(BASE_COMPILER_OPTIONS))) { + if (typeof value === 'string') { + generated.shouldPass(field, Match.strEq(value, true)); + continue; + } + + if (Array.isArray(value)) { + generated.shouldPass(field, Match.arrEq(value)); + continue; + } + + generated.shouldPass(field, Match.eq(value)); +} + +export default generated; diff --git a/src/tsconfig/rulesets/incompatible-options.ts b/src/tsconfig/rulesets/incompatible-options.ts new file mode 100644 index 00000000..24cb84eb --- /dev/null +++ b/src/tsconfig/rulesets/incompatible-options.ts @@ -0,0 +1,10 @@ +import { Match, RuleSet } from '../validator'; + +// A rule set defining all compilerOptions that are explicitly known to be incompatible with jsii +// This is an internal rule set, that may be used by other rule sets. +const incompatibleOptions = new RuleSet(); +incompatibleOptions.shouldFail('noEmit', Match.TRUE); +incompatibleOptions.shouldFail('noLib', Match.TRUE); +incompatibleOptions.shouldFail('declaration', Match.FALSE); + +export default incompatibleOptions; diff --git a/src/tsconfig/rulesets/minimal.public.ts b/src/tsconfig/rulesets/minimal.public.ts new file mode 100644 index 00000000..09463a8b --- /dev/null +++ b/src/tsconfig/rulesets/minimal.public.ts @@ -0,0 +1,10 @@ +import incompatible from './incompatible-options'; +import { RuleSet } from '../validator'; + +// The public rule set used for the "minimal" tsconfig validation setting +// To goal of this rule set is to only prevent obvious misconfigurations, +// while leaving everything else up to the user. +const minimal = new RuleSet(); +minimal.import(incompatible); + +export default minimal; diff --git a/src/tsconfig/rulesets/strict.public.ts b/src/tsconfig/rulesets/strict.public.ts new file mode 100644 index 00000000..5897ca0b --- /dev/null +++ b/src/tsconfig/rulesets/strict.public.ts @@ -0,0 +1,32 @@ +import configurable from './configurable'; +import incompatible from './incompatible-options'; +import { Match, RuleSet, RuleType } from '../validator'; + +// The public rule set used for the "strict" tsconfig validation setting. +// The goal of this rule set is to ensure a tsconfig that is following best practices for jsii. +// In practice, this is a combination of known incompatible options, known configurable options and additional best practices. +// The rule set also explicitly disallows unknown options. +const strict = new RuleSet({ + unexpectedFields: RuleType.FAIL, +}); + +// import all options that are configurable +strict.import(configurable); + +// import all options that are definitely incompatible +strict.import(incompatible); + +// Best practice rules +strict.shouldPass('target', Match.eq('es2022')); // node18 +strict.shouldPass('lib', Match.arrEq(['es2022'])); // node18 +strict.shouldPass('module', Match.oneOf('node16', 'commonjs')); +strict.shouldPass('moduleResolution', Match.optional(Match.oneOf('node', 'node16'))); +strict.shouldPass('esModuleInterop', Match.TRUE); +strict.shouldPass('skipLibCheck', Match.TRUE); +strict.shouldPass('strict', Match.eq(true)); +strict.shouldPass('incremental', Match.ANY); + +// Deprecated ts options that should not be used with jsii +strict.shouldPass('prepend', Match.MISSING); + +export default strict; diff --git a/src/tsconfig/tsconfig-validator.ts b/src/tsconfig/tsconfig-validator.ts new file mode 100644 index 00000000..c0b9c572 --- /dev/null +++ b/src/tsconfig/tsconfig-validator.ts @@ -0,0 +1,51 @@ +import { TypeScriptConfig, TypeScriptConfigValidationRuleSet } from '.'; +import generated from './rulesets/generated.public'; +import minimal from './rulesets/minimal.public'; +import strict from './rulesets/strict.public'; +import { Match, ObjectValidator, RuleSet, RuleType } from './validator'; + +const RuleSets: { + [name in TypeScriptConfigValidationRuleSet]: RuleSet; +} = { + generated, + strict, + minimal, + off: new RuleSet(), +}; + +export class TypeScriptConfigValidator { + private readonly validator: ObjectValidator; + private readonly compilerOptions: ObjectValidator; + + public constructor(public ruleSet: TypeScriptConfigValidationRuleSet) { + const topLevelRules = new RuleSet({ + unexpectedFields: RuleType.PASS, + }); + topLevelRules.shouldPass('files', Match.ANY); + topLevelRules.shouldPass('extends', Match.ANY); + topLevelRules.shouldPass('include', Match.ANY); + topLevelRules.shouldPass('exclude', Match.ANY); + topLevelRules.shouldPass('references', Match.ANY); + topLevelRules.shouldPass('watchOptions', Match.ANY); + topLevelRules.shouldPass('typeAcquisition', Match.MISSING); + + this.compilerOptions = new ObjectValidator(RuleSets[ruleSet], 'compilerOptions'); + topLevelRules.shouldPass('compilerOptions', (compilerOptions) => { + this.compilerOptions.validate(compilerOptions); + return true; + }); + + this.validator = new ObjectValidator(topLevelRules, 'tsconfig'); + } + + /** + * Validated the provided config against the set of rules. + * + * @throws when the config is invalid + * + * @param tsconfig the tsconfig to be validated, this MUST be a tsconfig as a user would have written it in tsconfig. + */ + public validate(tsconfig: TypeScriptConfig) { + this.validator.validate(tsconfig); + } +} diff --git a/src/tsconfig/validator.ts b/src/tsconfig/validator.ts new file mode 100644 index 00000000..1497b18d --- /dev/null +++ b/src/tsconfig/validator.ts @@ -0,0 +1,388 @@ +/** + * A function that receives 3 arguments and validates if the provided value matches. + * @param value The value to validate + * @params options Additional options to influence the matcher behavior. + * @returns true if the value matches + */ +type Matcher = (value: any, options?: MatcherOptions) => boolean; + +interface MatcherOptions { + /** + * A function that will be called by the matcher with a a violation message. + * This function is always called, regardless of the outcome of the matcher. + * It is up to the caller of the matcher to decide if the message should be used or not. + * + * @param message The message describing the possible failure. + */ + reporter?: (message: string) => void; + /** + * A function that might receive explicitly allowed values. + * This can be used to generate synthetics values that would match the matcher. + * It is not guaranteed that hints are received or that hints are complete. + * + * @param allowed The list values that a matcher offers as definitely allowed. + */ + hints?: (allowed: any[]) => void; +} + +export enum RuleType { + PASS, + FAIL, +} + +export interface RuleSetOptions { + /** + * Defines the behavior for any encountered fields for which no rules are defined. + * The default is to pass these fields without validation, + * but this can also be set to fail any unexpected fields. + * + * @default RuleType.PASS + */ + readonly unexpectedFields: RuleType; +} + +interface Rule { + field: string; + type: RuleType; + matcher: Matcher; +} + +export class RuleSet { + private _rules: Array = []; + public get rules(): Array { + return this._rules; + } + + /** + * Return all fields for which a rule exists + */ + public get fields(): Array { + return [...new Set(this._rules.map((r) => r.field))]; + } + + /** + * Return a list of fields that are allowed, or undefined if all are allowed. + */ + public get allowedFields(): Array | undefined { + if (this.options.unexpectedFields === RuleType.FAIL) { + return this.fields; + } + + return undefined; + } + + /** + * Find all required fields by evaluating every rule in th set against undefined. + * If the rule fails, the key must be required. + * + * @returns A list of keys that must be included or undefined + */ + public get requiredFields(): Array { + const required: string[] = []; + + for (const rule of this._rules) { + const key = rule.field; + const matcherResult = rule.matcher(undefined); + + switch (rule.type) { + case RuleType.PASS: + if (!matcherResult) { + required.push(key); + } + break; + case RuleType.FAIL: + if (matcherResult) { + required.push(key); + } + break; + default: + continue; + } + } + + return required; + } + + public constructor( + public readonly options: RuleSetOptions = { + unexpectedFields: RuleType.PASS, + }, + ) {} + + /** + * Requires the matcher to pass for the given field. + * Otherwise a violation is detected. + * + * @param field The field the rule applies to + * @param matcher The matcher function + */ + public shouldPass(field: string, matcher: Matcher) { + this._rules.push({ field, matcher, type: RuleType.PASS }); + } + + /** + * Detects a violation if the matcher is matching for a certain field. + * + * @param field The field the rule applies to + * @param matcher The matcher function + */ + public shouldFail(field: string, matcher: Matcher) { + this._rules.push({ field, matcher, type: RuleType.FAIL }); + } + + /** + * Imports all rules from an other rule set. + * Note that any options from the other rule set will be ignored. + * + * @param other The other rule set to import rules from. + */ + public import(other: RuleSet) { + this._rules.push(...other.rules); + } + + /** + * Records the field hints for the given rule set. + * Hints are values that are guaranteed to pass the rule. + * The list of hints is not guaranteed to be complete nor does it guarantee to return any values. + * This can be used to create synthetic values for testing for error messages. + * + * @returns A record of fields and allowed values + */ + public getFieldHints(): Record { + const fieldHints: Record = {}; + + for (const rule of this._rules) { + // We are only interested in PASS rules here. + // For FAILs we still don't know which values would pass. + if (rule.type === RuleType.PASS) { + // run the matcher to record hints + rule.matcher(undefined, { + hints: (receivedHints: any[]) => { + // if we have recorded hints, add them to the map + if (receivedHints) { + fieldHints[rule.field] ??= []; + fieldHints[rule.field].push(...receivedHints); + } + }, + }); + } + } + + return fieldHints; + } +} + +/** + * Helper to wrap a matcher with error reporting and hints + */ +function wrapMatcher(matcher: Matcher, message: (actual: any) => string, allowed?: any[]): Matcher { + return (value, options) => { + options?.reporter?.(message(value)); + if (allowed) { + options?.hints?.(allowed); + } + return matcher(value); + }; +} + +export class Match { + /** + * Value is optional, but if present should match + */ + public static optional(matcher: Matcher): Matcher { + return (value, options) => { + if (value == null) { + return true; + } + return matcher(value, options); + }; + } + + /** + * Value must be one of the allowed options + */ + public static oneOf(...allowed: Array): Matcher { + return wrapMatcher( + (actual) => allowed.includes(actual), + (actual) => `Expected value to be one of ${JSON.stringify(allowed)}, got: ${JSON.stringify(actual)}`, + allowed, + ); + } + + /** + * Value must be loosely equal to the expected value + * Arrays are compared by elements + */ + public static eq(expected: any): Matcher { + return (actual, options) => { + if (Array.isArray(expected)) { + return Match.arrEq(expected)(actual, options); + } + + try { + options?.hints?.([expected]); + options?.reporter?.(`Expected value ${JSON.stringify(expected)}, got: ${JSON.stringify(actual)}`); + return actual == expected; + } catch { + // some values cannot compared using loose quality, in this case the matcher just fails + return false; + } + }; + } + + /** + * Value must be loosely equal to the expected value + * Arrays are compared by elements + */ + public static arrEq(expected: any[]): Matcher { + return wrapMatcher( + (actual) => { + try { + // if both are arrays and of the same length, compare elements + // if one of them is not, or they are a different length, + // skip comparing elements as the the equality check later will fail + if (Array.isArray(expected) && Array.isArray(actual) && expected.length == actual.length) { + // compare all elements with loose typing + return expected.every((e) => actual.some((a) => a == e)); + } + + // all other values and arrays of different shape + return actual == expected; + } catch { + // some values cannot compared using loose quality, in this case the matcher just fails + return false; + } + }, + (actual) => `Expected array matching ${JSON.stringify(expected)}, got: ${JSON.stringify(actual)}`, + [expected], + ); + } + + /** + * Compare strings, allows setting cases sensitivity + */ + public static strEq(expected: string, caseSensitive = false): Matcher { + return wrapMatcher( + (actual) => { + // case insensitive + if (!caseSensitive && typeof actual === 'string') { + return expected.toLowerCase() == actual.toLowerCase(); + } + + // case sensitive + return actual == expected; + }, + (actual: any) => `Expected string ${JSON.stringify(expected)}, got ${JSON.stringify(actual)}`, + [expected], + ); + } + + /** + * Allows any value + */ + public static ANY: Matcher = (_val, _options) => true; + + // eslint-disable-next-line @typescript-eslint/member-ordering + public static TRUE: Matcher = Match.eq(true); + // eslint-disable-next-line @typescript-eslint/member-ordering + public static FALSE: Matcher = Match.eq(false); + + /** + * Missing (undefined) value + */ + // eslint-disable-next-line @typescript-eslint/member-ordering + public static MISSING = wrapMatcher( + (actual) => actual === null || actual === undefined, + (actual) => `Expected value to be present, got ${JSON.stringify(actual)}`, + [undefined, null], + ); +} + +export interface Violation { + field: string; + message: string; +} + +export class ValidationError extends Error { + constructor(public readonly violations: Violation[]) { + // error message is a list of violations + super('Data is invalid:\n' + violations.map((v) => v.field + ': ' + v.message).join('\n')); + + // Set the prototype explicitly. + Object.setPrototypeOf(this, ValidationError.prototype); + } +} + +export class ObjectValidator { + public constructor(public ruleSet: RuleSet, private readonly dataName: string = 'data') {} + + /** + * Validated the provided data against the set of rules. + * + * @throws when the data is invalid + * + * @param data the data to be validated + */ + public validate(data: { [field: string]: any }) { + // make sure data is an object + if (!(typeof data === 'object' && !Array.isArray(data) && data !== null)) { + throw new ValidationError([ + { field: this.dataName, message: 'Provided data must be an object, got: ' + JSON.stringify(data) }, + ]); + } + + const checkedFields = new Set(); + const violations: Violation[] = []; + + // first check all defined rules + for (const rule of this.ruleSet.rules) { + const value = data[rule.field]; + + // Use a fallback message, but allow the matcher to report a better arrow + let violationMessage = 'Value is not allowed, got: ' + JSON.stringify(value); + const matchResult = rule.matcher(value, { + reporter: (message: string) => { + violationMessage = message; + }, + }); + + switch (rule.type) { + case RuleType.PASS: + if (!matchResult) { + violations.push({ + field: rule.field, + message: violationMessage, + }); + } + break; + case RuleType.FAIL: + if (matchResult) { + violations.push({ + field: rule.field, + message: violationMessage, + }); + } + break; + default: + continue; + } + + checkedFields.add(rule.field); + } + + // finally check fields without any rules if they should fail the validation + if (this.ruleSet.options.unexpectedFields === RuleType.FAIL) { + const receivedFields = Object.keys(data); + for (const field of receivedFields) { + if (!checkedFields.has(field)) { + violations.push({ field: field, message: `Unexpected field, got: ${field}` }); + } + } + } + + // if we have encountered a violation, throw an error + if (violations.length > 0) { + throw new ValidationError(violations); + } + } +} diff --git a/test/compiler.test.ts b/test/compiler.test.ts index 1aa142c8..ec051e24 100644 --- a/test/compiler.test.ts +++ b/test/compiler.test.ts @@ -2,10 +2,11 @@ import { mkdirSync, existsSync, mkdtempSync, rmSync, writeFileSync, readFileSync import { tmpdir } from 'node:os'; import { join } from 'node:path'; import { loadAssemblyFromPath, SPEC_FILE_NAME, SPEC_FILE_NAME_COMPRESSED } from '@jsii/spec'; - import { Compiler } from '../src/compiler'; import { TYPES_COMPAT } from '../src/downlevel-dts'; import { ProjectInfo } from '../src/project-info'; +import { TypeScriptConfigValidationRuleSet } from '../src/tsconfig'; +import { TypeScriptConfigValidator } from '../src/tsconfig/tsconfig-validator'; describe(Compiler, () => { describe('generated tsconfig', () => { @@ -35,122 +36,212 @@ describe(Compiler, () => { expectedTypeScriptConfig(), ); }); - }); - - test('"watch" mode', async () => { - const sourceDir = mkdtempSync(join(tmpdir(), 'jsii-compiler-watch-mode-')); - try { - writeFileSync(join(sourceDir, 'index.ts'), 'export class MarkerA {}'); - // Intentionally using lower case name - it should be case-insensitive - writeFileSync(join(sourceDir, 'readme.md'), '# Test Package'); + test('generated tsconfig passed validation', () => { + const sourceDir = mkdtempSync(join(tmpdir(), 'jsii-compiler-watch-mode-')); const compiler = new Compiler({ projectInfo: _makeProjectInfo(sourceDir, 'index.d.ts'), - failOnWarnings: true, - projectReferences: false, }); - let firstCompilation = true; - let onWatchClosed: () => void; - let onWatchFailed: (err: unknown) => void; - const watchClosed = new Promise((ok, ko) => { - onWatchClosed = ok; - onWatchFailed = ko; - }); - const watch = await compiler.watch({ - nonBlocking: true, - // Ignore diagnostics reporting (not to pollute test console output) - reportDiagnostics: () => null, - // Ignore watch status reporting (not to pollute test console output) - reportWatchStatus: () => null, - // Verify everything goes according to plan - compilationComplete: (emitResult) => { - try { - expect(emitResult.emitSkipped).toBeFalsy(); - const output = JSON.stringify(loadAssemblyFromPath(sourceDir)); - if (firstCompilation) { - firstCompilation = false; - expect(output).toContain('"MarkerA"'); - setImmediate(() => writeFileSync(join(sourceDir, 'index.ts'), 'export class MarkerB {}')); - return; + compiler.emit(); + + // read generated config + const tsconfig = JSON.parse(readFileSync(join(sourceDir, 'tsconfig.json'), 'utf-8')); + + // set up the tsconfig validator with rules for the generated file + const validator = new TypeScriptConfigValidator(TypeScriptConfigValidationRuleSet.GENERATED); + + expect(() => validator.validate(tsconfig)).not.toThrow(); + }); + + test('"watch" mode', async () => { + const sourceDir = mkdtempSync(join(tmpdir(), 'jsii-compiler-watch-mode-')); + + try { + writeFileSync(join(sourceDir, 'index.ts'), 'export class MarkerA {}'); + // Intentionally using lower case name - it should be case-insensitive + writeFileSync(join(sourceDir, 'readme.md'), '# Test Package'); + + const compiler = new Compiler({ + projectInfo: _makeProjectInfo(sourceDir, 'index.d.ts'), + failOnWarnings: true, + projectReferences: false, + }); + + let firstCompilation = true; + let onWatchClosed: () => void; + let onWatchFailed: (err: unknown) => void; + const watchClosed = new Promise((ok, ko) => { + onWatchClosed = ok; + onWatchFailed = ko; + }); + const watch = await compiler.watch({ + nonBlocking: true, + // Ignore diagnostics reporting (not to pollute test console output) + reportDiagnostics: () => null, + // Ignore watch status reporting (not to pollute test console output) + reportWatchStatus: () => null, + // Verify everything goes according to plan + compilationComplete: (emitResult) => { + try { + expect(emitResult.emitSkipped).toBeFalsy(); + const output = JSON.stringify(loadAssemblyFromPath(sourceDir)); + if (firstCompilation) { + firstCompilation = false; + expect(output).toContain('"MarkerA"'); + setImmediate(() => writeFileSync(join(sourceDir, 'index.ts'), 'export class MarkerB {}')); + return; + } + expect(output).toContain('"MarkerB"'); + watch.close(); + // Tell the test suite we're done here! + onWatchClosed(); + } catch (e) { + watch.close(); + onWatchFailed(e); } - expect(output).toContain('"MarkerB"'); - watch.close(); - // Tell the test suite we're done here! - onWatchClosed(); - } catch (e) { - watch.close(); - onWatchFailed(e); - } - }, - }); - await watchClosed; - } finally { - rmSync(sourceDir, { force: true, recursive: true }); - } - }, 15_000); - - test('rootDir is added to assembly', () => { - const outDir = 'jsii-outdir'; - const rootDir = 'jsii-rootdir'; - const sourceDir = mkdtempSync(join(tmpdir(), 'jsii-tmpdir')); - mkdirSync(join(sourceDir, rootDir), { recursive: true }); - - try { - writeFileSync(join(sourceDir, rootDir, 'index.ts'), 'export class MarkerA {}'); - // Intentionally using lower case name - it should be case-insensitive - writeFileSync(join(sourceDir, rootDir, 'readme.md'), '# Test Package'); + }, + }); + await watchClosed; + } finally { + rmSync(sourceDir, { force: true, recursive: true }); + } + }, 15_000); - const compiler = new Compiler({ - projectInfo: { - ..._makeProjectInfo(sourceDir, join(outDir, 'index.d.ts')), - tsc: { - outDir, - rootDir, + test('rootDir is added to assembly', () => { + const outDir = 'jsii-outdir'; + const rootDir = 'jsii-rootdir'; + const sourceDir = mkdtempSync(join(tmpdir(), 'jsii-tmpdir')); + mkdirSync(join(sourceDir, rootDir), { recursive: true }); + + try { + writeFileSync(join(sourceDir, rootDir, 'index.ts'), 'export class MarkerA {}'); + // Intentionally using lower case name - it should be case-insensitive + writeFileSync(join(sourceDir, rootDir, 'readme.md'), '# Test Package'); + + const compiler = new Compiler({ + projectInfo: { + ..._makeProjectInfo(sourceDir, join(outDir, 'index.d.ts')), + tsc: { + outDir, + rootDir, + }, }, - }, - failOnWarnings: true, - projectReferences: false, - }); + failOnWarnings: true, + projectReferences: false, + }); - compiler.emit(); + compiler.emit(); - const assembly = loadAssemblyFromPath(sourceDir); - expect(assembly.metadata).toEqual( - expect.objectContaining({ - tscRootDir: rootDir, - }), - ); - } finally { - rmSync(sourceDir, { force: true, recursive: true }); - } - }); + const assembly = loadAssemblyFromPath(sourceDir); + expect(assembly.metadata).toEqual( + expect.objectContaining({ + tscRootDir: rootDir, + }), + ); + } finally { + rmSync(sourceDir, { force: true, recursive: true }); + } + }); + + test('emits declaration map when feature is enabled', () => { + const sourceDir = mkdtempSync(join(tmpdir(), 'jsii-tmpdir')); - test('emits declaration map when feature is enabled', () => { - const sourceDir = mkdtempSync(join(tmpdir(), 'jsii-tmpdir')); + try { + writeFileSync(join(sourceDir, 'index.ts'), 'export class MarkerA {}'); + + const compiler = new Compiler({ + projectInfo: { + ..._makeProjectInfo(sourceDir, 'index.d.ts'), + tsc: { + declarationMap: true, + }, + }, + generateTypeScriptConfig: 'tsconfig.jsii.json', + }); - try { - writeFileSync(join(sourceDir, 'index.ts'), 'export class MarkerA {}'); + compiler.emit(); + + expect(() => { + readFileSync(join(sourceDir, 'index.d.ts.map'), 'utf-8'); + }).not.toThrow(); + } finally { + rmSync(sourceDir, { force: true, recursive: true }); + } + }); + }); + + describe('user-provided tsconfig', () => { + test('will use user-provided config', () => { + const sourceDir = mkdtempSync(join(tmpdir(), 'jsii-compiler-user-tsconfig-')); + const tsconfigPath = 'tsconfig.dev.json'; + writeFileSync(join(sourceDir, tsconfigPath), JSON.stringify(tsconfigForNode18Strict(), null, 2)); const compiler = new Compiler({ - projectInfo: { - ..._makeProjectInfo(sourceDir, 'index.d.ts'), - tsc: { - declarationMap: true, - }, - }, - generateTypeScriptConfig: 'tsconfig.jsii.json', + projectInfo: _makeProjectInfo(sourceDir, 'index.d.ts'), + typeScriptConfig: tsconfigPath, }); compiler.emit(); + }); + + test('"watch" mode', async () => { + const sourceDir = mkdtempSync(join(tmpdir(), 'jsii-compiler-watch-mode-')); + const tsconfigPath = 'tsconfig.dev.json'; + writeFileSync(join(sourceDir, tsconfigPath), JSON.stringify(tsconfigForNode18Strict(), null, 2)); + + try { + writeFileSync(join(sourceDir, 'index.ts'), 'export class MarkerA {}'); + // Intentionally using lower case name - it should be case-insensitive + writeFileSync(join(sourceDir, 'readme.md'), '# Test Package'); - expect(() => { - readFileSync(join(sourceDir, 'index.d.ts.map'), 'utf-8'); - }).not.toThrow(); - } finally { - rmSync(sourceDir, { force: true, recursive: true }); - } + const compiler = new Compiler({ + projectInfo: _makeProjectInfo(sourceDir, 'index.d.ts'), + failOnWarnings: true, + typeScriptConfig: tsconfigPath, + validateTypeScriptConfig: TypeScriptConfigValidationRuleSet.STRICT, + }); + + let firstCompilation = true; + let onWatchClosed: () => void; + let onWatchFailed: (err: unknown) => void; + const watchClosed = new Promise((ok, ko) => { + onWatchClosed = ok; + onWatchFailed = ko; + }); + const watch = await compiler.watch({ + nonBlocking: true, + // Ignore diagnostics reporting (not to pollute test console output) + reportDiagnostics: () => null, + // Ignore watch status reporting (not to pollute test console output) + reportWatchStatus: () => null, + // Verify everything goes according to plan + compilationComplete: (emitResult) => { + try { + expect(emitResult.emitSkipped).toBeFalsy(); + const output = JSON.stringify(loadAssemblyFromPath(sourceDir)); + if (firstCompilation) { + firstCompilation = false; + expect(output).toContain('"MarkerA"'); + setImmediate(() => writeFileSync(join(sourceDir, 'index.ts'), 'export class MarkerB {}')); + return; + } + expect(output).toContain('"MarkerB"'); + watch.close(); + // Tell the test suite we're done here! + onWatchClosed(); + } catch (e) { + watch.close(); + onWatchFailed(e); + } + }, + }); + await watchClosed; + } finally { + rmSync(sourceDir, { force: true, recursive: true }); + } + }, 15_000); }); describe('compressed assembly option', () => { @@ -212,6 +303,8 @@ describe(Compiler, () => { function _makeProjectInfo(sourceDir: string, types: string): ProjectInfo { return { projectRoot: sourceDir, + description: 'test', + homepage: 'https://github.com/aws/jsii-compiler', packageJson: {}, types, main: types.replace(/(?:\.d)?\.ts(x?)/, '.js$1'), @@ -247,7 +340,7 @@ function expectedTypeScriptConfig() { inlineSourceMap: true, inlineSources: true, lib: ['es2020'], - module: 'CommonJS', + module: 'commonjs', noEmitOnError: true, noFallthroughCasesInSwitch: true, noImplicitAny: true, @@ -261,10 +354,31 @@ function expectedTypeScriptConfig() { strictNullChecks: true, strictPropertyInitialization: true, stripInternal: false, - target: 'ES2020', + target: 'es2020', tsBuildInfoFile: 'tsconfig.tsbuildinfo', }, exclude: ['node_modules', TYPES_COMPAT], include: [join('**', '*.ts')], }; } + +/** + * An example of a user-provided config, based on the popular tsconfig/bases project & adjusted for the strict rule set + * @see https://github.com/tsconfig/bases/blob/main/bases/node18.json + */ +function tsconfigForNode18Strict() { + return { + compilerOptions: { + lib: ['es2022'], + module: 'node16', + target: 'es2022', + + strict: true, + esModuleInterop: true, + skipLibCheck: true, + moduleResolution: 'node16', + }, + exclude: ['node_modules', TYPES_COMPAT], + include: [join('**', '*.ts')], + }; +} diff --git a/test/project-info.test.ts b/test/project-info.test.ts index ab9acda9..42a180e5 100644 --- a/test/project-info.test.ts +++ b/test/project-info.test.ts @@ -7,6 +7,7 @@ import * as clone from 'clone'; import * as ts from 'typescript'; import { loadProjectInfo } from '../src/project-info'; +import { TypeScriptConfigValidationRuleSet } from '../src/tsconfig'; import { VERSION } from '../src/version'; const BASE_PROJECT = { @@ -253,6 +254,65 @@ describe('loadProjectInfo', () => { ); }); }); + + describe('user-provided tsconfig', () => { + test('can set a user-provided config', () => { + return _withTestProject( + (projectRoot) => expect(loadProjectInfo(projectRoot).projectInfo.tsconfig).toBe('tsconfig.dev.json'), + (info) => { + info.jsii.tsconfig = 'tsconfig.dev.json'; + }, + ); + }); + + test('if user-provided config is not set, default is undefined', () => { + return _withTestProject( + (projectRoot) => expect(loadProjectInfo(projectRoot).projectInfo.tsconfig).toBe(undefined), + (info) => { + info.jsii.tsconfig = undefined; + }, + ); + }); + + test.each(Object.entries(TypeScriptConfigValidationRuleSet))( + 'can set validation rule: "%s"', + (key: string, ruleSet: string) => { + return _withTestProject( + (projectRoot) => + expect(loadProjectInfo(projectRoot).projectInfo.validateTsConfig).toBe( + TypeScriptConfigValidationRuleSet[key as keyof typeof TypeScriptConfigValidationRuleSet], + ), + (info) => { + info.jsii.validateTsConfig = ruleSet; + }, + ); + }, + ); + + test('if validation rule set is not set, default is strict', () => { + return _withTestProject( + (projectRoot) => + expect(loadProjectInfo(projectRoot).projectInfo.validateTsConfig).toBe( + TypeScriptConfigValidationRuleSet.STRICT, + ), + (info) => { + info.jsii.validateTsConfig = undefined; + }, + ); + }); + + test.each([[''], [' '], ['definitely-invalid1234']])( + 'reject invalid validation rule set: "%s"', + (ruleSet: string) => { + return _withTestProject( + (projectRoot) => expect(() => loadProjectInfo(projectRoot)).toThrow(/Invalid validateTsConfig/), + (info) => { + info.jsii.validateTsConfig = ruleSet; + }, + ); + }, + ); + }); }); const TEST_DEP_ASSEMBLY: spec.Assembly = { diff --git a/test/tsconfig/helpers.ts b/test/tsconfig/helpers.ts new file mode 100644 index 00000000..0614ec25 --- /dev/null +++ b/test/tsconfig/helpers.ts @@ -0,0 +1,364 @@ +import * as fs from 'node:fs'; +import * as path from 'node:path'; +import fc from 'fast-check'; +import * as ts from 'typescript'; +import { TypeScriptConfig } from '../../src/tsconfig'; +import { + convertLibForJson, + convertNewLineForJson, + enumAsKebab, + enumAsLower, +} from '../../src/tsconfig/compiler-options'; +import { RuleSet, RuleType } from '../../src/tsconfig/validator'; + +/** + * An arbitrary but realistic TypeScriptConfig that matched the provided rule set. + * + * @param options - An object containing the properties used to generated an arbitrary tsconfig. + * @returns A TypeScriptConfig arbitrary. + */ +export function fcTsconfigRealistic(rules: RuleSet = new RuleSet(), _options: {} = {}) { + return fcTsconfig({ + compilerRuleSet: rules, + compilerArbs: { + // jsii does not care about this values + // however they need to match the file system + // for testing purposes we skip them + rootDir: fc.constant(undefined), + types: fc.constant(undefined), + paths: fc.constant(undefined), + baseUrl: fc.constant(undefined), + + // We are not testing incremental or composite builds + tsBuildInfoFile: fc.constant(undefined), + incremental: fc.constant(false), + composite: fc.constant(false), + + // Not interested in testing sourcemaps + sourceMap: fc.constant(undefined), + inlineSourceMap: fc.constant(undefined), + inlineSources: fc.constant(undefined), + + // Deprecated option + prepend: fc.constant(undefined), + }, + typeAcquisition: false, + references: false, + watchArbs: { + excludeDirectories: fc.constant([]), + excludeFiles: fc.constant([]), + }, + }); +} + +/** + * A TypeScriptConfig arbitrary. + * + * @param options - An object containing the properties used to generated an arbitrary tsconfig. + * @returns A TypeScriptConfig arbitrary. + */ +export function fcTsconfig( + options: { + /** + * Make sure generated options adhere to the rule set + */ + compilerRuleSet?: RuleSet; + /** + * Explicitly define some arbitraries for compilerOptions + */ + compilerArbs?: RecordModel>; + /** + * Explicitly define some arbitraries for watchOptions + */ + watchArbs?: RecordModel>; + /** + * Include typeAcquisition + * @default false + */ + typeAcquisition?: boolean; + /** + * Include references + * @default true + */ + references?: boolean; + } = {}, +): fc.Arbitrary { + const compilerOptionsModel: RecordModel> = { + allowImportingTsExtensions: fc.boolean(), + allowJs: fc.boolean(), + allowArbitraryExtensions: fc.boolean(), + allowSyntheticDefaultImports: fc.boolean(), + allowUmdGlobalAccess: fc.boolean(), + allowUnreachableCode: fc.boolean(), + allowUnusedLabels: fc.boolean(), + alwaysStrict: fc.boolean(), + baseUrl: fc.string(), + charset: fc.string(), + checkJs: fc.boolean(), + customConditions: fc.array(fc.string()), + declaration: fc.boolean(), + declarationMap: fc.boolean(), + emitDeclarationOnly: fc.boolean(), + declarationDir: fc.string(), + disableSizeLimit: fc.boolean(), + disableSourceOfProjectReferenceRedirect: fc.boolean(), + disableSolutionSearching: fc.boolean(), + disableReferencedProjectLoad: fc.boolean(), + downlevelIteration: fc.boolean(), + emitBOM: fc.boolean(), + emitDecoratorMetadata: fc.boolean(), + exactOptionalPropertyTypes: fc.boolean(), + experimentalDecorators: fc.boolean(), + forceConsistentCasingInFileNames: fc.boolean(), + ignoreDeprecations: fc.string(), + importHelpers: fc.boolean(), + importsNotUsedAsValues: fc.constantFrom(...(enumAsLower(ts.ImportsNotUsedAsValues) as any)), + inlineSourceMap: fc.boolean(), + inlineSources: fc.boolean(), + isolatedModules: fc.boolean(), + jsx: fc.constantFrom(...(enumAsKebab(ts.JsxEmit) as any)), + keyofStringsOnly: fc.boolean(), + lib: fc.uniqueArray(fc.constantFrom(...convertLibForJson(TS_LIBS))), + locale: fc.string(), + mapRoot: fc.string(), + maxNodeModuleJsDepth: fc.nat(), + module: fc.constantFrom(...(enumAsLower(ts.ModuleKind) as any)), + moduleResolution: fc.constantFrom(...(enumAsLower(ts.ModuleResolutionKind).filter((r) => r !== 'nodejs') as any)), // remove deprecated value + moduleSuffixes: fc.array(fc.string()), + moduleDetection: fc.constantFrom(...(enumAsLower(ts.ModuleDetectionKind) as any)), + newLine: fc.constantFrom( + // Newline has a special conversion + ...([ts.NewLineKind.CarriageReturnLineFeed, ts.NewLineKind.LineFeed].map(convertNewLineForJson) as any), + ), + noEmit: fc.boolean(), + noEmitHelpers: fc.boolean(), + noEmitOnError: fc.boolean(), + noErrorTruncation: fc.boolean(), + noFallthroughCasesInSwitch: fc.boolean(), + noImplicitAny: fc.boolean(), + noImplicitReturns: fc.boolean(), + noImplicitThis: fc.boolean(), + noStrictGenericChecks: fc.boolean(), + noUnusedLocals: fc.boolean(), + noUnusedParameters: fc.boolean(), + noImplicitUseStrict: fc.boolean(), + noPropertyAccessFromIndexSignature: fc.boolean(), + assumeChangesOnlyAffectDirectDependencies: fc.boolean(), + noLib: fc.boolean(), + noResolve: fc.boolean(), + noUncheckedIndexedAccess: fc.boolean(), + out: fc.string(), + outDir: fc.string(), + outFile: fc.string(), + paths: fc.dictionary(fc.string(), fc.array(fc.string())), + preserveConstEnums: fc.boolean(), + noImplicitOverride: fc.boolean(), + preserveSymlinks: fc.boolean(), + preserveValueImports: fc.boolean(), + project: fc.string(), + reactNamespace: fc.string(), + jsxFactory: fc.string(), + jsxFragmentFactory: fc.string(), + jsxImportSource: fc.string(), + composite: fc.boolean(), + incremental: fc.boolean(), + tsBuildInfoFile: fc.string(), + removeComments: fc.boolean(), + resolvePackageJsonExports: fc.boolean(), + resolvePackageJsonImports: fc.boolean(), + rootDir: fc.string(), + rootDirs: fc.array(fc.string()), + skipLibCheck: fc.boolean(), + skipDefaultLibCheck: fc.boolean(), + sourceMap: fc.boolean(), + sourceRoot: fc.string(), + strict: fc.boolean(), + strictFunctionTypes: fc.boolean(), + strictBindCallApply: fc.boolean(), + strictNullChecks: fc.boolean(), + strictPropertyInitialization: fc.boolean(), + stripInternal: fc.boolean(), + suppressExcessPropertyErrors: fc.boolean(), + suppressImplicitAnyIndexErrors: fc.boolean(), + target: fc.constantFrom(...(enumAsLower(ts.ScriptTarget).filter((t) => t !== 'json') as any)), // json target is not supported + traceResolution: fc.boolean(), + useUnknownInCatchVariables: fc.boolean(), + resolveJsonModule: fc.boolean(), + types: fc.array(fc.string()), + typeRoots: fc.array(fc.string()), + verbatimModuleSyntax: fc.boolean(), + esModuleInterop: fc.boolean(), + useDefineForClassFields: fc.boolean(), + }; + + // limit to only allowed keys + const allowedKeys = options.compilerRuleSet?.allowedFields ?? []; + + // prevent required options from being dropped + const requiredKeys = findRequiredKeysFromRuleSet(options.compilerRuleSet) ?? []; + + const watchOptionsModel: RecordModel> = { + watchFile: fc.constantFrom(...(enumAsLower(ts.WatchFileKind) as any)), + watchDirectory: fc.constantFrom(...(enumAsLower(ts.WatchDirectoryKind) as any)), + fallbackPolling: fc.constantFrom(...(enumAsLower(ts.PollingWatchKind) as any)), + synchronousWatchDirectory: fc.boolean(), + excludeDirectories: fc.array(fc.string()), + excludeFiles: fc.array(fc.string()), + }; + + return fc.record( + { + files: fc.array(fc.string()), + extends: fc.oneof(fc.string(), fc.array(fc.string())), + include: fc.array(fc.string()), + exclude: fc.array(fc.string()), + references: + options.references ?? true + ? fc.array( + fc.record( + { + path: fc.string(), + originalPath: fc.string(), + prepend: fc.boolean(), + circular: fc.boolean(), + }, + { + requiredKeys: ['path'], + }, + ), + ) + : fc.constant(undefined), + compilerOptions: fc.record( + filterArbsByRuleSet( + filterModelKeys( + { + ...compilerOptionsModel, // base arbitraries + ...constantsFromRuleSet(options.compilerRuleSet), // derived constants from the rule set + ...(options.compilerArbs || {}), // explicitly set arbitraries + }, + allowedKeys, + ), + options.compilerRuleSet, + ), + { + requiredKeys, + }, + ), + watchOptions: fc.record( + { + ...watchOptionsModel, // base arbitraries + ...(options.watchArbs || {}), // explicitly set arbitraries + }, + { withDeletedKeys: true }, + ), + + // typeAcquisition is usually not relevant for this project, so it is disabled by default + ...(options.typeAcquisition + ? { + typeAcquisition: fc.record( + { + enable: fc.boolean(), + include: fc.array(fc.string()), + exclude: fc.array(fc.string()), + disableFilenameBasedTypeAcquisition: fc.boolean(), + }, + { withDeletedKeys: true }, + ), + } + : {}), + }, + { + requiredKeys: ['compilerOptions'], + }, + ) as any; +} + +type RecordModel = { + [K in keyof T]: fc.Arbitrary; +}; + +function constantsFromRuleSet(rules: RuleSet = new RuleSet()): Record> { + const result: Record> = {}; + const fieldHints = rules.getFieldHints(); + for (const [field, values] of Object.entries(fieldHints)) { + if (values && Array.isArray(values) && values.length >= 1) { + result[field] = fc.constantFrom(...values); + } + } + + return result; +} + +/** + * Find all required keys by evaluating every rule in th set against undefined. + * If the rule fails, the key must be required. + * + * @param rules The rule set to test against + * @returns A list of keys that must be included + */ +function findRequiredKeysFromRuleSet(rules: RuleSet = new RuleSet()): string[] { + const required: string[] = []; + + for (const rule of rules.rules) { + const key = rule.field; + const matcherResult = rule.matcher(undefined); + + switch (rule.type) { + case RuleType.PASS: + if (!matcherResult) { + required.push(key); + } + break; + case RuleType.FAIL: + if (matcherResult) { + required.push(key); + } + break; + default: + continue; + } + } + + return required; +} + +function filterArbsByRuleSet(arbs: RecordModel, rules: RuleSet = new RuleSet()): RecordModel { + for (const rule of rules.rules) { + if ((rule.field as keyof typeof arbs) in arbs) { + const key: keyof typeof arbs = rule.field as any; + switch (rule.type) { + case RuleType.PASS: + arbs[key] = arbs[key].filter((v: any) => rule.matcher(v)); + break; + case RuleType.FAIL: + arbs[key] = arbs[key].filter((v: any) => !rule.matcher(v)); + break; + default: + continue; + } + } + } + + return arbs; +} + +function filterModelKeys(arbs: RecordModel, keepOnly?: string[]): RecordModel { + // if no keep is provided, return all keys + if (keepOnly === undefined) { + return arbs; + } + + const r = Object.fromEntries( + Object.entries(arbs).filter(([key]) => keepOnly?.includes(key) ?? true), + ) as RecordModel; + + return r; +} + +function tsLibs(): string[] { + const libdir = path.dirname(require.resolve('typescript')); + const allFiles = fs.readdirSync(libdir); + return allFiles.filter((file) => file.startsWith('lib.') && file.endsWith('.d.ts') && file !== 'lib.d.ts'); +} + +const TS_LIBS = tsLibs(); diff --git a/test/tsconfig/tsconfig-validator.test.ts b/test/tsconfig/tsconfig-validator.test.ts new file mode 100644 index 00000000..787070d9 --- /dev/null +++ b/test/tsconfig/tsconfig-validator.test.ts @@ -0,0 +1,51 @@ +import fc from 'fast-check'; +import { fcTsconfig } from './helpers'; +import { TypeScriptConfigValidationRuleSet } from '../../src/tsconfig'; +import rulesForGenerated from '../../src/tsconfig/rulesets/generated.public'; +import rulesForMinimal from '../../src/tsconfig/rulesets/minimal.public'; +import rulesForStrict from '../../src/tsconfig/rulesets/strict.public'; +import { TypeScriptConfigValidator } from '../../src/tsconfig/tsconfig-validator'; + +describe('rule sets', () => { + test('minimal', () => { + fc.assert( + fc.property( + fcTsconfig({ + compilerRuleSet: rulesForMinimal, + }), + (config) => { + const validator = new TypeScriptConfigValidator(TypeScriptConfigValidationRuleSet.MINIMAL); + validator.validate(config); + }, + ), + ); + }); + + test('generated', () => { + fc.assert( + fc.property( + fcTsconfig({ + compilerRuleSet: rulesForGenerated, + }), + (config) => { + const validator = new TypeScriptConfigValidator(TypeScriptConfigValidationRuleSet.GENERATED); + validator.validate(config); + }, + ), + ); + }); + + test('strict', () => { + fc.assert( + fc.property( + fcTsconfig({ + compilerRuleSet: rulesForStrict, + }), + (config) => { + const validator = new TypeScriptConfigValidator(TypeScriptConfigValidationRuleSet.STRICT); + validator.validate(config); + }, + ), + ); + }); +}); diff --git a/test/tsconfig/validator.test.ts b/test/tsconfig/validator.test.ts new file mode 100644 index 00000000..76abd572 --- /dev/null +++ b/test/tsconfig/validator.test.ts @@ -0,0 +1,193 @@ +import fc from 'fast-check'; +import { Match, ObjectValidator, RuleSet } from '../../src/tsconfig/validator'; + +const expectViolation = (expectedMessage: string, expected: any, actual: any) => { + return (message: string) => { + expect(message).toMatch(expectedMessage); + expect(message).toContain(String(JSON.stringify(expected))); // JSON.stringify might return undefined, force it as string + expect(message).toContain(String(JSON.stringify(actual))); + }; +}; + +describe('Built-in matchers', () => { + describe('Match.ANY', () => { + test('pass', () => { + fc.assert( + fc.property(fc.anything(), (actual) => { + return Match.ANY(actual); + }), + ); + }); + }); + + describe('Match.oneOf', () => { + test('pass', () => { + fc.assert( + fc.property( + fc + .array(fc.oneof(fc.string(), fc.integer(), fc.float()), { + minLength: 1, + }) + .chain((allowed) => fc.tuple(fc.constant(allowed), fc.constantFrom(...allowed))), + ([allowed, actual]) => { + return Match.oneOf(...allowed)(actual); + }, + ), + ); + }); + test('fail', () => { + fc.assert( + fc.property( + fc.uniqueArray(fc.oneof(fc.string(), fc.integer(), fc.float()), { + minLength: 1, + }), + (possible) => { + const allowed = possible.slice(0, -1); + const actual = possible.at(-1); + return !Match.oneOf(...allowed)(actual, { + reporter: expectViolation('Expected value to be one of', allowed, actual), + }); + }, + ), + ); + }); + }); + + describe('Match.eq', () => { + test('pass', () => { + fc.assert( + fc.property(fc.anything(), (value) => { + return Match.eq(value)(value); + }), + ); + }); + test('fail', () => { + fc.assert( + fc.property( + fc.uniqueArray( + // Need to remove empty arrays here as they are equal to each other + fc.anything(), + { + minLength: 2, + maxLength: 2, + // mimic what the matcher does + // this uses loose equality, while catching any not comparable values + comparator: (a, b) => { + try { + return a == b || Match.arrEq(a as any)(b); + } catch { + return false; + } + }, + }, + ), + ([expected, actual]) => { + const keyword = Array.isArray(expected) ? 'array' : 'value'; + return !Match.eq(expected)(actual, { reporter: expectViolation(`Expected ${keyword}`, expected, actual) }); + }, + ), + ); + }); + }); + + describe('Match.arrEq', () => { + test('pass', () => { + fc.assert( + fc.property( + fc + .array(fc.anything({ maxDepth: 0 })) + .chain((expected) => + fc.tuple( + fc.constant(expected), + fc.shuffledSubarray(expected, { minLength: expected.length, maxLength: expected.length }), + ), + ), + ([expected, actual]) => { + return Match.arrEq(expected)(actual); + }, + ), + ); + }); + test('fail', () => { + fc.assert( + fc.property( + fc.uniqueArray(fc.anything(), { + minLength: 1, + }), + fc.array(fc.anything()), + (possible, actualBase) => { + const expected = possible.slice(0, -1); + const actual = [...actualBase, possible.at(-1)]; + return !Match.arrEq(expected)(actual, { + reporter: expectViolation('Expected array matching', expected, actual), + }); + }, + ), + ); + }); + }); + + describe('Match.strEq case sensitive', () => { + test('pass', () => { + fc.assert( + fc.property(fc.string(), (value) => { + return Match.strEq(value, true)(value); + }), + ); + }); + test('fail', () => { + fc.assert( + fc.property( + fc.uniqueArray(fc.string(), { + minLength: 2, + maxLength: 2, + }), + ([expected, actual]) => { + return !Match.strEq(expected, true)(actual, { + reporter: expectViolation('Expected string', expected, actual), + }); + }, + ), + ); + }); + }); + + describe('Match.strEq case insensitive', () => { + test('pass', () => { + fc.assert( + fc.property( + fc.string({ minLength: 1 }).chain((s) => fc.tuple(fc.constant(s), fc.mixedCase(fc.constant(s)))), + ([expected, actual]) => { + return Match.strEq(expected, false)(actual); + }, + ), + ); + }); + test('fail', () => { + fc.assert( + fc.property( + fc.uniqueArray(fc.string(), { + minLength: 2, + maxLength: 2, + }), + ([expected, actual]) => { + return !Match.strEq(expected, true)(actual, { + reporter: expectViolation('Expected string', expected, actual), + }); + }, + ), + ); + }); + }); +}); + +describe('Object Validator', () => { + test('throws for non objects', () => { + fc.assert( + fc.property(fc.oneof(fc.anything({ maxDepth: 0 }), fc.array(fc.anything())), (data: any) => { + const validator = new ObjectValidator(new RuleSet(), 'testData'); + expect(() => validator.validate(data)).toThrow('Provided data must be an object'); + }), + ); + }); +}); diff --git a/yarn.lock b/yarn.lock index 1ea28bf5..1a0997cd 100644 --- a/yarn.lock +++ b/yarn.lock @@ -6044,16 +6044,7 @@ string-length@^4.0.1: char-regex "^1.0.2" strip-ansi "^6.0.0" -"string-width-cjs@npm:string-width@^4.2.0": - version "4.2.3" - resolved "https://registry.yarnpkg.com/string-width/-/string-width-4.2.3.tgz#269c7117d27b05ad2e536830a8ec895ef9c6d010" - integrity sha512-wKyQRQpjJ0sIp62ErSZdGsjMJWsap5oRNihHhu6G7JVO/9jIB6UyevL+tXuOqrng8j/cxKTWyWUwvSTriiZz/g== - dependencies: - emoji-regex "^8.0.0" - is-fullwidth-code-point "^3.0.0" - strip-ansi "^6.0.1" - -"string-width@^1.0.2 || 2 || 3 || 4", string-width@^4.1.0, string-width@^4.2.0, string-width@^4.2.3: +"string-width-cjs@npm:string-width@^4.2.0", "string-width@^1.0.2 || 2 || 3 || 4", string-width@^4.1.0, string-width@^4.2.0, string-width@^4.2.3: version "4.2.3" resolved "https://registry.yarnpkg.com/string-width/-/string-width-4.2.3.tgz#269c7117d27b05ad2e536830a8ec895ef9c6d010" integrity sha512-wKyQRQpjJ0sIp62ErSZdGsjMJWsap5oRNihHhu6G7JVO/9jIB6UyevL+tXuOqrng8j/cxKTWyWUwvSTriiZz/g== @@ -6106,14 +6097,7 @@ string_decoder@^1.1.1: dependencies: safe-buffer "~5.2.0" -"strip-ansi-cjs@npm:strip-ansi@^6.0.1": - version "6.0.1" - resolved "https://registry.yarnpkg.com/strip-ansi/-/strip-ansi-6.0.1.tgz#9e26c63d30f53443e9489495b2105d37b67a85d9" - integrity sha512-Y38VPSHcqkFrCpFnQ9vuSXmquuv5oXOKpGeT6aGrr3o3Gc9AlVa6JBfUSOCnbxGGZF+/0ooI7KrPuUSztUdU5A== - dependencies: - ansi-regex "^5.0.1" - -strip-ansi@^6.0.0, strip-ansi@^6.0.1: +"strip-ansi-cjs@npm:strip-ansi@^6.0.1", strip-ansi@^6.0.0, strip-ansi@^6.0.1: version "6.0.1" resolved "https://registry.yarnpkg.com/strip-ansi/-/strip-ansi-6.0.1.tgz#9e26c63d30f53443e9489495b2105d37b67a85d9" integrity sha512-Y38VPSHcqkFrCpFnQ9vuSXmquuv5oXOKpGeT6aGrr3o3Gc9AlVa6JBfUSOCnbxGGZF+/0ooI7KrPuUSztUdU5A== @@ -6686,7 +6670,7 @@ word-wrap@^1.2.5: resolved "https://registry.yarnpkg.com/word-wrap/-/word-wrap-1.2.5.tgz#d2c45c6dd4fbce621a66f136cbe328afd0410b34" integrity sha512-BN22B5eaMMI9UMtjrGd5g5eCYPpCPDUy0FJXbYsaT5zYxjFOckS53SQDE3pWkVoWpHXVb3BrYcEN4Twa55B5cA== -"wrap-ansi-cjs@npm:wrap-ansi@^7.0.0": +"wrap-ansi-cjs@npm:wrap-ansi@^7.0.0", wrap-ansi@^7.0.0: version "7.0.0" resolved "https://registry.yarnpkg.com/wrap-ansi/-/wrap-ansi-7.0.0.tgz#67e145cff510a6a6984bdf1152911d69d2eb9e43" integrity sha512-YVGIj2kamLSTxw6NsZjoBxfSwsn0ycdesmc4p+Q21c5zPuZ1pl+NfxVdxPtdHvmNVOQ6XSYG4AUtyt/Fi7D16Q== @@ -6704,15 +6688,6 @@ wrap-ansi@^6.2.0: string-width "^4.1.0" strip-ansi "^6.0.0" -wrap-ansi@^7.0.0: - version "7.0.0" - resolved "https://registry.yarnpkg.com/wrap-ansi/-/wrap-ansi-7.0.0.tgz#67e145cff510a6a6984bdf1152911d69d2eb9e43" - integrity sha512-YVGIj2kamLSTxw6NsZjoBxfSwsn0ycdesmc4p+Q21c5zPuZ1pl+NfxVdxPtdHvmNVOQ6XSYG4AUtyt/Fi7D16Q== - dependencies: - ansi-styles "^4.0.0" - string-width "^4.1.0" - strip-ansi "^6.0.0" - wrap-ansi@^8.1.0: version "8.1.0" resolved "https://registry.yarnpkg.com/wrap-ansi/-/wrap-ansi-8.1.0.tgz#56dc22368ee570face1b49819975d9b9a5ead214"