Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions bazel/BUILD.bazel
Original file line number Diff line number Diff line change
Expand Up @@ -33,6 +33,7 @@ filegroup(
"//bazel/private:files",
"//bazel/remote-execution:files",
"//bazel/spec-bundling:files",
"//bazel/ts_project:files",
],
visibility = ["//:npm"],
)
9 changes: 9 additions & 0 deletions bazel/ts_project/BUILD.bazel
Original file line number Diff line number Diff line change
@@ -0,0 +1,9 @@
package(default_visibility = ["//visibility:public"])

# Make source files available for distribution
filegroup(
name = "files",
srcs = glob(["*"]) + [
"//bazel/ts_project/strict_deps:files",
],
)
3 changes: 3 additions & 0 deletions bazel/ts_project/index.bzl
Original file line number Diff line number Diff line change
@@ -0,0 +1,3 @@
load("//bazel/ts_project/strict_deps:index.bzl", _strict_deps_test = "strict_deps_test")

strict_deps_test = _strict_deps_test
26 changes: 26 additions & 0 deletions bazel/ts_project/strict_deps/BUILD.bazel
Original file line number Diff line number Diff line change
@@ -0,0 +1,26 @@
load("@aspect_rules_js//js:defs.bzl", "js_binary")
load("@aspect_rules_ts//ts:defs.bzl", "ts_project")

package(default_visibility = ["//visibility:public"])

# Make source files available for distribution
filegroup(
name = "files",
srcs = glob(["*"]),
)

ts_project(
name = "lib",
srcs = glob(["*.mts"]),
deps = [
"//bazel:node_modules/@types/node",
"//bazel:node_modules/typescript",
],
)

js_binary(
name = "bin",
data = [":lib"],
entry_point = ":index.mjs",
visibility = ["//visibility:public"],
)
12 changes: 12 additions & 0 deletions bazel/ts_project/strict_deps/diagnostic.mts
Original file line number Diff line number Diff line change
@@ -0,0 +1,12 @@
import ts from 'typescript';

export function createDiagnostic(message: string, node: ts.Node): ts.Diagnostic {
return {
category: ts.DiagnosticCategory.Error,
code: -1,
file: node.getSourceFile(),
start: node.getStart(),
length: node.getWidth(),
messageText: message,
};
}
135 changes: 135 additions & 0 deletions bazel/ts_project/strict_deps/index.bzl
Original file line number Diff line number Diff line change
@@ -0,0 +1,135 @@
load("@aspect_rules_js//js:providers.bzl", "JsInfo")

# A custom provider to pass along the npm package name for linked npm packages
NpmPackage = provider()

def _npm_package_aspect_impl(target, ctx):
if (ctx.rule.kind == "npm_link_package_store"):
package_name = ctx.rule.attr.package

# TODO: Determine how to include the package field information in locally built npm package targets
if package_name == "":
package_name = target[JsInfo].npm_package_store_infos.to_list()[0].package
return [NpmPackage(name = package_name)]
return []

# Aspect to include the npm package name for use in strict deps checking.
_npm_package_aspect = aspect(
implementation = _npm_package_aspect_impl,
required_providers = [],
)

def _strict_deps_impl(ctx):
sources = []

allowed_sources = []
allowed_module_names = []
test_files = []

# Whether or not the strict_deps check is expected to fail.
expect_failure = "true" if ctx.attr.will_fail else "false"

for dep in ctx.attr.deps:
if JsInfo in dep:
# Because each source maps to a corresponding type file, we can simply look at the type
# files for the sources, this also allows for situations in which we only provide types.
sources.append(dep[JsInfo].types)
if NpmPackage in dep:
allowed_module_names.append(dep[NpmPackage].name)

for source in depset(transitive = sources).to_list():
allowed_sources.append(source.short_path)

for file in ctx.files.srcs:
allowed_sources.append(file.short_path)
if file.is_source:
test_files.append(file.short_path)

manifest = ctx.actions.declare_file("%s_strict_deps_manifest.json" % ctx.attr.name)
ctx.actions.write(
output = manifest,
content = json.encode({
# Note: Ensure this matches `StrictDepsManifest` from `manifest.mts`
"testFiles": test_files,
"allowedModuleNames": allowed_module_names,
"allowedSources": allowed_sources,
}),
)

