From 315ff4b4874806f0a9526f8ce79cd82bffc8bec6 Mon Sep 17 00:00:00 2001 From: Ian Schmitz Date: Tue, 23 Oct 2018 12:52:48 -0700 Subject: [PATCH] Respect tsconfig.json extends when validating config (#5537) * Use TS to resolve tsconfig extends * Prevent modifications to original tsconfig * Print friendly error --- packages/react-dev-utils/immer.js | 12 ++ packages/react-dev-utils/package.json | 2 + .../scripts/utils/verifyTypeScriptSetup.js | 122 ++++++++++++------ 3 files changed, 100 insertions(+), 36 deletions(-) create mode 100644 packages/react-dev-utils/immer.js diff --git a/packages/react-dev-utils/immer.js b/packages/react-dev-utils/immer.js new file mode 100644 index 00000000000..ab019eef891 --- /dev/null +++ b/packages/react-dev-utils/immer.js @@ -0,0 +1,12 @@ +/** + * Copyright (c) 2015-present, Facebook, Inc. + * + * This source code is licensed under the MIT license found in the + * LICENSE file in the root directory of this source tree. + */ + +'use strict'; + +var immer = require('immer'); + +module.exports = immer; diff --git a/packages/react-dev-utils/package.json b/packages/react-dev-utils/package.json index d7bb290de2c..7c3748af901 100644 --- a/packages/react-dev-utils/package.json +++ b/packages/react-dev-utils/package.json @@ -24,6 +24,7 @@ "getCSSModuleLocalIdent.js", "getProcessForPort.js", "ignoredFiles.js", + "immer.js", "InlineChunkHtmlPlugin.js", "inquirer.js", "InterpolateHtmlPlugin.js", @@ -53,6 +54,7 @@ "find-up": "3.0.0", "global-modules": "1.0.0", "gzip-size": "5.0.0", + "immer": "1.7.2", "inquirer": "6.2.0", "is-root": "2.0.0", "loader-utils": "1.1.0", diff --git a/packages/react-scripts/scripts/utils/verifyTypeScriptSetup.js b/packages/react-scripts/scripts/utils/verifyTypeScriptSetup.js index 2205d56634e..50491825da3 100644 --- a/packages/react-scripts/scripts/utils/verifyTypeScriptSetup.js +++ b/packages/react-scripts/scripts/utils/verifyTypeScriptSetup.js @@ -14,30 +14,12 @@ const resolve = require('resolve'); const path = require('path'); const paths = require('../../config/paths'); const os = require('os'); +const immer = require('react-dev-utils/immer').produce; function writeJson(fileName, object) { fs.writeFileSync(fileName, JSON.stringify(object, null, 2) + os.EOL); } -const compilerOptions = { - // These are suggested values and will be set when not present in the - // tsconfig.json - target: { suggested: 'es5' }, - allowJs: { suggested: true }, - skipLibCheck: { suggested: true }, - esModuleInterop: { suggested: true }, - allowSyntheticDefaultImports: { suggested: true }, - strict: { suggested: true }, - - // These values are required and cannot be changed by the user - module: { value: 'esnext', reason: 'for import() and import/export' }, - moduleResolution: { value: 'node', reason: 'to match webpack resolution' }, - resolveJsonModule: { value: true, reason: 'to match webpack loader' }, - isolatedModules: { value: true, reason: 'implementation limitation' }, - noEmit: { value: true }, - jsx: { value: 'preserve', reason: 'JSX is compiled by Babel' }, -}; - function verifyTypeScriptSetup() { let firstTimeSetup = false; @@ -86,20 +68,83 @@ function verifyTypeScriptSetup() { process.exit(1); } + const compilerOptions = { + // These are suggested values and will be set when not present in the + // tsconfig.json + // 'parsedValue' matches the output value from ts.parseJsonConfigFileContent() + target: { + parsedValue: ts.ScriptTarget.ES5, + suggested: 'es5', + }, + allowJs: { suggested: true }, + skipLibCheck: { suggested: true }, + esModuleInterop: { suggested: true }, + allowSyntheticDefaultImports: { suggested: true }, + strict: { suggested: true }, + + // These values are required and cannot be changed by the user + module: { + parsedValue: ts.ModuleKind.ESNext, + value: 'esnext', + reason: 'for import() and import/export', + }, + moduleResolution: { + parsedValue: ts.ModuleResolutionKind.NodeJs, + value: 'node', + reason: 'to match webpack resolution', + }, + resolveJsonModule: { value: true, reason: 'to match webpack loader' }, + isolatedModules: { value: true, reason: 'implementation limitation' }, + noEmit: { value: true }, + jsx: { + parsedValue: ts.JsxEmit.Preserve, + value: 'preserve', + reason: 'JSX is compiled by Babel', + }, + }; + + const formatDiagnosticHost = { + getCanonicalFileName: fileName => fileName, + getCurrentDirectory: ts.sys.getCurrentDirectory, + getNewLine: () => os.EOL, + }; + const messages = []; - let tsconfig; + let appTsConfig; + let parsedTsConfig; + let parsedCompilerOptions; try { - const { config, error } = ts.readConfigFile( + const { config: readTsConfig, error } = ts.readConfigFile( paths.appTsConfig, ts.sys.readFile ); if (error) { - throw error; + throw new Error(ts.formatDiagnostic(error, formatDiagnosticHost)); } - tsconfig = config; - } catch (_) { + appTsConfig = readTsConfig; + + // Get TS to parse and resolve any "extends" + // Calling this function also mutates the tsconfig above, + // adding in "include" and "exclude", but the compilerOptions remain untouched + let result; + parsedTsConfig = immer(readTsConfig, config => { + result = ts.parseJsonConfigFileContent( + config, + ts.sys, + path.dirname(paths.appTsConfig) + ); + }); + + if (result.errors && result.errors.length) { + throw new Error( + ts.formatDiagnostic(result.errors[0], formatDiagnosticHost) + ); + } + + parsedCompilerOptions = result.options; + } catch (e) { console.error( chalk.red.bold( 'Could not parse', @@ -107,27 +152,31 @@ function verifyTypeScriptSetup() { 'Please make sure it contains syntactically correct JSON.' ) ); + console.error(e && e.message ? `Details: ${e.message}` : ''); process.exit(1); } - if (tsconfig.compilerOptions == null) { - tsconfig.compilerOptions = {}; + if (appTsConfig.compilerOptions == null) { + appTsConfig.compilerOptions = {}; firstTimeSetup = true; } for (const option of Object.keys(compilerOptions)) { - const { value, suggested, reason } = compilerOptions[option]; + const { parsedValue, value, suggested, reason } = compilerOptions[option]; + + const valueToCheck = parsedValue === undefined ? value : parsedValue; + if (suggested != null) { - if (tsconfig.compilerOptions[option] === undefined) { - tsconfig.compilerOptions[option] = suggested; + if (parsedCompilerOptions[option] === undefined) { + appTsConfig.compilerOptions[option] = suggested; messages.push( `${chalk.cyan('compilerOptions.' + option)} to be ${chalk.bold( 'suggested' )} value: ${chalk.cyan.bold(suggested)} (this can be changed)` ); } - } else if (tsconfig.compilerOptions[option] !== value) { - tsconfig.compilerOptions[option] = value; + } else if (parsedCompilerOptions[option] !== valueToCheck) { + appTsConfig.compilerOptions[option] = value; messages.push( `${chalk.cyan('compilerOptions.' + option)} ${chalk.bold( 'must' @@ -137,14 +186,15 @@ function verifyTypeScriptSetup() { } } - if (tsconfig.include == null) { - tsconfig.include = ['src']; + // tsconfig will have the merged "include" and "exclude" by this point + if (parsedTsConfig.include == null) { + appTsConfig.include = ['src']; messages.push( `${chalk.cyan('include')} should be ${chalk.cyan.bold('src')}` ); } - if (tsconfig.exclude == null) { - tsconfig.exclude = ['**/__tests__/**', '**/?*test.*', '**/?*spec.*']; + if (parsedTsConfig.exclude == null) { + appTsConfig.exclude = ['**/__tests__/**', '**/?*test.*', '**/?*spec.*']; messages.push(`${chalk.cyan('exclude')} should exclude test files`); } @@ -171,7 +221,7 @@ function verifyTypeScriptSetup() { }); console.warn(); } - writeJson(paths.appTsConfig, tsconfig); + writeJson(paths.appTsConfig, appTsConfig); } // Copy type declarations associated with this version of `react-scripts`