angular/tools/bazel/tsec.bzl
Paul Gschwendtner 61fb2fff2a build: add modified version of tsec that can work with interop (#61336)
As we roll out more PRs that migrate to `ts_project`, we need to make
sure that we don't break existing `tsec` targets. This PR copies the
original `tsec` Starlark code and adjusts it to work with the interop.

Note that we don't patch as this would unnecessarily complicate this
file that only exists during migration. Long-term, after migration, we
either need to consider switching to the new ESLint plugin that is being
worked on (I'm in contact with the team), or we send a CL to update the
Tsec Starlark code to keep our changes minimal and avoid scope increase.

PR Close #61336
2025-05-14 08:31:33 -07:00

253 lines
9.2 KiB
Python

"""Bazel rules and macros for running tsec over a ng_module or ts_library."""
load("@aspect_rules_js//js:providers.bzl", "JsInfo")
load("@bazel_skylib//lib:new_sets.bzl", "sets")
load("@build_bazel_rules_nodejs//:index.bzl", "nodejs_test")
load("@build_bazel_rules_nodejs//:providers.bzl", "DeclarationInfo", "NpmPackageInfo")
load("@npm//@bazel/concatjs/internal:ts_config.bzl", "TsConfigInfo")
TsecInteropSrcs = provider("Sources used by an interop target", fields = ["srcs"])
TsecTargetInfo = provider("Attributes required for tsec_test to generate tsconfig.json", fields = ["srcs", "deps", "module_name", "paths", "node_modules_root"])
def _capture_tsec_attrs_aspect_impl(target, ctx):
"""Capture certain attributes of `ts_library` into a TsecTargetInfo provider."""
# If we come across a `ts_project`, don't propagate anything. All information
# has to go through the interop targets and the logic below. We just expose
# the real `.ts` sources here.
if ctx.rule.kind == "ts_project":
return [
TsecInteropSrcs(
srcs = ctx.rule.attr.srcs,
),
]
module_name_from_providers = getattr(target, "module_name", None)
module_name_from_attrs = getattr(ctx.rule.attr, "module_name", None)
module_name = module_name_from_providers if module_name_from_providers else module_name_from_attrs
module_root = getattr(ctx.rule.attr, "module_root", None)
paths = {}
deps = []
node_modules_root = None
if module_name:
paths[module_name] = "%s/%s" % (target.label.package, module_root) if module_root else target.label.package
# Note: We might come across an interop target. Their deps include the RJS deps
# as well. Those might ultimately point to a real `//:node_modules/x` target without
# any `deps` attribute. This is fine as we also get the "rules_nodejs" target; so
# gracefully proceed with an empty set of "deps" then.
for d in getattr(ctx.rule.attr, "deps", []):
if TsecTargetInfo in d:
paths.update(d[TsecTargetInfo].paths)
if DeclarationInfo in d:
deps.append(d[DeclarationInfo].transitive_declarations)
if JsInfo in d:
deps.append(d[JsInfo].transitive_types)
if node_modules_root == None and NpmPackageInfo in d:
node_modules_root = "/".join(["external", d[NpmPackageInfo].workspace, "node_modules"])
srcs = getattr(ctx.rule.attr, "srcs", [])
# If this is an interop target, pull the `srcs` from the underlying `ts_project`.
if ctx.rule.kind == "ts_project_module":
srcs = ctx.rule.attr.dep[TsecInteropSrcs].srcs
return [
TsecTargetInfo(
srcs = srcs,
deps = depset(transitive = deps),
module_name = module_name,
paths = paths,
node_modules_root = node_modules_root,
),
]
_capture_tsec_attrs_aspect = aspect(
implementation = _capture_tsec_attrs_aspect_impl,
attr_aspects = ["deps", "dep"],
)
def _generate_tsconfig(bin_dir_path, target, base_tsconfig, use_runfiles):
tsconfig = {"bazel": True}
pkg_base_dir = "/".join([".."] * len(target.label.package.split("/")))
# With runfiles, the location of the source code is the same as the generated .d.ts, i.e., the workspace root.
# Without runfiles, the source code remains in the Bazel package folder in the source tree, so `src_base_dir`
# has to go to the execroot first and further back to the source tree.
src_base_dir = pkg_base_dir if use_runfiles else "/".join([".."] * len(bin_dir_path.split("/")) + [pkg_base_dir])
if base_tsconfig:
base = src_base_dir if base_tsconfig.is_source else pkg_base_dir
tsconfig["extends"] = base + "/" + base_tsconfig.short_path
compiler_options = {"noEmit": True}
compiler_options["baseUrl"] = src_base_dir
tslib_info = target[TsecTargetInfo]
paths = {}
for name, path in tslib_info.paths.items():
paths[name] = [path]
paths["%s/*" % name] = ["%s/*" % path]
if not use_runfiles:
paths[name].append(bin_dir_path + "/" + path)
node_modules_root = tslib_info.node_modules_root
if node_modules_root != None:
type_roots = [node_modules_root, node_modules_root + "/@types"]
paths["*"] = ["%s/*" % r for r in type_roots]
compiler_options["typeRoots"] = ["%s/%s/*" % (src_base_dir, r) for r in type_roots]
compiler_options["paths"] = paths
if not use_runfiles:
compiler_options["rootDirs"] = [src_base_dir, src_base_dir + "/" + bin_dir_path]
tsconfig["compilerOptions"] = compiler_options
files = sets.make()
for s in tslib_info.srcs:
if hasattr(s, "files"):
for f in s.files.to_list():
base = src_base_dir if f.is_source else pkg_base_dir
sets.insert(files, base + "/" + f.short_path)
for f in tslib_info.deps.to_list():
# Do not include non-TS files
if f.extension not in ["ts", "tsx"]:
continue
path = f.short_path
# Do not include ngc produced files
if path.endswith(".ngfactory.d.ts") or path.endswith(".ngsummary.d.ts"):
continue
if not use_runfiles and f.owner.workspace_name == "npm":
# For npm hosted source files, we need to go further back two levels
# of directories (workspace and execroot) from `src_base_dir`, and
# then go to node_modules_root (and trim off the redundant "npm"
# segment that exists in both `path` and `node_modules_root`.
base = "../../%s/%s/.." % (src_base_dir, node_modules_root)
else:
base = src_base_dir if f.is_source else pkg_base_dir
sets.insert(files, base + "/" + path)
tsconfig["files"] = sets.to_list(files)
return json.encode(tsconfig)
TsecTsconfigInfo = provider("Transitive depenedencies of tsconfig for tsec_test", fields = ["files"])
def _tsec_config_impl(ctx):
deps = []
base_tsconfig_src = None
base = ctx.attr.base
# Gather all base tsconfig files and the exemption list.
if base:
if TsConfigInfo not in base:
fail("`base` must be a ts_config target")
deps.extend(base[TsConfigInfo].deps)
base_tsconfig_src = ctx.attr.base.files.to_list()[0]
out = ctx.outputs.out
ts_target = ctx.attr.target
generated_tsconfig_content = _generate_tsconfig(
ctx.bin_dir.path,
ts_target,
base_tsconfig_src,
ctx.attr.use_runfiles,
)
ctx.actions.write(output = out, content = generated_tsconfig_content)
deps.append(out)
return [DefaultInfo(files = depset(deps))]
_tsec_config = rule(
implementation = _tsec_config_impl,
attrs = {
"target": attr.label(
mandatory = True,
aspects = [_capture_tsec_attrs_aspect],
doc = """The ts_library target for which the tsconfig is generated.""",
),
"base": attr.label(
allow_single_file = [".json"],
doc = """Base tsconfig to extend from.""",
),
"use_runfiles": attr.bool(mandatory = True),
"out": attr.output(mandatory = True),
},
doc = """Generate the tsconfig.json for a tsec_test. """,
)
def _all_transitive_deps_impl(ctx):
if TsecTargetInfo not in ctx.attr.target:
fail("`target` must be a ts_library target")
tslib_info = ctx.attr.target[TsecTargetInfo]
files = []
for s in tslib_info.srcs:
if hasattr(s, "files"):
files.extend(s.files.to_list())
files.extend(tslib_info.deps.to_list())
return [DefaultInfo(files = depset(files))]
_all_transitive_deps = rule(
implementation = _all_transitive_deps_impl,
attrs = {"target": attr.label(aspects = [_capture_tsec_attrs_aspect])},
doc = """Expand all transitive dependencies needed to run `_tsec_test`.""",
)
def tsec_test(name, target, tsconfig, use_runfiles_on_windows = False):
"""Run tsec over a ts_library or ng_module target to check its compatibility with Trusted Types.
This rule DOES NOT check transitive dependencies.
Args:
name: name of the tsec test
target: the ts_library or ng_module target to be checked
tsconfig: the ts_config target used for configuring tsec
use_runfiles_on_windows: whether to force using runfiles on Windows
"""
tsec_tsconfig_name = "%s_tsec_tsconfig" % name
generated_tsconfig = "%s_tsconfig.json" % name
use_runfiles = use_runfiles_on_windows or select({
"@platforms//os:windows": False,
"//conditions:default": True,
})
_tsec_config(
name = tsec_tsconfig_name,
testonly = True,
tags = ["tsec"],
target = target,
base = tsconfig,
use_runfiles = use_runfiles,
out = generated_tsconfig,
)
all_transitive_deps_name = "%s_all_transitive_deps" % name
_all_transitive_deps(
name = all_transitive_deps_name,
testonly = True,
tags = ["tsec"],
target = target,
)
nodejs_test(
name = name,
entry_point = Label("@npm//:node_modules/tsec/bin/tsec"),
data = [Label("@npm//tsec"), tsec_tsconfig_name, all_transitive_deps_name, generated_tsconfig],
tags = ["tsec"],
templated_args = ["-p", "$$(rlocation $(rootpath %s))" % generated_tsconfig],
)