launcher = ctx.actions.declare_file("%s_launcher.sh" % ctx.attr.name)
ctx.actions.write(
output = launcher,
is_executable = True,
# Bash runfile library taken from:
# https://github.com/bazelbuild/bazel/blob/master/tools/bash/runfiles/runfiles.bash.

Check notice on line 64 in bazel/ts_project/strict_deps/index.bzl

View check run for this annotation

In Solidarity / Inclusive Language

Match Found

Please consider an alternative to `master`. Possibilities include: `primary`, `main`, `leader`, `active`, `writer`
Raw output
/master/gi
content = """
#!/usr/bin/env bash

# --- begin runfiles.bash initialization v3 ---
# Copy-pasted from the Bazel Bash runfiles library v3.
set -uo pipefail; set +e; f=bazel_tools/tools/bash/runfiles/runfiles.bash
# shellcheck disable=SC1090
source "${RUNFILES_DIR:-/dev/null}/$f" 2>/dev/null || \
source "$(grep -sm1 "^$f " "${RUNFILES_MANIFEST_FILE:-/dev/null}" | cut -f2- -d' ')" 2>/dev/null || \
source "$0.runfiles/$f" 2>/dev/null || \
source "$(grep -sm1 "^$f " "$0.runfiles_manifest" | cut -f2- -d' ')" 2>/dev/null || \
source "$(grep -sm1 "^$f " "$0.exe.runfiles_manifest" | cut -f2- -d' ')" 2>/dev/null || \
{ echo>&2 "ERROR: cannot find $f"; exit 1; }; f=; set -e
# --- end runfiles.bash initialization v3 ---

exec $(rlocation %s) $(rlocation %s) %s
""" % (
"%s/%s" % (ctx.workspace_name, ctx.files._bin[0].short_path),
"%s/%s" % (ctx.workspace_name, manifest.short_path),
expect_failure,
),
)

bin_runfiles = ctx.attr._bin[DefaultInfo].default_runfiles

return [
DefaultInfo(
executable = launcher,
runfiles = ctx.runfiles(
files = ctx.files._runfiles_lib + ctx.files.srcs + [manifest],
).merge(bin_runfiles),
),
]

_strict_deps_test = rule(
implementation = _strict_deps_impl,
test = True,
doc = "Rule to verify that specified TS files only import from explicitly listed deps.",
attrs = {
"deps": attr.label_list(
aspects = [_npm_package_aspect],
doc = "Direct dependencies that are allowed",
default = [],
),
"srcs": attr.label_list(
doc = "TS files to be checked",
allow_files = True,
mandatory = True,
),
"will_fail": attr.bool(
doc = "Whether the test is expected to fail",
default = False,
),
"_runfiles_lib": attr.label(
default = "@bazel_tools//tools/bash/runfiles",
),
"_bin": attr.label(
default = "@devinfra//bazel/ts_project/strict_deps:bin",
executable = True,
cfg = "exec",
),
},
)

def strict_deps_test(**kwargs):
kwargs["will_fail"] = False
_strict_deps_test(**kwargs)

def invalid_strict_deps_test(**kwargs):
kwargs["will_fail"] = True
_strict_deps_test(**kwargs)
77 changes: 77 additions & 0 deletions bazel/ts_project/strict_deps/index.mts
Original file line number Diff line number Diff line change
@@ -0,0 +1,77 @@
import fs from 'node:fs/promises';
import path from 'node:path';
import ts from 'typescript';
import {createDiagnostic} from './diagnostic.mjs';
import {StrictDepsManifest} from './manifest.mjs';
import {getImportsInSourceFile} from './visitor.mjs';

const [manifestExecPath, expectedFailureRaw] = process.argv.slice(2);
const expectedFailure = expectedFailureRaw === 'true';

const manifest: StrictDepsManifest = JSON.parse(await fs.readFile(manifestExecPath, 'utf8'));

/**
* Regex matcher to extract a npm package name, potentially with scope from a subpackage import path.
*/
const moduleSpeciferMatcher = /^(@[\w\d-_]+\/)?([\w\d-_]+)/;
const extensionRemoveRegex = /\.[mc]?(js|(d\.)?ts)$/;
const allowedModuleNames = new Set<string>(manifest.allowedModuleNames);
const allowedSources = new Set<string>(
manifest.allowedSources.map((s) => s.replace(extensionRemoveRegex, '')),
);

const diagnostics: ts.Diagnostic[] = [];

for (const fileExecPath of manifest.testFiles) {
const content = await fs.readFile(fileExecPath, 'utf8');
const sf = ts.createSourceFile(fileExecPath, content, ts.ScriptTarget.ESNext, true);
const imports = getImportsInSourceFile(sf);

for (const i of imports) {
const moduleSpecifier = i.moduleSpecifier.replace(extensionRemoveRegex, '');
// When the module specified is the file itself this is always a valid dep.
if (i.moduleSpecifier === '') {
continue;
}
if (moduleSpecifier.startsWith('.')) {
const targetFilePath = path.posix.join(
path.dirname(i.diagnosticNode.getSourceFile().fileName),
moduleSpecifier,
);

if (allowedSources.has(targetFilePath) || allowedSources.has(`${targetFilePath}/index`)) {
continue;
}
}

if (moduleSpecifier.startsWith('node:') && allowedModuleNames.has('@types/node')) {
continue;
}

if (
allowedModuleNames.has(moduleSpecifier.match(moduleSpeciferMatcher)?.[0] || moduleSpecifier)
) {
continue;
}

diagnostics.push(
createDiagnostic(`No explicit Bazel dependency for this module.`, i.diagnosticNode),
);
}
}

