diff --git a/packages/typescript/BUILD.bazel b/packages/typescript/BUILD.bazel index 704fa3e049..59b7dfc8d1 100644 --- a/packages/typescript/BUILD.bazel +++ b/packages/typescript/BUILD.bazel @@ -46,6 +46,7 @@ pkg_npm( ], deps = [ "@npm_bazel_typescript//internal:BUILD", + "@npm_bazel_typescript//internal:ts_project_options_validator.js", ] + select({ # FIXME: fix stardoc on Windows; @npm_bazel_typescript//:index.md generation fails with: # ERROR: D:/b/62unjjin/external/npm_bazel_typescript/BUILD.bazel:36:1: Couldn't build file diff --git a/packages/typescript/replacements.bzl b/packages/typescript/replacements.bzl index 06081bcbca..04f6c79fcd 100644 --- a/packages/typescript/replacements.bzl +++ b/packages/typescript/replacements.bzl @@ -24,6 +24,7 @@ TYPESCRIPT_REPLACEMENTS = dict( # @build_bazel_rules_typescript//:npm_bazel_typescript_package # use this alternate fencing "(#|\/\/)\\s+BEGIN-DEV-ONLY[\\w\W]+?(#|\/\/)\\s+END-DEV-ONLY": "", + "//internal:local_validator": "@npm//@bazel/typescript/bin:ts_project_options_validator", # This file gets vendored into our repo "@build_bazel_rules_typescript//internal:common": "//internal:common", } diff --git a/packages/typescript/src/internal/BUILD.bazel b/packages/typescript/src/internal/BUILD.bazel index 839caa36a6..1df9a3b5a2 100644 --- a/packages/typescript/src/internal/BUILD.bazel +++ b/packages/typescript/src/internal/BUILD.bazel @@ -12,12 +12,34 @@ # See the License for the specific language governing permissions and # limitations under the License. -# gazelle:exclude worker_protocol.proto - load("@bazel_skylib//:bzl_library.bzl", "bzl_library") +# gazelle:exclude worker_protocol.proto +load("@build_bazel_rules_nodejs//:index.bzl", "nodejs_binary") +load("@npm//typescript:index.bzl", "tsc") + package(default_visibility = ["//visibility:public"]) +tsc( + name = "compile", + outs = ["ts_project_options_validator.js"], + args = [ + "--outDir $(RULEDIR)", + "$(execpath ts_project_options_validator.ts)", + "--types node", + ], + data = [ + "ts_project_options_validator.ts", + "@npm//@types/node", + ], +) + +nodejs_binary( + name = "local_validator", + data = ["@npm//typescript"], + entry_point = "ts_project_options_validator.js", +) + bzl_library( name = "bzl", srcs = glob( diff --git a/packages/typescript/src/internal/ts_project.bzl b/packages/typescript/src/internal/ts_project.bzl index a9df74a363..228f57d9bb 100644 --- a/packages/typescript/src/internal/ts_project.bzl +++ b/packages/typescript/src/internal/ts_project.bzl @@ -132,6 +132,50 @@ ts_project = rule( attrs = dict(_ATTRS, **_OUTPUTS), ) +def _validate_options_impl(ctx): + # Bazel won't run our action unless its output is needed, so make a marker file + # We make it a .d.ts file so we can plumb it to the deps of the ts_project compile. + marker = ctx.actions.declare_file("%s.optionsvalid.d.ts" % ctx.label.name) + + arguments = ctx.actions.args() + arguments.add_all([ctx.file.tsconfig.path, marker.path, ctx.attr.target, struct( + declaration = ctx.attr.declaration, + declaration_map = ctx.attr.declaration_map, + composite = ctx.attr.composite, + emit_declaration_only = ctx.attr.emit_declaration_only, + source_map = ctx.attr.source_map, + incremental = ctx.attr.incremental, + ).to_json()]) + + run_node( + ctx, + inputs = [ctx.file.tsconfig] + ctx.files.extends, + outputs = [marker], + arguments = [arguments], + executable = "validator", + ) + return [ + DeclarationInfo( + transitive_declarations = depset([marker]), + ), + ] + +validate_options = rule( + implementation = _validate_options_impl, + attrs = { + "composite": attr.bool(), + "declaration": attr.bool(), + "declaration_map": attr.bool(), + "emit_declaration_only": attr.bool(), + "extends": attr.label_list(allow_files = [".json"]), + "incremental": attr.bool(), + "source_map": attr.bool(), + "target": attr.string(), + "tsconfig": attr.label(mandatory = True, allow_single_file = [".json"]), + "validator": attr.label(default = Label("//internal:local_validator"), executable = True, cfg = "host"), + }, +) + def _out_paths(srcs, ext): return [f[:f.rindex(".")] + ext for f in srcs if not f.endswith(".d.ts")] @@ -149,6 +193,7 @@ def ts_project_macro( incremental = False, emit_declaration_only = False, tsc = _DEFAULT_TSC, + validate = True, **kwargs): """Compiles one TypeScript project using `tsc --project` @@ -273,6 +318,8 @@ def ts_project_macro( For example, `tsc = "@my_deps//typescript/bin:tsc"` Or you can pass a custom compiler binary instead. + validate: boolean; whether to check that the tsconfig settings match the attributes. + declaration: if the `declaration` bit is set in the tsconfig. Instructs Bazel to expect a `.d.ts` output for each `.ts` source. source_map: if the `sourceMap` bit is set in the tsconfig. @@ -293,11 +340,28 @@ def ts_project_macro( if tsconfig == None: tsconfig = name + ".json" + extra_deps = [] + + if validate: + validate_options( + name = "_validate_%s_options" % name, + target = "//%s:%s" % (native.package_name(), name), + declaration = declaration, + source_map = source_map, + declaration_map = declaration_map, + composite = composite, + incremental = incremental, + emit_declaration_only = emit_declaration_only, + tsconfig = tsconfig, + extends = extends, + ) + extra_deps.append("_validate_%s_options" % name) + ts_project( name = name, srcs = srcs, - deps = deps, args = args, + deps = deps + extra_deps, tsconfig = tsconfig, extends = extends, js_outs = _out_paths(srcs, ".js") if not emit_declaration_only else [], diff --git a/packages/typescript/src/internal/ts_project_options_validator.ts b/packages/typescript/src/internal/ts_project_options_validator.ts new file mode 100644 index 0000000000..9512fccfa1 --- /dev/null +++ b/packages/typescript/src/internal/ts_project_options_validator.ts @@ -0,0 +1,83 @@ +import * as ts from 'typescript'; + +const diagnosticsHost: ts.FormatDiagnosticsHost = { + getCurrentDirectory: () => ts.sys.getCurrentDirectory(), + getNewLine: () => ts.sys.newLine, + // Print filenames including their relativeRoot, so they can be located on + // disk + getCanonicalFileName: (f: string) => f +}; + +function main([tsconfigPath, output, target, attrsStr]: string[]): 0|1 { + // The Bazel ts_project attributes were json-encoded + // (on Windows the quotes seem to be quoted wrong, so replace backslash with quotes :shrug:) + const attrs = JSON.parse(attrsStr.replace(/\\/g, '"')); + + // Parse your typescript settings from the tsconfig + // This will understand the "extends" semantics. + const {config, error} = ts.readConfigFile(tsconfigPath, ts.sys.readFile); + if (error) throw new Error(tsconfigPath + ':' + ts.formatDiagnostic(error, diagnosticsHost)); + const {errors, options} = + ts.parseJsonConfigFileContent(config, ts.sys, require('path').dirname(tsconfigPath)); + // We don't pass the srcs to this action, so it can't know if the program has the right sources. + // Diagnostics look like + // error TS18002: The 'files' list in config file 'tsconfig.json' is empty. + // error TS18003: No inputs were found in config file 'tsconfig.json'. Specified 'include'... + const fatalErrors = errors.filter(e => e.code !== 18002 && e.code != 18003); + if (fatalErrors.length > 0) + throw new Error(tsconfigPath + ':' + ts.formatDiagnostics(fatalErrors, diagnosticsHost)); + + const failures: string[] = []; + const buildozerCmds: string[] = []; + function check(option: string, attr?: string) { + attr = attr || option; + // treat compilerOptions undefined as false + const optionVal = options[option] === undefined ? false : options[option]; + if (optionVal !== attrs[attr]) { + failures.push( + `attribute ${attr}=${attrs[attr]} does not match compilerOptions.${option}=${optionVal}`); + buildozerCmds.push(`set ${attr} ${optionVal ? 'True' : 'False'}`); + } + } + + check('declarationMap', 'declaration_map'); + check('emitDeclarationOnly', 'emit_declaration_only'); + check('sourceMap', 'source_map'); + check('composite'); + check('declaration'); + check('incremental'); + + if (failures.length > 0) { + console.error(`ERROR: ts_project rule ${ + target} was configured with attributes that don't match the tsconfig`); + failures.forEach(f => console.error(' - ' + f)); + console.error('You can automatically fix this by running:'); + console.error(` npx @bazel/buildozer ${buildozerCmds.map(c => `'${c}'`).join(' ')} ${target}`); + console.error('Or to suppress this error, run:'); + console.error(` npx @bazel/buildozer 'set validate False' ${target}`); + return 1; + } + + // We have to write an output so that Bazel needs to execute this action. + // Make the output change whenever the attributes changed. + require('fs').writeFileSync( + output, ` +// ${process.argv[1]} checked attributes for ${target} +// composite: ${attrs.composite} +// declaration: ${attrs.declaration} +// declaration_map: ${attrs.declaration_map} +// incremental: ${attrs.incremental} +// source_map: ${attrs.source_map} +// emit_declaration_only: ${attrs.emit_declaration_only} +`, + 'utf-8'); + return 0; +} + +if (require.main === module) { + try { + process.exitCode = main(process.argv.slice(2)); + } catch (e) { + console.error(process.argv[1], e); + } +} \ No newline at end of file diff --git a/packages/typescript/src/package.json b/packages/typescript/src/package.json index d494711d41..2b3245a4bc 100644 --- a/packages/typescript/src/package.json +++ b/packages/typescript/src/package.json @@ -18,7 +18,8 @@ "main": "./internal/tsc_wrapped/index.js", "typings": "./internal/tsc_wrapped/index.d.ts", "bin": { - "tsc_wrapped": "./internal/tsc_wrapped/tsc_wrapped.js" + "tsc_wrapped": "./internal/tsc_wrapped/tsc_wrapped.js", + "ts_project_options_validator": "./internal/ts_project_options_validator.js" }, "//": "note that typescript doesn't follow semver, so technically anything 3.6 or higher might break us", "peerDependencies": { diff --git a/packages/typescript/test/ts_project/a/BUILD.bazel b/packages/typescript/test/ts_project/a/BUILD.bazel index ebbb07a282..37725b049a 100644 --- a/packages/typescript/test/ts_project/a/BUILD.bazel +++ b/packages/typescript/test/ts_project/a/BUILD.bazel @@ -3,6 +3,8 @@ load("@npm_bazel_typescript//:index.bzl", "ts_project") ts_project( composite = True, extends = ["//packages/typescript/test/ts_project:tsconfig-base.json"], - source_map = True, + # Intentionally not syncing this option from tsconfig, to test validator suppression + # source_map = True, + validate = False, visibility = ["//packages/typescript/test:__subpackages__"], ) diff --git a/packages/typescript/test/ts_project/empty_intermediate/tsconfig-a.json b/packages/typescript/test/ts_project/empty_intermediate/tsconfig-a.json index 3b2c25a809..3563bd1c00 100644 --- a/packages/typescript/test/ts_project/empty_intermediate/tsconfig-a.json +++ b/packages/typescript/test/ts_project/empty_intermediate/tsconfig-a.json @@ -1,6 +1,3 @@ { - "files": ["a.ts"], - "compilerOptions": { - "declaration": true - } + "files": ["a.d.ts"] } \ No newline at end of file diff --git a/packages/typescript/test/ts_project/validation/BUILD.bazel b/packages/typescript/test/ts_project/validation/BUILD.bazel new file mode 100644 index 0000000000..85e88399a3 --- /dev/null +++ b/packages/typescript/test/ts_project/validation/BUILD.bazel @@ -0,0 +1,9 @@ +load("@npm_bazel_typescript//:index.bzl", "ts_project") + +ts_project( + composite = True, + declaration = True, + declaration_map = True, + incremental = True, + source_map = True, +) diff --git a/packages/typescript/test/ts_project/validation/a.ts b/packages/typescript/test/ts_project/validation/a.ts new file mode 100644 index 0000000000..39db1c8627 --- /dev/null +++ b/packages/typescript/test/ts_project/validation/a.ts @@ -0,0 +1 @@ +export const a: number = 0; diff --git a/packages/typescript/test/ts_project/validation/tsconfig.json b/packages/typescript/test/ts_project/validation/tsconfig.json new file mode 100644 index 0000000000..19c65db5a6 --- /dev/null +++ b/packages/typescript/test/ts_project/validation/tsconfig.json @@ -0,0 +1,9 @@ +{ + "compilerOptions": { + "incremental": true, + "composite": true, + "declarationMap": true, + "declaration": true, + "sourceMap": true, + } +} \ No newline at end of file