Changelog¶
All notable changes to this project will be documented in this file. The format is based on Keep a Changelog.
Nothing here has been released. There is no git tag, no GitHub release and no
Bazel Central Registry entry; 0.2.0 is the version string in MODULE.bazel.
Consumers pin a commit
(quickstart),
so every entry below is a change against the commit you pinned last.
Pre-1.0, breaking changes land without a deprecation path. The breaking sections list every break with the edit it requires.
Changes made since the newest section below are not here yet: they sit in
changelog.d/, one file per pull request, and a release folds them in
(bazel run //tools/changelog -- --version X.Y.Z --write).
[0.2.0]¶
Breaking — ts_compile¶
- An import no direct dep provides now fails the build. A
TsStrictDepsaction reads the target's own sources and reports every specifier that resolves only because a dep's own deps carry it:
//src/app:app imports a module no direct dep provides:
src/app/main.ts:1 imports "./hidden"
add "//src/app:hidden" to deps
The transitive closure is still an action input, so a declared dep's own
.d.ts keeps resolving its own imports; arriving transitively no longer
satisfies an import. Relative paths, bare specifiers, npm packages and
module_name targets are all checked. Node builtins,
node: specifiers and path_aliases prefixes are exempt. An import nothing
in the closure provides is left to TypeScript's TS2307, since there is no
label to suggest. There is no flag and no opt-out. Run bazel run //:gazelle
to fix a failure, or add the printed labels by hand.
/// <reference types="x" /> is not checked, and Gazelle generates no dep for
it either.
- isolated_declarations = True|False is replaced by
declarations = "tsgo"|"oxc", default "tsgo". "tsgo" emits .d.ts from
the full type program: no export needs an explicit type annotation, and a
type error fails a plain bazel build. "oxc" is the old behaviour, a
syntactic per-file emit requiring isolated declarations, with type-checking
demoted to a _validation action. Gazelle's
# gazelle:ts_isolated_declarations <bool> becomes
# gazelle:ts_declarations <tsgo|oxc>.
- ts_compile_legacy is deleted. It left oxc as the emitter with isolated
declarations off, which silently widened declarations
(export declare const PATTERNS: {} for an object of five RegExps). Move
those targets to the declarations default.
- Under declarations = "tsgo", enable_check = False means no type program
and therefore no .d.ts output at all. Use it for terminal targets (app
entries, dev servers, bundle inputs), not for anything another ts_compile
depends on.
- New attributes: tsconfig, lib, types, jsx_import_source,
compiler_options, module_name. See
ts_compile
for the precedence rules.
- tsconfig layers, it does not replace. strict, module: "Preserve",
skipLibCheck and esModuleInterop are applied in both modes. Without a
tsconfig they go into the generated config as before. With one they go into a
<target>.tsconfig_baseline.json the generated config extends first, so
every key your file — or its own extends chain — mentions wins, and the
baseline reaches only the keys it says nothing about. A target extending a
non-strict tsconfig still checks under that file's options; a target whose
tsconfig omits one of them now keeps the ruleset's value instead of falling
back to tsc's default.
Edit required only if you were relying on a tsconfig to un-set one of
them: say so in the file ("strict": false) and it wins, or override it
in compiler_options, which sits above both.
- moduleResolution: "Bundler" is stated only where the ruleset also owns
module. TypeScript couples the two, and layers merge per key, so a
baseline resolver left standing under your "module": "NodeNext" was TS5109
at line 2 of a generated file — 25 targets in one repo, none of which had
anything wrong with them. It is now in the generated config without a
tsconfig (and only while compiler_options names neither key), and in
neither file with one. Nothing that resolved before resolves differently:
tsgo derives Bundler from every module but Node16/NodeNext, which
derive their own. A tsconfig naming module: "Node16"/"NodeNext", or
"CommonJS", now compiles instead of failing on the pair.
- New hard analysis error: compiler_options setting moduleResolution to
Node16 or NodeNext with no module beside it, and no tsconfig that
could carry one, fails analysis naming the value to set. That pair is TS5110
whatever the ruleset defaults module to, so it is reported with the label
rather than against a generated file.
- target and jsx_mode are still injected in every mode and supersede a
target/jsx in the tsconfig file, since oxc transforms with them.
- New hard analysis error: compiler_options naming any of the 16
Bazel-owned keys (paths, baseUrl, rootDir, rootDirs, outDir,
declarationDir, declaration, declarationMap, sourceMap,
emitDeclarationOnly, noEmit, noEmitOnError, isolatedDeclarations,
composite, incremental, tsBuildInfoFile) fails analysis and names the
attribute to use.
- New hard analysis error: a path_aliases value pointing into
bazel-out/ or bazel-bin/ fails analysis; that path embeds the build
configuration, so it breaks under -c opt or a different exec platform. To
import another target by bare specifier, set module_name on the target that
produces the declarations and depend on it.
- A dep's global .d.ts is now in your program. A .d.ts in a target's
srcs with no top-level import or export declares globals, and those names
are now in scope in every target that depends on it, however far down the
graph the declaration sits — the scope a single tsc run over the same
sources gives them. A TsGlobalDts action classifies each .d.ts and writes
the reference file a consumer's tsconfig files lists. Names that used to be
a TS2304 now resolve, and an interface a dep declares merges with one of
the same name you declare yourself, so a value that satisfied your Env can
fail with TS2741 once a dep's Env adds a member:
src/app/main.ts(1,14): error TS2741: Property 'B' is missing in type
'{ A: string; }' but required in type 'Env'.
Drop the edge if the two Envs were never the same type, or rename one of
them. Declaration-internal collisions stay silent: the generated tsconfig
keeps skipLibCheck on.
Breaking — new public API¶
ts_configrule (//ts:defs.bzl). Declares a hand-writtentsconfig.jsonplus the files itextends, which Starlark cannot read the file to find. A tsconfig that extends nothing goes tots_compiledirectly.TsModuleInfoprovider (//ts:defs.bzl). Carries the bare specifier a target is importable as (module_name) and the directories its declarations land in, so a consumer's tsconfigpathsno longer names output paths by hand.
Breaking — ts_test¶
- A vitest config is now always generated and always passed with
--config, so vitest never auto-discovers a stray config from the runfiles tree. configmerges into the generated config and accepts an inline dict as well as a label. Objects merge key by key, arrays concatenate base-first (matching vite'smergeConfig), later scalars win.- New attributes:
setup_files,global_setup,data,globals,reporters,coverage_thresholds,tsconfig.tsconfigis forwarded to thets_compilethe macro generates; the rule already had the attribute, only the macro did not pass it along. environmentis emitted into the config, no longer passed on the command line, and is no longer validated against a fixed set of names.- A
*.module.cssimport resolves to the real export map. The Bazel layer's plugin reads<source>.exports.json, the mapcss_modulewrote and generated the.d.tsfrom, sostyles.buttonis the scoped string a bundler emits (_button_<8 hex>) and a test can assert on a renderedclassattribute. It was aProxyreturning the property name, soexpect(styles.button).toBe("button")asserted about a string no browser ever sees. Expect snapshot and assertion updates in tests that touched CSS-module class names. A*.module.csswith nocss_moduletarget behind it keeps the proxy. - The auto-generated
node_modulestree moved to a per-target directory, so a package may now hold more than onets_test. - The config that actually ran is readable:
bazel build //path:my_test --output_groups=vitest_config. - Snapshots work, and a stale one now fails. Vitest resolves a
.snapbeside the test file it ran, which under Bazel is the compiled.jsinbazel-out, never the source tree, so abazel testpassed against a stale checked-in snapshot: vitest treated the absent.snapas new, wrote it into the sandbox and reported success, sotoMatchSnapshot()underts_testasserted nothing. Three changes fix that:- A fourth, root-only config layer sets
test.resolveSnapshotPathto<package>/__snapshots__/<source name>.snap, where a plainvitestrun already keeps it, so an adopting repository renames nothing. - A new
snapshotsattr (glob(["__snapshots__/*.snap"])) puts those files in runfiles, which is what makes a stale or absent one a failure. CI=trueis set on every non-update run, vitest's read-only snapshot mode. It goes throughdict.setdefault, soenv = {"CI": "false"}still opts out. Its only other effect isallowOnly→false.
- A fourth, root-only config layer sets
- Every
ts_testnow also declares<name>.update_snapshots, an executable that runs the same compiled tests with--updateand writes underBUILD_WORKSPACE_DIRECTORY. It reuses the test's ownts_compile, which removes an output collision: twots_test-like targets over the same srcs in one package are twots_compiles declaring the same.jsand.d.ts.update_snapshots = Truesurvives as a standalone updater, now documented as unable to share a package with a test over the same srcs.--sandbox_writable_pathis no longer part of any snapshot workflow. - A DOM
environmentruns under sandboxing.resolve.preserveSymlinks: trueis in the Bazel layer. A browser-like environment realpaths every module id, which for a runfiles symlink resolves to the execroot path outside the test sandbox (Failed to load url … Does the file exist?). - A
configthat default-exports an array emitstest.projects, nottest.workspace. vitest 4 throws ontest.workspace, so that shape was a startup crash on 4. The array form now needs vitest 3.2 or later, the release that renamed the key. Object, function, promise and inline-dict shapes run on 3 and 4 alike.
Breaking — Vite and vitest versions¶
- The tested lane is Vite 8 / vitest 4. Neither tool is a ruleset
dependency, so nothing forces your pin; what changed is which versions the
generated configs are proven against.
@npm(tests/npm/pnpm-lock.yaml) resolves vite 8.2.2 and vitest 4.1.11, one resolution each. Vite 6 / vitest 3 was the previously-tested lane; the two spellings that move with the major are listed in COMPATIBILITY.md. @npm_viteandvite/pnpm-lock.yamlare deleted. Nothing outside the repository's own tests named that hub.@npm_features(tests/npm/pnpm-lock-features.yaml) is unaffected: it is the pnpm patch/alias/peer-variant fixture, resolves neither Vite nor vitest, and is declareddev_dependency, so it never reaches a consumer's resolution.
Breaking — npm¶
- One external repository per npm package is the only implementation. The
npm_translate_lockrepository rule andnpm.translate_lock(lazy = ...)are deleted, and with them the 1,794-linets/private/npm_translate_lock.bzl;npm/private/npm_translate_lock.bzlis now only the pnpm lockfile reader. The label surface does not move:@npm//:zod,@npm//:types_reactand@npm//:vitest_binall still resolve, through an alias hub that downloads nothing. npm.translate_lockgainspatches, a label list.patchedDependencieswas previously ignored outright and the unpatched upstream tarball installed with no warning. Files are matched to lockfile entries by pnpm's ownpnpm patch-commitname,<name with / replaced by __>@<version>.patch, and every pairing is verified at extension time. Four failures, each naming the label: a label that resolves to no readable file; a file whose sha256 disagrees with the digestpatchedDependenciesrecords; apatchedDependenciesentry with no matching label; and a passed file no entry claims. A patch filename starting with@cannot be exported byexports_files(glob(["*.patch"])), so the unreadable-label message says to list those files literally.- npm alias specifiers (
h3-v2: h3@2.0.1) get their own labels. They previously collapsed onto the real package's label, so the name the importing code uses did not exist as a target. npm_bin'soptional_dep_infoattribute is removed; only the deleted single-repository path produced it. Platform-matchedoptionalDependencies(native sidecars such asoxlint→@oxlint/linux-x64-gnu) are named throughoptional_dep_packages.- pnpm
catalogs,overrides(including theparent>childform) andpackageExtensionsneed no support code and got none: pnpm resolves all three at every use site, so the lockfile already carries concrete versions and injected peers. Tests pin that. - New hard error: a
packages:entry with no usable integrity. Such an entry was previously downloaded with no verification and no warning. The check runs when the extension is evaluated, over the whole lockfile, so an entry nothing depends on is checked too, and it names every offending package with theresolution:keys it carries.sha512-,sha384-andsha256-are accepted; a pre-SRIsha1-is not. There is no opt-out. The rejected shapes are dependencies with no published tarball: a git dependency, afile:dependency on a local directory, and a remote tarball pnpm could not hash. The first two already failed; the third is a real capability removal. Depend on such a package as a workspace member (link:) or vendor its files.npm_import'sintegrityattribute is now mandatory. - A
workspace:*member is importable by its package name with nomodule_name. The hub target for a pnpmlink:dependency is a generated rule that declares the name, not analias: Bazel resolves an alias before any rule implementation runs, so every member used to restate the npm name asmodule_name. Members that do set it keep working. The checked-in IDE tsconfig still reads the attribute, so set it there too if the editor has to resolve the bare import.
Breaking — ts_add_package¶
ts_add_packagenow requirespnpm_lock, the label of the lockfile of the hub the target edits — the same label that hub'snpm.translate_lock()reads. pnpm is pointed at that label's directory with--dir.bazel run //:add_package -- <pkg>previously resolved against the workspace root, writing a straypackage.jsonandpnpm-lock.yamlthere. A target declared without the attribute fails at load time, naming itself and the line to add. The lockfile is also an input of the target, so a missing or invisible hub is a build error. Gazelle writespnpm_lock = "//:pnpm-lock.yaml"on the target it generates at the workspace root; a repository with several hubs wants onets_add_packageper hub, named after it.--lockfile-diris now refused. Only--dirwas constrained, by appending the hub's own, so--lockfile-dirstill reached the stray rootpnpm-lock.yaml.
Breaking — node_modules trees¶
- A tree now places every resolved version of a package. The old tree keyed
every destination by package name alone, so when one npm name resolved to two
versions in a closure both wrote to
node_modules/<name>, the last copy won, and every dependent silently got that version. Now each name's primary version keeps the flat top-level directory (primary = what the tree's owndepsdeclare, else the highest version, the rule@npm//:<name>already follows), every other version gets its bytes once under.pnpm/<name>@<version>/node_modules/<name>, and each dependent that resolved to one of those gets a relative symlink at<dependent>/node_modules/<name>. Nothing that resolved before moves. - New hard error: declaring two versions of one name directly on a single
node_modulestarget.node_modules/<name>is one directory. The failure names the target, both versions, and the two ways out: depend on one and let the other arrive transitively, or split into two targets. NpmPackageInfogainsdirect_deps, the per-dependent resolution the links are built from.ts_test's auto-generated tree gets the same layout from the same builder.- A tree is keyed by resolution, not by
name@version. pnpm resolves a package once per distinct set of peers, soansi-styles@6.2.3(ansi-regex@5.0.1)andansi-styles@6.2.3(ansi-regex@6.2.2)used to collapse onto one directory.NpmPackageInfogainspeer_id, carrying pnpm's peer suffix, and the store path becomes.pnpm/<name>@<version>_<peer set>/node_modules/<name>. The peer component is a readable prefix plus a digest of the whole set, since a nested peer set can run to hundreds of characters. A tree with no peer-differing edge is unchanged byte for byte; a tree with one gains the files of the resolution that was previously dropped. - New hard error: declaring two peer resolutions of one version on a single
node_modulestarget, the version error one level narrower. The message distinguishes it from the version case. ts_testno longer keeps its own npm-package collector. The duplicate keyed byname@version, dropping a peer variant before the layout planner saw it. Both paths now call onecollect_npm_packages.
Breaking — toolchains and repositories¶
- The
ts.oxc_toolchain(),ts.tsgo_toolchain()andts.node()extension tags are removed.ts.tsgo(version = ...)is the only remaining tag. rules_typescript_dependencies()is gone, and with itts/repositories.bzl. Thetsextension declares its own repositories.- The
@oxcrepository is gone; oxc resolves directly to//oxc_cli:oxc-bazel, built from source byrules_rustfor the exec platform.@tsgois replaced by per-platform@tsgo_<platform>repositories. PLATFORM_CONSTRAINTSis replaced by//platforms:platforms.bzl%PLATFORMS. The new//platformspackage is the single platform vocabulary and also declares//platforms:<key>(for--platforms) and//platforms:is_<key>(forselect()).- oxc and tsgo are
exec_compatible_with-constrained, no longertarget_compatible_with-constrained, so they follow the execution platform. A build setting--platformsunusually previously lost its compilers. - New toolchain type
//ts/toolchain:js_tool_typeseparates node-as-a-build-tool (exec config) from node-as-a-runtime (js_runtime_type, whoseruntime_binaryis nowcfg = "target"). Registering@rules_typescript//ts/toolchain:allpicks up both. - The
node_modulestree action resolves node throughjs_tool_typelike every other build action; it was the one left onjs_runtime_type. It is identical where exec and target agree, and builds for the exec platform under cross-compilation.node_modulesand thets_test-internal tree rule declarejs_tool_typeaccordingly, so a setup registering only a JS runtime toolchain fails at analysis.
Breaking — the IDE tsconfig¶
- A target's
module_namegets its owncompilerOptions.pathsentry, so a first-party package imported by bare specifier resolves in the editor. Themodule_namekeys are emitted last, so a first-party name beats a same-named npm package. ts_refresh_tsconfiggainsextra_exclude, globs appended to the generatedexclude. TypeScript trees that are not in this module's build graph — a nested Bazel module, an example workspace in.bazelignore— are otherwise walked bytscand checked under the wrongcompilerOptions, sinceincludeis**/*.- The
ts_compiletargetsts_testgenerates fromsrcs,setup_filesandglobal_setupfollow the test'svisibility, defaulting to//visibility:public. The old hardcoded//visibility:privatewas unnameable, sots_refresh_tsconfig(deps = ...)could not reach the npm packages only a test declares. To lock them down, setvisibilityon thets_test.
Breaking — bundling, assets and packaging¶
ts_bundlerequiresbundler. The bundler-less mode concatenated every transitive.jsfile behind a// Placeholder bundleheader, an artifact no runtime accepts. Point the attr at avite_bundler, or any target providingBundlerInfo.ts_binaryis unchanged: without a bundler it runs the entry point.jsdirectly.css_moduleandjson_libraryneed thejs_tooltoolchain, andts_npm_publishandnext_buildnow do too;register_toolchains("@rules_typescript//ts/toolchain:all")covers all of them.css_moduleandts_npm_publishused to shell out to the hostawk.css_modulecompiles the stylesheet with postcss-modules and no longer extracts class names from it. Each src gains a second output,<source>.exports.json, holding the export map postcss-modules produced, and the.d.tsis generated from that map's keys. Consequences:- The declared key set grows.
@keyframesnames,#idselectors and@valuenames are exports and are now declared, sostyles["panel-fade"]type-checks. Code that walkedkeyof typeof stylesexhaustively sees new members. composes: x from "./other.module.css"works, which means it also fails on bad input: postcss-modules errors on a name it cannot find, and the other file has to be indeps.- Constructs postcss-modules rejects now fail the build.
:local(...)inside a:global(...)group is the one this repo's own fixture used. - The values are scoped names Bazel decided:
_<name>_<sha256 of the stylesheet, 8 hex>, fromts/private/css/scoped_name.ts, with no path in the hash. Class names in a bundle change shape:_panel_t1r4u_1becomes_panel_<8 hex>, so a snapshot or a stylesheet override written against the old form moves. - The knobs that change the answer are attributes of the rule
(
locals_convention,scope_behaviour,hash_prefix,export_globals), not of avite_config, because the rule is what wrote the.d.ts. Setting one of them, orgenerateScopedName, orcss.modules = false, on the bundler side — where it was silently authoritative — is now a hard build failure naming the attribute to use. - The guard tests a mark on the function, not its identity: a framework plugin
driving its own
createBuilder, as TanStack Start does, makes Vite run the plugin factory once per environment, so the resolved config's function is a different closure from the checking instance's. ts_bundle,ts_dev_serverandts_testinstall a Bazel-owned Vite plugin that hands Vite the map, so the bundler's CSS-modules pass reproduces the names.- A new non-dev npm hub,
@npm_css(ts/private/css/pnpm-lock.yaml, 19 pure-JS packages), lands in every consumer'sMODULE.bazel.lock. It is the compiler, so it cannot be dev-only:css_moduleis consumer API.bazel run //:add_package_css -- <pkg>edits it. - A name that is not a bare TypeScript identifier is emitted quoted
(
readonly "button-primary": string) and is no longer a syntax error. ts_npm_publishstages into<name>_pkg/package/, not<name>_pkg/, and itspackage.jsonis rewritten throughJSON.parse/JSON.stringify, so a template that is not one-field-per-line survives. Output is pretty-printed two-space JSON.ts_npm_packagerequirespackage_dirandpackage_files; the impl always dereferenced both.ts_bundle's generated Vite config no longer names anything Vite removed or made optional.split_chunksemitsbuild.rollupOptions.output.manualChunksin place ofsplitVendorChunkPlugin, which Vite 7 removed.minify = Trueemitstrue, the running Vite's own default minifier (esbuild on 6, oxc on 8), and no longer names"esbuild": Vite 8 accepts the name but then installsbuildEsbuildPlugin(), and esbuild is an optional peer, absent from a tree built fromdeps = ["@npm//:vite"].minify = Falsenow also emitsoutput.minify: false, becausebuild.minify: falsealone still runs the dead-code pass, which re-emits each chunk from its AST and silently discards whatever a plugin'srenderChunkreturned, string form and{code}form alike.
Breaking — tests¶
tests/bootstrapis deleted, all 8 targets. Every scenario it covered exists undertests/integration/, with ten more besides; the bootstrap copies inheritedPATH/HOME/USERand foundbazelon the caller's PATH. Usebazel test //tests/integration/.... TheRULES_TYPESCRIPT_ROOTenvironment variable those runners needed is no longer read anywhere.- Integration targets carry neither
manualnorexclusive, sobazel test //...runs them and runs them concurrently. They carrynested-bazel,cpu:2,no-sandboxandexternal;bazel test --config=fast //...filters-nested-bazeland skips them. Count what your tree has withbazel query 'attr(tags, "nested-bazel", tests(//...))' | wc -l. - The nested-Bazel workspaces share one repository cache. Each has its own
output base, so each used to fetch the whole BCR registry separately and the
concurrent lookups failed on a different subset each run.
prepare()intests/integration/harness/harness.goappendscommon --repository_cache=<shared>to each staged workspace's.bazelrc.
Breaking — the dev server¶
bazel run //app:devserves first-party source. Vite transforms the checked-in.tsin memory, with no Bazel analysis-and-action cycle between a keystroke and the browser.bazel-binstays authoritative only for what Vite cannot produce itself: the npm tree,ts_codegenoutput, assets and passthrough.d.ts. Generated code is recognised by having no checked-in source, not by a path list. It previously served the compiled.jsfrombazel-bin, which put a Bazel rebuild in the inner loop.- The dev server no longer type-checks. That is native Vite parity: a type
error surfaces in the editor and in
bazel build, and no longer blocks the browser update.ts_bundleis unchanged: a production bundle still consumes Bazel's compiled output. - The restart decision moved into the Vite process.
ibazel runSIGTERMs the launcher after every rebuild and the launcher deliberately survives it, so one Vite process lives across rebuilds. AConfigWatchercompares content digests of the inputs the generated config was built from (the config itself, the npm tree, the toolchain node binary) and restarts only when one changes. Neither ats_codegenrebuild nor a source edit restarts the server. - A bare npm specifier from dev-served source resolves. The generated
config set
resolve.modules, a webpack option Vite ignores, so a served module importing"react"answered 500. Abazel:npm-resolveplugin (enforce: 'pre') finds the package's ownpackage.jsoninside thenode_modulestree and hands the id back to Vite's own resolver anchored there, so exports maps, conditions and subpaths stay Vite's to interpret. A package the tree does not carry falls through to Vite's ordinary unresolved-import error. react_refresh = Truefails loudly and no longer serves without Fast Refresh. The rule imported@vitejs/plugin-react/dist/index.mjsby fixed path, a file the installed major does not ship, and swallowed the failure in aconsole.warn. The entry point now comes from that package's ownexportsmap, and a load failure throws naming thets_dev_serverlabel and the dep to add. A dev server whosenode_modulestarget is missing@npm//:vitejs_plugin-reactnow refuses to start.vite_configis loaded from a copy inbazel-bin, not from your source tree. Node resolves a runfiles symlink before that file's own imports, and this ruleset has no source-treenode_modules. The consequence is a boundary: a bare npm specifier in the config resolves through the tree thenode_modulesattr built, provided that target is in the same Bazel package as the dev server; a relative import does not, because only the one file is copied, and the server exits with[rules_typescript] Failed to load vite_confignaming the file.ts_bundle'svite_configis unchanged and still imported from the source tree.
Breaking — shell replaced by Go¶
scripts/is deleted.ci.shduplicated.github/workflows/ci.yml;quickstart.shandrelease.sharebazel run //tools/quickstartand//tools/release;verify_determinism.shis explicit CI steps, since two builds cannot be one action.ts_binary,ts_test,ts_dev_serverandnpm_binrun through one checked-in Go launcher that reads a per-target JSON config. The generated file is<target>_launcher—<target>_test_launcherfromts_test,<target>_bin_launcherfromnpm_bin— not<target>_runner.sh; anything naming the old file breaks.--dump-configprints the resolved config.tools/refresh_tsconfig.shis deleted.ts_refresh_tsconfigtakesdepsand an aspect walks them; adeps = []call produces a tsconfig with nopathsat all. Itsexecroot_symlinkattr is removed.npm_import'sexpected_prefixattr is removed; the prefix is detected.
Added¶
asset_librarytakesdeclaration_type, so a project can say what an extension's import resolves to. The rule hardcodedstringfor every extension, and a project running an svgr-style transform had no way to say otherwise: TypeScript prefers the concretelogo.svg.d.tsthe rule writes beside the asset over anydeclare module "*.svg"the project writes, so the ambient never applied.declaration_typemaps extension → type expression and replaces what that generated declaration says:
asset_library(
name = "logo_svg",
srcs = ["logo.svg"],
declaration_type = {
".svg": 'import("react").FC<import("react").SVGProps<SVGSVGElement>>',
},
)
An extension left out keeps string. A key that is not an asset_library
extension is an analysis failure. The type expression is written into the
declaration verbatim, so a name it uses has to resolve from there — an
import("pkg") type query, with pkg in the consuming ts_compile target's
deps. The expression itself is unchecked and a name that does not resolve is
silent, since the generated file is a .d.ts and this ruleset compiles with
skipLibCheck: the import widens to any. Build the consumer with
compiler_options = {"skipLibCheck": False} to surface it, and the error
names the generated file, whose first line names the target and attribute
that wrote the type. An asset reached through a path_aliases alias is not
covered: the alias resolves into the source tree, where no generated
declaration sits beside the asset, so a wildcard ambient decides that import.
- ts_binary takes a plain JavaScript file as its entry_point. The attr
is polymorphic: a target providing JsInfo behaves exactly as before, and a
single .js, .mjs or .cjs source now runs as-is. A Node generator for
ts_codegen no longer needs an sh_binary wrapper to become an executable:
ts_binary(
name = "paraglide_compile",
entry_point = "scripts/paraglide-compile.mjs",
data = ["scripts/paraglide-compile-dts.mjs"],
node_modules = "//web:node_modules",
)
ts_codegen(
name = "compiled",
srcs = ["project.inlang/settings.json"] + glob(["messages/**"]),
out_dir = "compiled",
generator = ":paraglide_compile",
node_modules = "//web:node_modules",
)
A new data attr carries the modules the entry imports into runfiles; it is
extra runfiles for either entry_point shape. A .ts entry point is refused
with a message naming ts_compile rather than compiled implicitly, which
would be a second compile surface with no deps, no tsconfig and no choice
of declaration emitter. entry_point no longer declares
providers = [JsInfo], so a target providing neither now fails in the rule
with the message the rule already carried instead of in attribute checking.
//tools/codegen:tanstack_routes, the route-tree generator this ruleset
ships, is the first user: its 102-line shell wrapper — which parsed --out
and --srcs by hand and wrote a .mjs file out of a heredoc at build time —
is now a 93-line .mjs and a three-line ts_binary.
- A ts_codegen directory can be a ts_compile dep. out_dir is the only
output shape a generator whose file names come from its input can have, and
until now nothing downstream could read one: the rule returned DefaultInfo
alone, so naming it in deps failed analysis with does not have mandatory
providers. An out_dir target now carries JsInfo, TsDeclarationInfo and
TsModuleInfo — the same fields a ts_compile's own outputs travel in, not a
parallel set for directories — and the new module_name attribute names the
specifier importers write:
ts_codegen(
name = "messages",
out_dir = "compiled",
module_name = "#app/messages",
...
)
ts_compile(name = "app", srcs = ["main.ts"], deps = [":messages"])
The tree goes in deps, never srcs: srcs declares one output per input
file at analysis time and a directory has no file list to declare from. So the
generator has to emit compiled output — .js beside .d.ts — because nothing
downstream will compile it. module_name without out_dir is an
analysis-time error.
The undeclared-import check reads a directory as a path prefix rather than as
a file list, and reports the label when a relative import lands inside a tree
that arrived only through another dep.
- ts_worker_deploy uploads a Cloudflare Worker. The ruleset could only ever
dry-run one, so authentication, whether Cloudflare accepts the bundle, routes
and cron triggers had never been exercised. It is a bazel run target, the
shape Bazel has for a side effect and the one rules_oci gives oci_push:
bazel build //... writes the launcher, bazel test //... cannot select a
non-test rule, and only bazel run uploads. Same attributes as
ts_worker_dry_run, env_name included, and the command line it builds is
the dry run's minus --dry-run.
The launcher's default is still the dry run. A launcher config that does not
say "command": "deploy" — older, hand-written, or misspelt — dry-runs, and an
unrecognised value fails the config outright. In the other direction, a dry-run
target now refuses --no-dry-run and --dry-run=false, which yargs' boolean
negation would otherwise have honoured, and it has the CLOUDFLARE_* and
legacy CF_* variables removed from the environment wrangler is given — so
"no credentials and no network" is now enforced rather than merely intended.
ts_worker_deploy is the only one of the three that keeps the ambient HOME,
CI and credentials.
- Gazelle names a package's own tsconfig.json on the targets it
generates. Nothing it wrote ever did, so every generated target compiled
against the ruleset's baseline alone while the repo's own lib, types, jsx
and strictness sat unread beside it — a worker whose tsconfig declares
"lib": ["es2022"] failing on the globals that lib grants. Every generated
ts_compile and ts_test now names the nearest hand-written tsconfig.json
walking up, the way tsserver resolves one, and a ts_config target beside
that file makes it a label a subpackage can reach. deps on that target — the
extends chain Starlark cannot read — is yours and survives every run without
a # keep.
The ts_config is written even into a directory that holds nothing else, which
is what the pnpm workspace-member layout is: package.json and tsconfig.json
beside each other with the sources under src/. Without it the label names a
target in a package Bazel never loads, and that fails analysis for the whole
workspace.
Three cases get no attribute rather than a label into a directory Gazelle
writes no BUILD file into, each logged with the fix: a directory under a
# gazelle:ts_ignore or inside a tree Next.js or SvelteKit stages by glob;
under index-only or tsconfig boundaries, one that is not already a package,
where a BUILD file written just to hold the ts_config would stop the roll-up
walk and drop every source beneath it; and one whose own target is already
named tsconfig. The tsconfig.json files ts_refresh_tsconfig writes are
skipped — they are built out of the very targets that would name them.
Naming a tsconfig adds its options and removes none: see the ts_compile
breaking note above for the baseline that now applies in both modes, which is
what keeps a plain gazelle run over a working build from changing what any
existing target already compiled with.
- # gazelle:ts_ambient_types declares ambient @types for a whole tree.
Every dep Gazelle writes comes from a specifier in a source file, so an
ambient declaration — process, Buffer, __dirname, a test global — has
nothing to infer from, and was the one strict-deps failure
bazel run //:gazelle could not repair. The directive appends its labels to
every generated ts_compile and ts_test in the tree, a target that imports
nothing included, and subdirectories inherit it and may add their own. It
changes nothing about what the compiler accepts: the dep must exist,
and ts_compile still names only direct @types/* deps in the tsconfig.
- SvelteKit, Remix SSR and Svelte components have rules. sveltekit_build
and remix_build each wrap the framework's own Vite build as one action and
return both halves: the browser bundle under client/, the request handler
under server/, staged from declared inputs with the network blocked.
svelte_library compiles .svelte files, which ts_compile cannot read,
into files the rest of the graph can carry. SvelteKit previously got no
buildable target at all; Gazelle now generates sveltekit_build. Integration
tests:
sveltekit_test, remix_ssr_test, svelte_test, tanstack_test.
- next_dev_server and next_serve. next dev over your source tree for
the inner loop, and next start over a staged copy of the build to check that
the built app server-renders. Gazelle writes the dev target beside
next_build.
- A *.module.css or an imported asset can be bundled. ts_bundle
collected only CssInfo from its entry point, so a .module.css or an .svg
was never in the sandbox and Vite resolved the relative import onto an empty
bazel-bin path; css_module and asset_library also
did not copy their sources into bazel-bin the way css_library does. All
three providers now reach the bundle action, and both rules copy.
//tests/vite_bundle:bundle_assets_test builds an app-mode bundle whose entry
imports both and asserts the asset lands under a content hash.
- ts_bundle gained public_dir and manifest, both app mode only.
public_dir stages a filegroup into a directory of its own and hands it to
Vite as publicDir, so those files are copied into the bundle verbatim and
unhashed. manifest = True writes manifest.json, mapping each input to the
hashed file it became. Both fail at analysis time in lib mode, which declares
its output filenames and does not hash them.
- The keys and values a *.module.css declaration promises are checked
against the ones the bundler emitted. A bundle fixture dumps the real export
map through css.modules.getJSON and compares it to the .d.ts and
<source>.exports.json, then looks for every value in the emitted stylesheet.
The @keyframes divergence this entry used to record is gone.
- App-mode asset hashing is pinned. //tests/vite_bundle:app_mode_test
checked only that some .js existed; it now requires the hashed chunk name
and the emitted HTML's reference to it.
- bazel coverage works on a test whose pool runs in a second runtime.
ts_test gained coverage_provider ("v8", vitest's own default, or
"istanbul"). v8 coverage is read out of Node's inspector, which workerd has
no equivalent of, so a @cloudflare/vitest-pool-workers test needs the
transform-time provider; the launcher no longer forces v8 on the command
line, where it outranked every config layer. Two pieces moved with it: the
Bazel config layer sets test.coverage.allowExternal, without which vitest
instruments nothing under Bazel, and the lcov rewrite resolves a source path
that escapes the run directory back to its package path.
//tests/workers:worker_test reports LH:4 LF:4 for
tests/workers/src/index.js, from code that ran inside workerd.
- ts_compile can consume a real tsconfig.json: the generated config
extends it in place and does not copy it, so relative paths inside it still
resolve. This unblocks ambient-globals-only packages
(types: ["./worker-configuration.d.ts"]), jsx: preserve with
jsxImportSource, lib: ["webworker"], resolveJsonModule and
allowImportingTsExtensions.
- npm packages are fetched on demand. A target's npm cost is its own dependency
closure, not the whole lockfile; repositories fetch in parallel, caching and
invalidation are per package, and a malformed tarball fails only its own
package.
- //tests/lsp:test_tsserver_diagnostics passes for the first time. It wanted a
typescript module from a host node_modules that does not exist on a clean
machine; typescript now comes from the lockfile through @npm.
- //tests/integration/npmrc_registry covers .npmrc private registries
against a throwaway local registry that 401s unauthenticated requests: a
scoped registry= override, and an _authToken reaching the wire as
Authorization: Bearer.
- A root go.mod and go.work, so go vet, staticcheck and gopls reach
the ruleset's Go. go vet immediately found a redeclaration Bazel cannot see:
three files in one directory each declared const placeholder, one single-src
go_test apiece. CI gates on gofmt -l and go vet.
- All six examples/ workspaces are in the CI matrix. It built two, so
examples/tanstack-app was broken with nobody watching.
- //vite/tests:peer_version_test reads peerDependencies.vite out of
vite/package.json and asserts the Vite the tests install is a major that
range names. That coupling was briefly a second npm hub (@npm_vite, on its
own lockfile). See
COMPATIBILITY.md.
- //tests/integration:remix_test, a nested-Bazel journey through a real Remix
workspace: Gazelle, bazel build on what it wrote, then assertions on the
artifacts (client/index.html with hashed refs, client/.vite/manifest.json,
one hashed chunk per route). It also asserts the failure each load-bearing
attr prevents: dropping package.json from staging_srcs fails on
_staging/package.json, and a root-relative entry_point fails
bazel build //... on '//:entry_client' does not exist.
- //tests/vitest/snapshot and //tests/vitest/environment:{dom,node}_test,
the first tests here to run a snapshot assertion and a non-default vitest
environment. The environment pair asserts the same claim from both sides — one
needs a document, the other needs there to be none — so an environment
that never reached vitest fails one of them. jsdom and edge-runtime stay
analysis-only behind a build_test.
Fixed¶
- A
tsconfigpathsvalue under the tsconfig's own directory no longer fails withTS5090. The value is computed relative to the generated tsconfig, and a target below that directory relativised to a bare segment —compiled/index.d.ts— which TypeScript reads as a package name now that tsgo has removedbaseUrl. Everypathsvalue is written explicitly relative. Reached by anymodule_nameorpath_aliasestarget whose root sits under the consuming package's bin directory; ats_codegentree always does. - A directory in
ts_compilesrcssays what to do about it. It used to report(extension: .)from the file-type check. It now names the attribute the tree belongs in. - The undeclared-import check sees a
#specifier at all. Every specifier had its URL fragment stripped from the first#, and Node calls a#-prefixed name a package-private import -- the whole specifier, not a fragment on an empty one. So#shared/messagesbecame""and every one of them was silently exempt from the check. A#after the first character is still a fragment. - A dep's tree artifact no longer kills the undeclared-import check. Every
provider route into
_strict_deps_checkfed the manifest throughadd_all, which expands a directory the action holds no input for:Failed to expand directory <generated file .../compiled>. Measured on all four:TsDeclarationInfo.declaration_files,.transitive_declaration_files,JsInfo, andpath_alias_srcs. Expansion is off, and the directory enters the manifest as the one path Bazel knows. - A
glob()in a# gazelle:ts_codegensrcs:field reaches the BUILD file as Starlark. Both ways of writing one were broken. Asrcs:field holding only a glob was written as a quoted string --srcs = "glob([\"messages/*.json\"])"-- which is a string on alabel_listattribute, so Bazel refused to load the package at all: expected value of type 'list(label)' for attribute 'srcs'. A glob beside a file name was silently dropped instead, leaving a target whose generator read fewer inputs than the directive named and no diagnostic saying so. The field is now split on the commas between entries rather than every comma, so a glob keeps its own patterns and itsexclude, and the srcs attribute is built as a real expression:["settings.json"] + glob(["messages/*.json"]). An entry that opensglob(and does not parse as aglob()call is refused with a log line and no rule, rather than being dropped. Thedir:out_dir form is documented for what it is: Bazel declares the directory as one artifact, so nothing in it has a label, no<name>_compileis written, and reaching the output means a rule of your own that adapts the directory to the providersts_compile.depsreads. - A directory a
ts_codegenglob collects is no longer made into a package of its own. Rendering the glob correctly was not enough to load it: asrcs:glob reaching into a subdirectory of catalogues met ajson_libraryper file there, which makes that directory its own Bazel package -- andglob()does not descend into one, so the pattern matched nothing and Bazel rejected the package above it outright (glob pattern didn't match anything, but allow_empty is set to False). Those files are the ancestor rule's inputs, so Gazelle now writes no targets over them and the directory stays part of the package that globs it. Where that is not enough -- a file the glob does not collect still needs a target, and that target restores the package -- Gazelle names the leftovers in a log line instead.gazelle_roundtripnow runs Bazel over a generated tree carrying such a directive: the converge tests compare BUILD text, and neither this nor the quoted-glob bug was visible in text. - A
module_namedep in a package below its consumer builds again. The generated tsconfig names each module root relative to its own directory, and a producer nested under the consumer is the one shape whose relative path has no leading..to give it away://web:webdepending on//web/shared/i18n:xwroteshared/i18n/compiled. TypeScript reads apathsvalue with no visible.as a module specifier rather than a path and rejects the config outright --error TS5090: Non-relative paths are not allowed-- so the target failed before tsgo read one import, and nomodule_namedep nested under its consumer could be declared at all. Everypathsvalue the rule writes now carries an explicit./when it has none of its own:path_aliases, npm entry points, npm subpath declarations and both roots of amodule_name. The editor tsconfigts_refresh_tsconfigwrites was already right -- its values are workspace-root-relative and every one is written with the prefix -- and a test now pins that. - A dep is no longer fabricated for a directory that does not exist. When
no indexed rule provides an unresolved specifier, Gazelle names the package
that would have to:
//src/generated/apifor@/generated/api. It never checked that the directory was there. A specifier that maps inside the workspace but points at nothing -- animportsentry for a codegen output that has not been generated, most easily -- therefore produced a label Bazel answers withno such package, which fails analysis for every target in the build, where the missing module alone would have been oneTS2307from the compile. The two neighbouring cases (a file path read as a directory, an unclassified extension) were already guarded for exactly this reason; the missing directory was not. The fabricated label now requires a directory on disk. A directory that exists but is not a Bazel package is unchanged. - A dep is no longer fabricated for a directory the generator refuses to
walk. When no indexed rule provides a specifier, Gazelle names the package
that would have to:
//web/shared/public/.well-knownfor../../shared/public/.well-known/assetlinks.json?raw.rolledUpInskips a dot-directory,node_modules,distandbazel-out, so under any package boundary butevery-dirnothing claims those files and no BUILD file is ever written in them -- the resolver was naming a package the generator had already decided would not exist. Bazel answers that withno such packageduring analysis, which fails every target in the build where the missing module alone would have been oneTS2307. Both sides now read the same rule. An indexed rule in such a directory still answers first, which is what keeps a dot-directory package generated inevery-dirmode resolving. - A
#specifier resolves through the package'simportsfield. Node calls a#-prefixed specifier a package-private import, and theimportsmap in the importing package's ownpackage.jsonis the only thing that answers one. Gazelle never read that field, so#shared/flagsfell through to the bare- specifier branch and came back as@npm//:#shared-- a label whose target no hub declares, which fails analysis for the whole build rather than dropping one dep. The map is now read as a path alias (conditions objects and alternative arrays included), belowcompilerOptions.paths, which is what kept the one monorepo measured working: it duplicates both entries intopaths. Nearest wins: an inner package's map replaces an outer package's answer for the same key, the way Node answers a#from the nearest enclosingpackage.jsonrather than the outermost. An entry whose target names another package rather than a path --{"#dep": "lodash"}, the conditional-polyfill shape the field exists for -- resolves to that package's label. A wildcard that is not a trailing/*is dropped: an alias key matches by prefix, so a key holding a literal*could never fire. A#specifier no entry covers at all resolves to nothing.paths. A#specifier no entry covers now resolves to nothing. - A
json_libraryparse error names the line the mistake is on. The rule parses the stripped text, so the line and column injson_library: failed to parse ...come from that text and not from the file a person edits.Stripdeleted a block comment whole, and deleted the whitespace between a trailing comma and its closing brace -- newlines included, while the//branch had always emitted one. A block comment spanning four lines above the mistake reported it three lines early (line 7 read as line 4); a trailing comma whose}is on the next line cost one more (line 6 read as line 5). Both branches now leave their newlines where they were, and each half is load-bearing on its own. - A checked-in
*.gen.tsis a source again. Gazelle dropped every file whose name ended.gen/.generated/.autofrom every source target, on the guess that something in the build produced it. The guess was already unnecessary:claimedSrcskeeps out exactly what ats_codegenin the package declares as an out. What the name check added on top was the checked-in generated file nothing produces -- 204 of them in one monorepo, under one directory, behind 416TS2307s from the#shared/lib/flags/gen/<name>.genspecifiers that import them. OnlyrouteTree.gen.tsis still recognised by name, because the build really does write it: the Start Vite plugin regenerates it into the staging tree, and the tree it emits imports the router module that imports the tree back -- a cycle between two Bazel packages however the two are split. The edit: a workspace that wants some other generated-looking file out ofsrcsnames it in# gazelle:ts_exclude. json_libraryreads JSONC, so a commented.jsongets its.d.ts. TypeScript accepts comments and trailing commas wherever it reads JSON, and Gazelle already decodedtsconfig.jsonthrough a stripper for exactly that reason -- but the rule's own build-time read was a strictJSON.parse, so the same file meant two things depending on which side read it. The stripper moved to//gazelle/jsonc, andjson_librarynow strips through//gazelle/jsonc/stripbefore parsing: one implementation of the dialect, shared by the BUILD generator and the rule. Fourjson_librarytargets on one monorepo built again, among them twotsconfig.*.jsonfiles -- files Gazelle itself reads as JSONC. This is the declaration and the type-check; it is not a runtime claim. Ajson_library.jsonreaches no bundler at all today -- nothing copies it into bazel-bin and it carries noAssetInfo, so ats_bundleoverimport data from "./data.json"fails to resolve the specifier whether or not the file has a comment in it (TODO.md, "Known gaps").ts_testroots Vite at the package, so a relative path in a user config means what it says. Vite's root defaulted to the working directory, which under Bazel is the runfiles root -- one tree above the package. Every relative path a config author writes was resolved from there: asetupFiles: ['./x.mjs']looked for<runfiles>/x.mjs, and@cloudflare/vitest-pool-workersresolvedwrangler: { configPath: 'wrangler.jsonc' }to<runfiles>/wrangler.jsoncand failed the run withCould not read file. The generated config now setsrootto the package directory the launcher already exports asTS_TEST_PACKAGE_DIR. It is in the Bazel layer, so a config that wants a different root still wins.-
A src listed from outside this package's tree fails while the BUILD file loads. A source is compiled into the package of the target that lists it, but the root its package-relative path hangs off is where the file actually lives, so a src from a sibling package, an ancestor or another repository hangs off a root of its own while the listing package's own sources hang off the package -- and one tsgo declaration emit has one
rootDir. That was already an error -- at analysis time, printing the exec root as an empty line, never naming the file, and blaming "a mix of checked-in and generated sources", which is a different cause. The srcs list is a loading-phase fact, so the half of the rule it can decide is now decided there, naming every file. Only the mix is rejected, and six shapes are not it:declarations = "oxc"andenable_check = False, the two escape hatches the analysis-time error already offers; a.d.ts, passed through rather than compiled, which is what makesvite_types = Truelegal from any package; a descendant package's src, which is already inside this package's directory and hangs off the same root -- a ts_compile may hold a subtree, and a subtree may grow a BUILD file; a target whose srcs all come from elsewhere, which has one root like any other; the top-level package, which IS the exec root a foreign src hangs off; and aselect, whose branches resolve after loading is over. Only a label that names a source file is judged --//other:some_targetstands for files the loading phase cannot place.@//pkg:fand the canonical@@//pkg:fare this repository, so@@//<this package>:fis this package. The analysis-time check stays for the half a srcs list cannot show, a generated source in this same package. -
Gazelle reads
pnpm-lock.yaml, so the npm inventory everything gates on is no longer empty.tc.npmPackages-- "which npm packages does this workspace declare" -- was populated in one place only, from the deprecatedgazelle_ts.jsonnpmMappingFilekey. Gazelle detected the lockfile's existence to decide whether to emit//:pnpmmacros and never read its contents, so on any repo without agazelle_ts.jsonthe inventory was nil and every check against it silently did nothing: anode:fsimport got no@types/nodedep (228TS2591plus 8TS2503on one monorepo measured), the prisma and graphql-codegen detectors emitted a@npm//:<tool>_binon file presence alone, andfilterNpmDepskept every configured framework dep whether the workspace had it or not. The lockfile is now parsed once per run into the set of names the@npmhub declares a flat label for -- the resolved closure, workspacelink:members and npm aliases included -- for lockfile formats 6.x and 9.x. An unsupported version logs and leaves the inventory absent rather than empty, which keeps the heuristics for a workspace whose lockfile could not be read while turning them into real checks for one whose could.gazelle_ts.json's mapping file still wins per package it names. # gazelle:ts_codegenwrites the targets it names, and imports of the generated module resolve to them. The directive parsed into a pattern that carried nosrcs, and a pattern with nosrcsproduces no rule, so the directive did nothing at all. It takes an optionalsrcs:<csv>field now and defaults to the directory's own sources without one. Alongside thets_codegenGazelle writes<name>_compile, thets_compilethat carries the codegen label insrcswithdeclarations = "oxc"— the generated module has to reach a compile throughsrcs, sincets_compile.depsrequires providersts_codegendoes not return — and indexes every declared out against it, soimport "./schema.gen"gets a dep with nothing hand-written. A declared out that is also checked in is kept out ofts_compile.srcs, whether the rule declaring it comes from the directive or was already in the BUILD file: the file being both a source and an output of its package is a conflicting declaration Bazel rejects. Deleting the checked-in copy is therefore optional, and works either way.- A pnpm workspace member is importable by every specifier its
package.jsondeclares. Thepathsmap hardcoded<root>/index.d.tsfor a member's bare specifier and<root>/*for everything under it, so a member entered through anything else wasTS2307at the root, and everyexportssubpath wasTS2307unless the specifier happened to mirror the source layout. A design-system package with a subpath per component — 99exportskeys,./button→./components/controls/button/index.ts— resolved almost none of them. The hub target now reads the member's own manifest:exportsin the map's own key order, thentypings,typesandmain, each designated target answered with the declaration Bazel emits from it. Every declared subpath gets its ownpathskey and a wildcard subpath gets a wildcard pattern, with the old guesses kept behind them — so a manifest naming a file the build does not produce resolves as it did before, and a member that declares nothing generates a byte-identical tsconfig.moduleis not read: no TypeScript resolution mode consults it. A subpath excluded withnull, and one naming something no compiler emits a declaration from, get no entry; enforcing that they must not resolve is the strict-deps check's job, not this map's. - The editor tsconfig reads that manifest too.
ide_tsconfigwrites the workspace-roottsconfig.jsonan editor resolves against, and it carried the same two guesses —<root>/indexfor the bare specifier and<root>/*for everything under it. So an editor answered@scope/pkg/buttonwith a file that is not there while the build answered it from the manifest, which is a divergence between the two programs rather than one wrong answer. It now writes the declared entries first, each in the source tree and underbazel-bin, with the guesses behind them exactly as the compile tsconfig keeps them. - A member's
package.jsonis found where its label says it is. Member paths were resolved against the lockfile's own directory whilelink_target_labelwrites@@//<path>, so a lockfile in a subdirectory looked for members beside itself and named them somewhere else. The two coincide for a lockfile at the workspace root, which is every real one. typingsis read beforetypes, the orderreadPackageJsonTypesFieldsreads them in. Both layers that resolve a declaration entry point now agree.- An npm alias resolved against peers gets its hub label. An alias declared
by an importer was recorded with its peer suffix stripped —
tailwindcss@3.4.18for a dependency pnpm resolved astailwindcss@3.4.18(tsx@4.23.12)(yaml@2.8.1)— and that value is apackages:key, while the hub looks an alias up among thesnapshots:. It matched nothing, so@npm//:<alias>did not exist at all and the per-importer label pointed at a target the package's own repository was never told to declare. An alias whose package pnpm resolved without peers was unaffected, which is why the flat-hub alias support looked complete. Acatalog:entry is the common way to reach the broken half: the catalog names the version, the use site sayscatalog:, and the resolution carries whatever peers the package has. - A pnpm
link:workspace member reaches the runtimenode_modulestree. The hub target for aworkspace:*dependency forwarded the providers a compiler reads and none that anode_modulestree is built from, so ats_testdepending on@npm//:<member>type-checked and then died in Node's resolver withCannot find package '<member>'. The hub target now describes the member as an npm package -- its files, its own npm closure, and a generatedpackage.jsonmarking it ESM -- and it stages like any other. - The hub looks up a
link:member's target instead of deriving it from the member's entry point. The label was//<member>/<dir of main>:<basename>, which is one of several targets Gazelle might have generated and is right only by coincidence:# gazelle:ts_package_boundary tsconfigrolls a member's subtree up into the directory holdingtsconfig.json, so a member withmain: src/index.tsbuilds from//packages/x:xand not from//packages/x/src:src, and# gazelle:ts_target_namerenames the target outright. The hub now walks from the directories the member's manifest designates an entry point in --main,moduleandexports["."], so a member that declares only an exports map is walked too -- up to the member's root, and takes the innermost one declaring a target of that name, honouringts_target_name; a directory becoming a package refetches the hub. When no candidate declares it the hub declares no target for that name and writes a comment saying why, and that covers a member whoseBUILD.bazelexists and declares something else as much as one with noBUILD.bazelat all -- an undeclared target fails only what asks for the member, while a label naming a target Bazel cannot resolve fails analysis for everything that reaches the hub. - Gazelle converges. The framework generators were create-if-absent
(
if !ruleExists(...)), so the second run emitted no candidate and the rule froze at whatever the first run wrote: a file added to a staged directory was absent from the build with nothing failing. They regenerate on every run;TestConvergeAfterMutationcovers 58 cases across six workspace shapes. - A value Gazelle drops is named, and a deleted one is not. The set of
attributes Gazelle owns and recomputes is derived from
Kinds(), which had been hand-listed and leftts_compile.depsunguarded. Every dropped value is reported with the# keepthat would hold it. Two cases stay silent: a value whose file or package is gone from disk, where# keepwould name a source nothing provides; and anything already carrying# keep. An expression the merger cannot reconcile value by value (a variable,a + b, aselect()) is reported as no longer maintained. - Gazelle's Node-builtin list is checked against the Node it runs. The
hand-maintained list was missing 15 bare names:
sysand the_http_*,_stream_*and_tls_*legacy aliases, soimport "sys"got a dep on@npm//:sys, a label no hub declares. In the same helper,# gazelle:ts_warn_unresolvedlogged "unresolved import" for every builtin sub-path (fs/promises,timers/promises,stream/web,util/types).tests/strict_deps/builtins_test.goreadsbuiltinModulesout of the toolchain Node the check action runs and asserts set equality. - A consumer whose lockfile has no esbuild can build the dev-server plugin.
//vite:esbuild_node_modulesnamed@npm//:esbuild, and inside a consumer's build@npmis their own lockfile. That tree feeds//vite:vite_plugin_bazel, whichts_dev_servertakes throughpluginand Gazelle writes with a dev server. esbuild now comes from@npm_css, at the version that hub already pinned.examples/react-appcarries thepluginattr Gazelle would write, and//tests/npm:vite_plugin_hub_testand:css_compiler_hub_testassert by aquery that neither tree reads@npm. - A musl-only tarball is no longer fetched, extracted or staged. pnpm's
libc:field fell through the parser's ignoring branch, so alibc: [musl]package was selected on glibc linux like any other. The parser carrieslibcalongsideosandcpu, and selection rejects a package whoselibcdoes not include the target's. Following npm's owncheckPlatform, darwin and Windows reject such a package too. Measured here:oxlint_linux-x64-muslwent from 6 occurrences inMODULE.bazel.lockto 0. - The dev server serves its own workspace on macOS.
server.fs.allownow carries both the resolved and the unresolved workspace path. Vite matches a request against the resolved one, so on a host where the workspace sits under a symlink (/varis/private/var) the server answered403for every module. - The generated IDE tsconfig no longer differs per host.
ts_refresh_tsconfiggainshost_only_packages, and the workspace's own target namesfseventsin it. pnpm resolves anoptionalDependenciesentry only where itsos/cpumatch, sofsevents— which shipsfsevents.d.tsand survived the drop of packages with no declarations — put apathsentry in a checked-in file and//:refresh_tsconfig_testfailed for everyone on the other platform. - A consumer on rules_rust 0.73 or newer can build again. 0.73 refuses a
crate_universehub declared by a non-root module unless the rendering is pinned by a checked-inlockfile. Both hubs now ship one:oxc_cli/Cargo.Bazel.lockfor@cratesandoj/Cargo.Bazel.lockfor@oj_crates, each beside theCargo.lockthat pins its cargo resolution. Without it, every consumer, example and integration test failed at analysis with "is in a non-root module but has no lockfile". Repinning is documented in CONTRIBUTING.md. - An npm package's declaration entry point is resolved the way a resolver
resolves it.
_exports_typesreadexports["."]for atypeskey directly under it, with no fallback to top-leveltypes/typings— where most of npm publishes, every@types/*package included. The tsconfig aspect wrote apathsentry pointing at a directory, TypeScript resolved nothing, and the build stayed green. Resolution now walks theexportssubtree in the map's own key order, array fallbacks and conditions-only shorthand included; a leaf naming.js/.mjs/.cjsresolves to the declaration beside it; then top-leveltypes, thentypings, extensionless included. Every candidate is existence-checked against the extracted package: six@babel/helper-*resolutions here name a.d.tsthe package does not ship. - An
@types/*dep supplies its ambient globals. The entry-point.d.tsof each direct@types/*dep is listed in the generated tsconfig'sfiles, and notypeRootsis derived at all. The derivedtypeRootsnamed the package directory itself andexternal/, soprocess,setTimeout,Bufferandimport 'node:fs'were all errors.NpmPackageInfogainsambient_types_file;TsDeclarationInfo.type_rootsis deleted. - Consumer setup no longer asks for
build --@rules_rust//rust/toolchain/channel=stable. The flag is a consumer-visible error (No repository visible as '@rules_rust') and a no-op here; this ruleset's Rust channel already defaults to stable. - Install instructions document the pre-BCR
git_override/archive_override/local_path_overriderecipes, since no registry entry or release exists for a barebazel_depto resolve against. - Cargo build output is gitignored.
oxc_cli/target/had 2322 tracked files, 622 MiB of blobs, which made the releasegit archiveof HEAD 206 MiB, not the 1.2 MiB it is now. - Gazelle parses
tsconfig.jsonas JSONC: comments and trailing commas no longer makecompilerOptions.pathssilently disappear. - Gazelle follows
extends, as a single specifier or an array of them, so a workspace member whosetsconfig.jsononly inherits a shared base still gets that base'spathsandbaseUrl. The merge istsc's: a key is replaced wholesale by the config nearest the leaf, never merged entry by entry, and a relative target resolves against the directory of the config that wrote it. A specifier that resolves throughnode_modulesis skipped with a warning. A stubtsconfig.jsonthat only extends therefore stops being inert, and atsconfig.jsonwithpathsreplaces the alias map for its directory and everything below: a subtree that reached a parent's# gazelle:ts_path_aliaskeys through such a stub now keeps only what the base declares. - Gazelle picks a
compilerOptions.pathsfallback entry that exists, no longer always the first. Entries under thebazel-*convenience symlinks are dropped outright (ts_compilefails analysis on an alias pointing into the output tree), as are dot-directory entries such as.bazel/npm; of what is left, the first that exists on disk becomes the alias, and when none do the first is kept. Thepaths entry "…" has N targets; using only "…" (first)line — 74 per run on this repository, all the./bazel-bin/…mirrorts_refresh_tsconfigwrites — is replaced by one that fires only when two entries in a chain are both real directories.path_aliasesis unchanged: stillstring_dict, still one directory per alias. - Gazelle names
css_library,css_module,asset_libraryandjson_librarytargets after the whole filename (button.css→button_css), so they no longer collide with the directory-namedts_compiletarget or each other. - Integration runners no longer default their scratch directory to
/tmp: a nested Bazel output base does not fit in a small tmpfs, and the failure surfaced as "No space left on device". - The tsgo repository rule no longer runs an unchecked host
chmod +x; the exec bit comes from the npm tarball. ts_npm_publishno longer creates an undeclaredpackagesymlink next to its output; two publish targets in one package wrote that path, so a concurrent build could archive the wrong directory. The staging directory is itself namedpackageandtar -Creads it directly.ts_npm_publish's staging action runs underset -euo pipefail; a failed copy used to leave a partial package and exit 0.ts_binarywith a Vite bundler no longer fails analysis with "No attribute 'env_vars' in attr". The Vite config generator reads the bundle attrsts_binarydoes not declare through their defaults.- Rules that run Node inside a build action (
css_module,json_library,ts_codegen,next_build,ts_npm_publish,vite_bundler) resolve it from thejs_tool(exec platform) toolchain, no longer fromjs_runtime(target platform). Under a--platformsthat differs from the host they ran the target's Node:node.exeon a Linux builder. vite_bundlerandnext_buildno longer fall back to anodefromPATHwhen no toolchain is registered; the missing toolchain is an error.//vite:vite_plugin_bazelis built by a rule that resolves the Node toolchain, replacing a genrule whose hand-writtenconfig_settings keyed the target platform to pick an exec-platform@nodejs_*binary. It failed analysis under--platforms=//platforms:windows_amd64.- An npm tarball's top-level directory is detected with
rctx.path().readdir(), no longer by shelling out to the hosttar, which on failure returned""and fell back topackage, aborting the fetch for DefinitelyTyped-style tarballs such as@types/express-serve-static-core(packed underexpress-serve-static-core v4.19/). Norctx.executecall remains in the ruleset. - Launchers resolve paths through the Bazel runfiles library, so they work with
a runfiles manifest and no symlink tree; the hand-rolled
RUNFILES_DIR/TEST_SRCDIR/$0.runfilesdiscovery died atcdthere. Four hand-written shell-quoting helpers are deleted with it. - Cycle breaking removes only cycle-closing edges. Self-edges are now removed (a
one-member SCC broke nothing, so Bazel saw the cycle), and an edge between two
distinct cycles is kept. Every edge a hub drops is written into
MODULE.bazel.lockasbroken_cycle_edges: four in@npm, three in@npm_workers, one in@npm_eslint, none in the other three hubs. ts_dev_serverno longer falls back to a host-PATHnodeorvite. A missing JS runtime toolchain fails at analysis time; a missingnode_modules/vite fails the launcher with an actionable message.- The Vite HMR watcher rides Vite's own
server.watcher, no longerimport('chokidar')— chokidar is inlined into Vite's dist chunk, so that import always threwERR_MODULE_NOT_FOUNDand every rebuild was dropped silently. A watcher that cannot start now warns. - The Vite bundler creates a
node_modulesbeside the generated config.linux-sandboxremounts/read-only, so Vite's.vite-tempmkdir hit the source tree withEROFS, which Node'srecursive: truemkdir masks asENOENTwhile Vite tolerates onlyEACCES.examples/tanstack-appcould not build. - Gazelle names a framework bundle's node_modules tree
node_modules. Node realpaths a module before resolving its bare imports, so<app>_node_modulesmade every Vite framework bundle fail to findrolldown. - No action needs a shell on the exec platform:
ctx.actions.run_shellis gone from the ruleset. - Gazelle's path-alias resolution is deterministic. When several
compilerOptions.pathsentries match one specifier — a tsconfig declaring both@xand@x/*— the longest matching alias key wins, matching TypeScript's own resolution. Go's randomised map iteration used to pick, so two identical runs could disagree. Ties break lexicographically. - An alias key without a trailing slash matches only at a path-segment boundary:
@sharedno longer swallows@sharedX. Gazelle's alias test is then identical to the strict-deps check's. - Gazelle's import scanner is a character-walk lexer, the same walk the
strict-deps check runs. The regex set it replaces missed
import def, * as ns from "x"(no dep generated) and matched specifiers inside template literals (deps on labels that do not exist). - Gazelle resolves a specifier that spells out its extension. A
NodeNext-style
./rules/foo.jsover afoo.tssource got a dep on//<dir>/foo.js, a label that does not exist. One candidate list now serves both sides: the path as written, the path with its extension dropped, that stem under each known extension, and<stem>/index.ts[x]. - Gazelle no longer writes a dep for a Node built-in spelled without
node:.import { join } from "path"became@npm//:path, which does not exist. The exemption matches the strict-deps checker's on the bare name. - A
ts_compile'smodule_nameis indexed, and a bare specifier consults the index before npm.import "@acme/lib"became@npm//:acme_lib, and the hub has no such package. - A generated
ts_compilecarries only the path aliases it can satisfy. The whole workspacepathsmap was copied onto every target, andts_compilehard-fails on an alias whose files are none of its inputs, so a Gazelle run could leave dozens of targets failing analysis. A target gets the aliases its own imports match, plus any alias whose directory holds its own sources. Aliases read back out of a tsconfig this ruleset generated are an echo and are skipped; a# gazelle:ts_path_aliasdirective reaches the IDE tsconfig'spathseven when nothing imports through it yet. - A generated target no longer claims a hand-written target's srcs. Two
ts_compiles over one source declare the same.jsand.d.ts, a conflicting-action error. Generation drops any src an existingts_compile/ts_test/css_*/asset_library/json_libraryin the same BUILD file already lists, plus anyts_test'ssetup_filesandglobal_setup. - Gazelle no longer emits a dep on the importing package itself. A module in the importer's own package that no rule claims used to resolve to that package's own label, a dependency cycle. It resolves to nothing, and an unindexed module elsewhere resolves to its directory's target when the last segment names a file.
bazel run //gazelleis a no-op on a clean checkout of this repository: zero files changed. Ten BUILD files came back modified because they differed from Gazelle's own rendering, not because anything had drifted. The fixtures carry that rendering, and the hand-written forms that must survive a run are pinned with# keep—visibilityincluded, since it merges and a hand-narrowed one came back//visibility:publicevery run. Check yours withbazel run //:gazelle -- -mode=diff.- Gazelle names the framework it will not bundle. SvelteKit and Solid Start
were emitting
node_modules+vite_bundler+ts_bundletargets that cannot build: SvelteKit's plugin runs its own sync step from the Viteconfighook and wants filesstaging_srcscannot carry, and@solidjs/startships no Vite plugin at all, sodefineConfig()returns a vinxi app thevite_configcontract discards. Both are still detected. SvelteKit now routes tosveltekit_build, which owns the working directory its plugin reads. Solid Start writes no bundle target and logs the framework, the reason and the fallback; a detected framework in neither table logs that no reason is registered for it. - The generated Remix bundle builds. Its
entry_pointwas":entry_client", root-package-relative, and the root has no TypeScript, so the label dangled and killedbazel build //...for the whole workspace. It names//app:entry_client, andpackage.jsonis staged for the@remix-run/devplugin to read from the staging root. bazel run //path:my_test.update_snapshotsno longer writes a.vite/cache directory into the source tree. Update mode runs with the working directory atBUILD_WORKSPACE_DIRECTORY, from which vite derivedcacheDir, so the layer pinscacheDirunder the target's output directory.
Measurements¶
Both numbers below come from one machine on one day; only the first can still be reproduced from this tree.
- Declaration emitters, via
tools/bench_declarations.sh 20 50 3(1,000 annotated files, 20 packages, one linear chain, medians of three interleaved runs):declarations = "tsgo"6.3s wall / 4.89s critical path,declarations = "oxc"3.8s / 2.15s,"oxc"withenable_check = False2.7s / 1.06s. The script is committed; re-run it on your own graph. - npm layout, measured while both existed: building one vitest target from an
empty output base against a 2731-package lockfile went from 392s / 2.9 GB of
external/to 66s / 415 MB, fetching 138 packages (vitest's actual transitive closure) and not all 2731. The single-repository arm is deleted, so this is a historical record. What is reproducible:bazel query 'kind(ts_npm_package, deps(//your:test))'counts the package targets a target can reach; here//tests/vitest/environment:node_test, whose only npm dep is@npm//:vitest, reaches 94, one repository each.
Deliberate behaviour¶
- Ambient globals reach a target from its direct
@types/*deps only. Declaring@types/nodeis how a target asks forprocess. Transitive@typesdeclarations still arrive as inputs; they are not named in the tsconfig'sfiles;# gazelle:ts_ambient_typescovers the ergonomics. The editor's single root program unions every ambient entry in the graph, so an undeclared global type-checks in the editor and fails the build; narrowing that per target would need a tsconfig per target.bazel buildis the authority, per ide-setup.md. skipLibCheck: truestays in the zero-config baseline. tsgo over the Workers fixture, three ways: with the rule'slib = ["es2022"], zero errors; withoutlibandskipLibCheck: true, one error at the use site (TS2339: Property 'default' does not exist on type 'CacheStorage'); withoutlibandskipLibCheck: false, that same error plus two maskedTS2403declaration conflicts. Narrowinglibis the fix, a documentedts_compileattribute with a worked Cloudflare example.- The array
configform ofts_testrequires vitest 3.2 or later. vitest 4 removedtest.workspaceand throws on it; vitest below 3.2 does not knowtest.projects. The generated config picks what the tested lane requires. Supporting 3.0/3.1 would mean sniffing vitest's version at config-load time plus a second lockfile lane, which COMPATIBILITY.md rules out by policy. No lockfile here resolves vitest 3; what is true of the otherconfigshapes is that they use no version-sensitive key. jsdomandedge-runtimestay analysis-only.environmentis a string the rule forwards totest.environment, and nothing ints_testbranches on its value, sodomandnodepin both sides of the only per-value axis:resolve.preserveSymlinks;//tests/workerscovers the third case, where a pool wants it false and the user layer wins. In tree the two absent values would cost a fifth lockfile pinning Vite and vitest, and jsdom also couples tonode_version(jsdom 30 wants node^22.22.2; the toolchain is 22.14.0, so the pin would sit at 29) plus 126 lines of declaration closure in the checked-intsconfig.json. Both staymanualbehind abuild_test, andMANUAL_ONLYintools/ci/check_test_sources.shnames both files and is exact in both directions, so untagging either fails CI./// <reference types="x" />is not checked. The directive resolves through TypeScript's type-reference resolver (node_modules/@typesandtypeRoots), not thepathsmap that carries npm deps, and there is nonode_modulesto walk, so it cannot resolve however the target is declared. tsgo reportsTS2688: Cannot find type definition file for 'x'. Gazelle cannot name a label either:types="x"means@types/xorx. The remedy is the rule:vite_types = True, or an ordinary@types/*dep.
Known gaps¶
- A
ts_codegengenerator that emits.tssources into anout_dirhas no route to ats_compile.depstakes the tree as already-compiled output and nothing downstream compiles it;srcscannot take it, because one output per input file is declared at analysis time. Closing this needs an oxc invocation over a whole directory emitting a directory, which is a different action shape, not an attribute. ts_codegen(node_modules = ...)only serves an ESM generator when the target is named literallynode_modules. The tree's directory is named after its target and Node's ESM resolver only ever looks in a directory callednode_modulesas it walks up;NODE_PATH, which the rule sets, is a CJS mechanism. A misnamed target leaves the generator failing withERR_MODULE_NOT_FOUND. Documented rather than fixed: renaming the artifact under the target would break thenode_modules()rule's own contract.- Three
@npmlabels remain in ruleset packages, unreached.//vite:plugin_typecheck(@npm//:types_node,@npm//:vite),//vite:tsup_config(@npm//:tsup) and//ts/private/css:compiler_typecheck(@npm//:types_node).rdepssays nothing consumer-facing reaches them. Making the invariant absolute means either adding@types/node,viteandtsupto the non-dev hub, growing every consumer'sMODULE.bazel.lockfor targets no consumer builds, or moving the targets into packages consumers never load, which needsts_compilesrcs to cross a package boundary. _short_digestis Java'sString.hashCodemasked to 32 bits. Two peer suffixes agreeing on their first 40 sanitised characters and colliding on that hash merge into one repository and one store directory. Starlark has no real hash function. A fix keyed on the hub's enumerated snapshot dict was rejected: it broke//tests/integration:tanstack_testwith a dangling link totiny-invariant. Cross-hub is a second case:_store_pathand_package_keyints/private/node_modules.bzlcarry no hub component.- There is still no libc
constraint_setting. Selection no longer needs one, sincelibc:is honoured in the parser and the matcher. What one would add is the ability to register a musl toolchain, and Node.js publishes no official musl tarball to register. - A
compilerOptions.pathschain collapses to one directory. Gazelle picks the first entry that exists on disk. Of 448pathskeys in this repository's own root tsconfig, 78 are chains and not one has a second entry surviving thebazel-*filter. A key with two genuinely distinct real directories keeps one and logs which it used and which it dropped; a specifier only the dropped directory provides fails withTS2307. Wideningpath_aliasesto astring_list_dictwas rejected:_validate_path_aliasesrequires every alias directory to hold one of that target's own staged inputs and Gazelle emits nopath_alias_srcs, andTsconfigSourcesInfo.aliasescarries no ordering index for the workspace-root tsconfig's union. vite/bundler.bzlborrows the namenode_modulesin the package output directory, which a siblingnode_modules()target may already own. Under the default sandbox the wrapper'sln -sfplants the name fresh and two Vite majors in one package do build. With sandboxing off (--spawn_strategy=local) the link lands inside the sibling's declared output and the action runs the sibling's Vite, silently. Nesting each tree at<target>/node_modulesdoes not fix it: the generated config sits outside the tree and itsimport { defineConfig } from "vite"resolves by walking up out of it. Order of work if taken on: drop that import (defineConfigis an identity function, andts_dev_serveralready loads its plugin by absolute path), then the link, then the tree layout. Until then, one tree per Bazel package, or one per package per Vite major.- Windows is unsupported, not partially supported. See COMPATIBILITY.md.
[0.1.0] — never released¶
No tag, no release and no registry entry exists for this version. The list below records what the ruleset did before the changes above.
- Core TypeScript compilation with oxc-bazel (Rust-based, per-file transform)
- tsgo (Go port of TypeScript) emits
.d.tsfrom the full type program by default, so unmodified TypeScript compiles with no explicit export annotations, and type errors failbazel buildbecause the declarations are real outputs declarations = "oxc"as an opt-in throughput mode: Oxc emits.d.tssyntactically (requiring isolated declarations, which it enforces) and type-checking becomes a validation action off the critical path- npm dependency management via pnpm-lock.yaml parser (v6 and v9 formats)
- Multiple npm package version support with semver-correct alias resolution
- npm bin script generation (
@npm//:vitest_bin,@npm//:esbuild_bin, …) - Conditional exports (package.json
exportsfield) resolution - pnpm workspace support (
workspace:*protocol) - Dependency cycle detection and breaking (Kosaraju's SCC algorithm)
- Gazelle extension for BUILD file auto-generation
- Gazelle every-dir default (every directory with
.tsfiles is a package) - Gazelle directives:
ts_package_boundary,ts_declarations,ts_path_alias,ts_runtime_dep,ts_exclude,ts_warn_unresolved,ts_ignore,ts_target_name,ts_codegen - Gazelle auto-detection of TanStack Router, Prisma, GraphQL codegen, OpenAPI generators
- Gazelle reads
tsconfig.jsoncompilerOptions.pathsautomatically - Gazelle generates
ts_compile,ts_test,ts_lint,ts_dev_server,css_library,css_module,asset_library,json_library,ts_codegentargets ts_testwith vitest (auto node_modules from deps, DOM testing, coverage, snapshots, custom config, environment selection)ts_binary(runnable, entry_file convention, index.js default)ts_bundlewith real Vite integration (ESM/CJS, tree-shaking, minification, chunk splitting, source maps, app mode, env_vars)ts_dev_serverwith ibazel HMR support and React Fast Refresh- Vite plugin injection via
vite_configattr (unlocks Remix, SvelteKit, TanStack Start) ts_codegenrule for code generation (TanStack routes, Prisma, GraphQL, OpenAPI, custom)css_library,css_module(typed.d.tsfrom regex extraction),asset_library,json_library(fully typed.d.ts)ts_lintrule (ESLint/oxlint as validation action)ts_npm_publishrule (tarball with auto-filled main/types/exports)- ESLint plugin for isolated declarations migration
(
require-explicit-typesrule) vite_typesattr onts_compileforimport.meta.envand asset URL typespath_aliasesattr onts_compilefor tsgo type-checking with path aliases- JS runtime toolchain (Node.js via rules_nodejs, pluggable for Deno/Bun/workerd)
toolchain_utils(v1.3.0) integration for resolved toolchain targets- Linux ARM64 platform support (oxc built from source, tsgo from npm)
- IDE support via
bazel run //:refresh_tsconfigand a live tsserver resolution hook - GitHub Actions CI workflow
- Examples: basic, app (zod), react-app (React + testing-library), tanstack-app (TanStack Router SPA)
- BCR presubmit configuration