if (diagnostics.length > 0) {
const formattedDiagnostics = ts.formatDiagnosticsWithColorAndContext(diagnostics, {
getCanonicalFileName: (f) => f,
getCurrentDirectory: () => '',
getNewLine: () => '\n',
});
console.error(formattedDiagnostics);
process.exitCode = 1;
}

if (expectedFailure && process.exitCode !== 0) {
console.log('Strict deps testing was marked as expected to fail, marking test as passing.');
// Force the exit code back to 0 as the process was expected to fail.
process.exitCode = 0;
}
5 changes: 5 additions & 0 deletions bazel/ts_project/strict_deps/manifest.mts
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
export interface StrictDepsManifest {
allowedModuleNames: string[];
allowedSources: string[];
testFiles: string[];
}
56 changes: 56 additions & 0 deletions bazel/ts_project/strict_deps/test/BUILD.bazel
Original file line number Diff line number Diff line change
@@ -0,0 +1,56 @@
load("@aspect_rules_ts//ts:defs.bzl", "ts_project")
load("//bazel/ts_project/strict_deps:index.bzl", "invalid_strict_deps_test", "strict_deps_test")

ts_project(
name = "sibling_import_from_depth",
srcs = ["sibling_import_from_depth.ts"],
deps = [
"//bazel/ts_project/strict_deps/test/depth",
],
)

strict_deps_test(
name = "import_node_module",
srcs = ["import_node_module.ts"],
deps = [
"//bazel:node_modules/@types/node",
],
)

invalid_strict_deps_test(
name = "invalid_import_node_module",
srcs = ["import_node_module.ts"],
)

strict_deps_test(
name = "import_npm_module",
srcs = ["import_npm_module.ts"],
deps = ["//bazel:node_modules/@microsoft/api-extractor"],
)

invalid_strict_deps_test(
name = "invalid_import_npm_module_transitively",
srcs = ["import_npm_module.ts"],
deps = [
"//bazel/ts_project/strict_deps/test/import_npm_module",
],
)

invalid_strict_deps_test(
name = "invalid_import_npm_module",
srcs = ["import_npm_module.ts"],
)

strict_deps_test(
name = "import_from_depth",
srcs = ["import_from_depth.ts"],
deps = ["//bazel/ts_project/strict_deps/test/depth"],
)

invalid_strict_deps_test(
name = "invalid_import_from_depth",
srcs = ["import_from_depth.ts"],
deps = [
":sibling_import_from_depth",
],
)
8 changes: 8 additions & 0 deletions bazel/ts_project/strict_deps/test/depth/BUILD.bazel
Original file line number Diff line number Diff line change
@@ -0,0 +1,8 @@
load("@aspect_rules_ts//ts:defs.bzl", "ts_project")

ts_project(
name = "depth",
srcs = ["file.ts"],
declaration = True,
visibility = ["//visibility:public"],
)
1 change: 1 addition & 0 deletions bazel/ts_project/strict_deps/test/depth/file.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
export const depthValue = 42;
7 changes: 7 additions & 0 deletions bazel/ts_project/strict_deps/test/depth/tsconfig.json
Original file line number Diff line number Diff line change
@@ -0,0 +1,7 @@
{
"compilerOptions": {
"strict": true,
"lib": ["esnext", "DOM"],
"declaration": true
}
}
3 changes: 3 additions & 0 deletions bazel/ts_project/strict_deps/test/import_from_depth.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,3 @@
import {depthValue} from './depth/file';

console.log(`The value from deeper down is ${depthValue}`);
4 changes: 4 additions & 0 deletions bazel/ts_project/strict_deps/test/import_node_module.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,4 @@
import {basename, join} from 'node:path';
import {cwd} from 'node:process';

join(basename(cwd()), 'test.txt');
3 changes: 3 additions & 0 deletions bazel/ts_project/strict_deps/test/import_npm_module.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,3 @@
import {Extractor} from '@microsoft/api-extractor';

export const AnExtractor = Extractor;
10 changes: 10 additions & 0 deletions bazel/ts_project/strict_deps/test/import_npm_module/BUILD.bazel
Original file line number Diff line number Diff line change
@@ -0,0 +1,10 @@
load("@aspect_rules_ts//ts:defs.bzl", "ts_project")

ts_project(
name = "import_npm_module",
srcs = ["index.ts"],
visibility = ["//visibility:public"],
deps = [
"//bazel:node_modules/@microsoft/api-extractor",
],
)
3 changes: 3 additions & 0 deletions bazel/ts_project/strict_deps/test/import_npm_module/index.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,3 @@
import {Extractor} from '@microsoft/api-extractor';

console.log(Extractor);
Original file line number Diff line number Diff line change
@@ -0,0 +1,6 @@
{
"compilerOptions": {
"strict": true,
"lib": ["esnext", "DOM"]
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,3 @@
import {depthValue} from './depth/file';

console.log(depthValue);
1 change: 1 addition & 0 deletions bazel/ts_project/strict_deps/test/transitive_from_depth.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
export {depthValue} from './depth/file';
Loading
Loading