From 2a966ad12bf79c4b9e5028680d257f7f84a52fd9 Mon Sep 17 00:00:00 2001 From: Alex Eagle Date: Sat, 8 Jan 2022 15:00:38 -0800 Subject: [PATCH] fix(typescript): add build_test to ensure typecheck is run under --build_tests_only (#3196) --- docs/TypeScript.md | 24 +- packages/typescript/internal/ts_project.bzl | 33 ++- .../bazelbuild/bazel-skylib/lib/dicts.bzl | 43 ++++ .../bazelbuild/bazel-skylib/lib/new_sets.bzl | 243 ++++++++++++++++++ .../bazel-skylib/rules/build_test.bzl | 114 ++++++++ 5 files changed, 439 insertions(+), 18 deletions(-) create mode 100644 third_party/github.com/bazelbuild/bazel-skylib/lib/dicts.bzl create mode 100644 third_party/github.com/bazelbuild/bazel-skylib/lib/new_sets.bzl create mode 100644 third_party/github.com/bazelbuild/bazel-skylib/rules/build_test.bzl diff --git a/docs/TypeScript.md b/docs/TypeScript.md index 74ef8e4cd0..788628e71d 100755 --- a/docs/TypeScript.md +++ b/docs/TypeScript.md @@ -758,15 +758,21 @@ See the packages/typescript/test/ts_project/swc directory for an example. When a custom transpiler is used, then the `ts_project` macro expands to these targets: - - `[name]` - the default target is a `js_library` which can be included in the `deps` of downstream rules. - Note that it will successfully build *even if there are typecheck failures* because the `tsc` binary - is not needed to produce the default outputs. - This is considered a feature, as it allows you to have a faster development mode where type-checking - is not on the critical path. - - `[name]_typecheck` - this target will fail to build if the type-checking fails, useful for CI. - - `[name]_typings` - internal target which runs the binary from the `tsc` attribute - - Any additional target(s) the custom transpiler rule/macro produces. - Some rules produce one target per TypeScript input file. +- `[name]` - the default target is a `js_library` which can be included in the `deps` of downstream rules. + Note that it will successfully build *even if there are typecheck failures* because the `tsc` binary + is not needed to produce the default outputs. + This is considered a feature, as it allows you to have a faster development mode where type-checking + is not on the critical path. +- `[name]_typecheck` - provides typings (`.d.ts` files) as the default output, + therefore building this target always causes the typechecker to run. +- `[name]_typecheck_test` - a + [`build_test`](https://github.com/bazelbuild/bazel-skylib/blob/main/rules/build_test.bzl) + target which simply depends on the `[name]_typecheck` target. + This ensures that typechecking will be run under `bazel test` with + [`--build_tests_only`](https://docs.bazel.build/versions/main/user-manual.html#flag--build_tests_only). +- `[name]_typings` - internal target which runs the binary from the `tsc` attribute +- Any additional target(s) the custom transpiler rule/macro produces. + Some rules produce one target per TypeScript input file. By default, `ts_project` expects `.js` outputs to be written in the same action that does the type-checking to produce `.d.ts` outputs. diff --git a/packages/typescript/internal/ts_project.bzl b/packages/typescript/internal/ts_project.bzl index ac3260210a..e1facd2e1a 100644 --- a/packages/typescript/internal/ts_project.bzl +++ b/packages/typescript/internal/ts_project.bzl @@ -4,6 +4,7 @@ load("@build_bazel_rules_nodejs//:providers.bzl", "DeclarationInfo", "ExternalNp load("@build_bazel_rules_nodejs//internal/linker:link_node_modules.bzl", "module_mappings_aspect") load("@build_bazel_rules_nodejs//internal/node:node.bzl", "nodejs_binary") load("@build_bazel_rules_nodejs//third_party/github.com/bazelbuild/bazel-skylib:lib/partial.bzl", "partial") +load("@build_bazel_rules_nodejs//third_party/github.com/bazelbuild/bazel-skylib:rules/build_test.bzl", "build_test") load("@build_bazel_rules_nodejs//:index.bzl", "js_library") load(":ts_config.bzl", "TsConfigInfo", "write_tsconfig") @@ -568,15 +569,21 @@ def ts_project_macro( When a custom transpiler is used, then the `ts_project` macro expands to these targets: - - `[name]` - the default target is a `js_library` which can be included in the `deps` of downstream rules. - Note that it will successfully build *even if there are typecheck failures* because the `tsc` binary - is not needed to produce the default outputs. - This is considered a feature, as it allows you to have a faster development mode where type-checking - is not on the critical path. - - `[name]_typecheck` - this target will fail to build if the type-checking fails, useful for CI. - - `[name]_typings` - internal target which runs the binary from the `tsc` attribute - - Any additional target(s) the custom transpiler rule/macro produces. - Some rules produce one target per TypeScript input file. + - `[name]` - the default target is a `js_library` which can be included in the `deps` of downstream rules. + Note that it will successfully build *even if there are typecheck failures* because the `tsc` binary + is not needed to produce the default outputs. + This is considered a feature, as it allows you to have a faster development mode where type-checking + is not on the critical path. + - `[name]_typecheck` - provides typings (`.d.ts` files) as the default output, + therefore building this target always causes the typechecker to run. + - `[name]_typecheck_test` - a + [`build_test`](https://github.com/bazelbuild/bazel-skylib/blob/main/rules/build_test.bzl) + target which simply depends on the `[name]_typecheck` target. + This ensures that typechecking will be run under `bazel test` with + [`--build_tests_only`](https://docs.bazel.build/versions/main/user-manual.html#flag--build_tests_only). + - `[name]_typings` - internal target which runs the binary from the `tsc` attribute + - Any additional target(s) the custom transpiler rule/macro produces. + Some rules produce one target per TypeScript input file. By default, `ts_project` expects `.js` outputs to be written in the same action that does the type-checking to produce `.d.ts` outputs. @@ -817,6 +824,7 @@ def ts_project_macro( tsc_target_name = "%s_typings" % name transpile_target_name = "%s_transpile" % name typecheck_target_name = "%s_typecheck" % name + test_target_name = "%s_typecheck_test" % name common_kwargs = { "tags": kwargs.get("tags", []), @@ -852,6 +860,13 @@ def ts_project_macro( **common_kwargs ) + # Ensures the target above gets built under `bazel test --build_tests_only` + build_test( + name = test_target_name, + targets = [typecheck_target_name], + **common_kwargs + ) + # Default target produced by the macro gives the js and map outs, with the transitive dependencies. js_library( name = name, diff --git a/third_party/github.com/bazelbuild/bazel-skylib/lib/dicts.bzl b/third_party/github.com/bazelbuild/bazel-skylib/lib/dicts.bzl new file mode 100644 index 0000000000..3f8e661a28 --- /dev/null +++ b/third_party/github.com/bazelbuild/bazel-skylib/lib/dicts.bzl @@ -0,0 +1,43 @@ +# Copyright 2017 The Bazel Authors. All rights reserved. +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. + +"""Skylib module containing functions that operate on dictionaries.""" + +def _add(*dictionaries, **kwargs): + """Returns a new `dict` that has all the entries of the given dictionaries. + + If the same key is present in more than one of the input dictionaries, the + last of them in the argument list overrides any earlier ones. + + This function is designed to take zero or one arguments as well as multiple + dictionaries, so that it follows arithmetic identities and callers can avoid + special cases for their inputs: the sum of zero dictionaries is the empty + dictionary, and the sum of a single dictionary is a copy of itself. + + Args: + *dictionaries: Zero or more dictionaries to be added. + **kwargs: Additional dictionary passed as keyword args. + + Returns: + A new `dict` that has all the entries of the given dictionaries. + """ + result = {} + for d in dictionaries: + result.update(d) + result.update(kwargs) + return result + +dicts = struct( + add = _add, +) diff --git a/third_party/github.com/bazelbuild/bazel-skylib/lib/new_sets.bzl b/third_party/github.com/bazelbuild/bazel-skylib/lib/new_sets.bzl new file mode 100644 index 0000000000..02510f2293 --- /dev/null +++ b/third_party/github.com/bazelbuild/bazel-skylib/lib/new_sets.bzl @@ -0,0 +1,243 @@ +# Copyright 2018 The Bazel Authors. All rights reserved. +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. + +"""Skylib module containing common hash-set algorithms. + + An empty set can be created using: `sets.make()`, or it can be created with some starting values + if you pass it an sequence: `sets.make([1, 2, 3])`. This returns a struct containing all of the + values as keys in a dictionary - this means that all passed in values must be hashable. The + values in the set can be retrieved using `sets.to_list(my_set)`. + + An arbitrary object can be tested whether it is a set generated by `sets.make()` or not with the + `types.is_set()` method in types.bzl. +""" + +load("//third_party/github.com/bazelbuild/bazel-skylib:lib/dicts.bzl", "dicts") + +def _make(elements = None): + """Creates a new set. + + All elements must be hashable. + + Args: + elements: Optional sequence to construct the set out of. + + Returns: + A set containing the passed in values. + """ + + # If you change the structure of a set, you need to also update the _is_set method + # in types.bzl. + elements = elements if elements else [] + return struct(_values = {e: None for e in elements}) + +def _copy(s): + """Creates a new set from another set. + + Args: + s: A set, as returned by `sets.make()`. + + Returns: + A new set containing the same elements as `s`. + """ + return struct(_values = dict(s._values)) + +def _to_list(s): + """Creates a list from the values in the set. + + Args: + s: A set, as returned by `sets.make()`. + + Returns: + A list of values inserted into the set. + """ + return s._values.keys() + +def _insert(s, e): + """Inserts an element into the set. + + Element must be hashable. This mutates the original set. + + Args: + s: A set, as returned by `sets.make()`. + e: The element to be inserted. + + Returns: + The set `s` with `e` included. + """ + s._values[e] = None + return s + +def _remove(s, e): + """Removes an element from the set. + + Element must be hashable. This mutates the original set. + + Args: + s: A set, as returned by `sets.make()`. + e: The element to be removed. + + Returns: + The set `s` with `e` removed. + """ + s._values.pop(e) + return s + +def _contains(a, e): + """Checks for the existence of an element in a set. + + Args: + a: A set, as returned by `sets.make()`. + e: The element to look for. + + Returns: + True if the element exists in the set, False if the element does not. + """ + return e in a._values + +def _get_shorter_and_longer(a, b): + """Returns two sets in the order of shortest and longest. + + Args: + a: A set, as returned by `sets.make()`. + b: A set, as returned by `sets.make()`. + + Returns: + `a`, `b` if `a` is shorter than `b` - or `b`, `a` if `b` is shorter than `a`. + """ + if _length(a) < _length(b): + return a, b + return b, a + +def _is_equal(a, b): + """Returns whether two sets are equal. + + Args: + a: A set, as returned by `sets.make()`. + b: A set, as returned by `sets.make()`. + + Returns: + True if `a` is equal to `b`, False otherwise. + """ + return a._values == b._values + +def _is_subset(a, b): + """Returns whether `a` is a subset of `b`. + + Args: + a: A set, as returned by `sets.make()`. + b: A set, as returned by `sets.make()`. + + Returns: + True if `a` is a subset of `b`, False otherwise. + """ + for e in a._values.keys(): + if e not in b._values: + return False + return True + +def _disjoint(a, b): + """Returns whether two sets are disjoint. + + Two sets are disjoint if they have no elements in common. + + Args: + a: A set, as returned by `sets.make()`. + b: A set, as returned by `sets.make()`. + + Returns: + True if `a` and `b` are disjoint, False otherwise. + """ + shorter, longer = _get_shorter_and_longer(a, b) + for e in shorter._values.keys(): + if e in longer._values: + return False + return True + +def _intersection(a, b): + """Returns the intersection of two sets. + + Args: + a: A set, as returned by `sets.make()`. + b: A set, as returned by `sets.make()`. + + Returns: + A set containing the elements that are in both `a` and `b`. + """ + shorter, longer = _get_shorter_and_longer(a, b) + return struct(_values = {e: None for e in shorter._values.keys() if e in longer._values}) + +def _union(*args): + """Returns the union of several sets. + + Args: + *args: An arbitrary number of sets. + + Returns: + The set union of all sets in `*args`. + """ + return struct(_values = dicts.add(*[s._values for s in args])) + +def _difference(a, b): + """Returns the elements in `a` that are not in `b`. + + Args: + a: A set, as returned by `sets.make()`. + b: A set, as returned by `sets.make()`. + + Returns: + A set containing the elements that are in `a` but not in `b`. + """ + return struct(_values = {e: None for e in a._values.keys() if e not in b._values}) + +def _length(s): + """Returns the number of elements in a set. + + Args: + s: A set, as returned by `sets.make()`. + + Returns: + An integer representing the number of elements in the set. + """ + return len(s._values) + +def _repr(s): + """Returns a string value representing the set. + + Args: + s: A set, as returned by `sets.make()`. + + Returns: + A string representing the set. + """ + return repr(s._values.keys()) + +sets = struct( + make = _make, + copy = _copy, + to_list = _to_list, + insert = _insert, + contains = _contains, + is_equal = _is_equal, + is_subset = _is_subset, + disjoint = _disjoint, + intersection = _intersection, + union = _union, + difference = _difference, + length = _length, + remove = _remove, + repr = _repr, + str = _repr, + # is_set is declared in types.bzl +) diff --git a/third_party/github.com/bazelbuild/bazel-skylib/rules/build_test.bzl b/third_party/github.com/bazelbuild/bazel-skylib/rules/build_test.bzl new file mode 100644 index 0000000000..70b9355a94 --- /dev/null +++ b/third_party/github.com/bazelbuild/bazel-skylib/rules/build_test.bzl @@ -0,0 +1,114 @@ +# Copyright 2019 The Bazel Authors. All rights reserved. +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. + +"""A test verifying other targets build as part of a `bazel test`""" + +load("//third_party/github.com/bazelbuild/bazel-skylib:lib/new_sets.bzl", "sets") + +def _empty_test_impl(ctx): + extension = ".bat" if ctx.attr.is_windows else ".sh" + content = "exit 0" if ctx.attr.is_windows else "#!/usr/bin/env bash\nexit 0" + executable = ctx.actions.declare_file(ctx.label.name + extension) + ctx.actions.write( + output = executable, + is_executable = True, + content = content, + ) + + return [DefaultInfo( + files = depset([executable]), + executable = executable, + runfiles = ctx.runfiles(files = ctx.files.data), + )] + +_empty_test = rule( + implementation = _empty_test_impl, + attrs = { + "data": attr.label_list(allow_files = True), + "is_windows": attr.bool(mandatory = True), + }, + test = True, +) + +def build_test(name, targets, **kwargs): + """Test rule checking that other targets build. + + This works not by an instance of this test failing, but instead by + the targets it depends on failing to build, and hence failing + the attempt to run this test. + + Typical usage: + + ``` + load("@bazel_skylib//rules:build_test.bzl", "build_test") + build_test( + name = "my_build_test", + targets = [ + "//some/package:rule", + ], + ) + ``` + + Args: + name: The name of the test rule. + targets: A list of targets to ensure build. + **kwargs: The common attributes for tests. + """ + if len(targets) == 0: + fail("targets must be non-empty", "targets") + if kwargs.get("data", None): + fail("data is not supported on a build_test()", "data") + + # Remove any duplicate test targets. + targets = sets.to_list(sets.make(targets)) + + # Use a genrule to ensure the targets are built (works because it forces + # the outputs of the other rules on as data for the genrule) + + # Split into batches to hopefully avoid things becoming so large they are + # too much for a remote execution set up. + batch_size = max(1, len(targets) // 100) + + # Pull a few args over from the test to the genrule. + args_to_reuse = ["compatible_with", "restricted_to", "tags"] + genrule_args = {k: kwargs.get(k) for k in args_to_reuse if k in kwargs} + + # Pass an output from the genrules as data to a shell test to bundle + # it all up in a test. + test_data = [] + + for idx, batch in enumerate([targets[i:i + batch_size] for i in range(0, len(targets), batch_size)]): + full_name = "{name}_{idx}__deps".format(name = name, idx = idx) + test_data.append(full_name) + native.genrule( + name = full_name, + srcs = batch, + outs = [full_name + ".out"], + testonly = 1, + visibility = ["//visibility:private"], + cmd = "touch $@", + cmd_bat = "type nul > $@", + **genrule_args + ) + + _empty_test( + name = name, + data = test_data, + size = kwargs.pop("size", "small"), # Default to small for test size + is_windows = select({ + "@bazel_tools//src/conditions:host_windows": True, + "//conditions:default": False, + }), + **kwargs + )