Gazelle Overview¶
Gazelle writes the BUILD files: one package per compiler program, its targets' sources and deps read off tsgo's own listing of that program.
Setup¶
Add the Gazelle binary to your root BUILD.bazel:
load("@gazelle//:def.bzl", "gazelle")
gazelle(
name = "gazelle",
gazelle = "@rules_typescript//gazelle:gazelle_typescript",
tags = ["manual"],
)
gazelle_typescript carries the TypeScript language alone. The other binary in
that package, gazelle_ts, also carries Go and proto; rules_typescript uses it
to generate BUILD files for its own .go sources. In a polyglot repo it
rewrites Go BUILD files too.
Add gazelle to MODULE.bazel:
rules_typescript declares rules_go, go_sdk and go_deps as non-dev
dependencies, so they propagate transitively via bzlmod. Consumers need only the
bazel_dep above, and no Go toolchain of their own. Gazelle compiles only
under bazel run: tags = ["manual"] keeps the target and its runner out of
bazel build //..., and the run fetches a Go SDK and its modules. bazel build
and bazel test compile no Go unless the workspace registers
tsgo from source; the ruleset's
other Go tools are the binaries of its
tools release.
Run Gazelle:
What a Run Reads¶
Five things, without program-membership directives.
- Every selected compiler configuration, normally
tsconfig.json. A keptsrcon the package's canonicalts_config(name = "tsconfig")selects an authored alternative independently of an editor solution wrapper. It is listed through tsgo from the repository root:tsgo -p <selected-config> --noEmit --listFilesOnly --explainFiles --pretty false. The listing is the program's files and every edge between them -- each import, module augmentation,/// <reference>directive andtypesentry, with the file it resolved to. The binary is the toolchain's, carried ingazelle_typescript's runfiles, so the program Gazelle reads is the one the build type-checks;-ts_tsgo=<path>names another. Two files are never listed: a roottsconfig.jsonwhose extends chain sets neitherincludenorfiles, since tsgo would enumerate the whole repository, and atsconfig.jsonts_refresh_tsconfigwrote, built out of the very targets that would name it. pnpm-lock.yamlat the repository root, read once: every name the hub declares, the importers and what each declares, and thelink:entries that make a directory a workspace member.- The nearest
package.jsonabove a package: itsname, and for ats_testitsdependenciesanddevDependencies. Without a local tsconfig, existing JavaScript and TypeScript entry points fromexports,types,typings,main,moduleandbrowserseed the same native compiler listing with JavaScript enabled. - The vitest configs the generated tests name, listed during generation while Gazelle's native walk can check exclusions for their imports. Each selected config is listed on its first request; tests using the same config share its observed program. Its imports and their source closure supply the test's runtime.
- The hand-written
ts_codegenrules in the BUILD files walked: theiroutsand everyout_dir, the target's output whatever a local run of the generator left on disk.
The listing resolves through the checkout's node_modules, so a repository
with a root lockfile is installed before a run. tsgo prints no line for an
import a missing install leaves unresolved, and a root pnpm-lock.yaml with no
node_modules/.modules.yaml beside it stops the run before any deps is
written. A tsconfig.json whose nearest package.json is no importer in the
lockfile is not listed at all: pnpm installs nothing for that project, so it is
foreign to the workspace (The Package Model).
A run says nothing else about the listings, tsgo's diagnostics included.
-ts_verbose prints which binary ran, one line per tsconfig.json with what
it listed or why it was not, its diagnostics, how many are packages, and the
.ts/.tsx/.mts/.cts files no program lists, per directory and in total:
bazel run //:gazelle -- -ts_verbose.
The Package Model¶
A directory is a package when its tsconfig.json lists a first-party file: a
path inside the repository and not under node_modules. include, files and
exclude are the program's, so they decide what the package compiles. A
directory without a local tsconfig.json can instead list existing manifest
entry points through the compiler. This includes separate declaration and
JavaScript exports and their imports; it does not synthesize a tsconfig.
Wildcard or nonlocal code entry points need an explicit tsconfig and produce
a diagnostic. Missing emitted entry points do not become source inputs. A
listing with no first-party file gets no target.
A package.json the lockfile has no importer for marks a foreign project:
pnpm installs nothing for it, so a bare import under it resolves through
whatever an importer above hoisted, or not at all. That directory and every
directory below it, down to the next package.json that is an importer, is
outside the package model: a tsconfig.json there is not listed and is no
package, and the run names the manifest once. A project meant to build on its
own is listed in pnpm-workspace.yaml.
A file belongs to the nearest package at or above its directory when that
package's program lists it. A file that package does not list belongs to no
package: every run from the repository root names each such file with the
programs that reached it, and so names a listed file under a directory the walk
did not enter (# gazelle:exclude, .bazelignore). A file
two programs list, a parent's and a nested package's, is the nested package's
alone, and the parent's edge to it is a dep.
An import into a source file with no indexed TypeScript target owner becomes
a direct srcs label in the consumer. This includes declarations-only
packages that generate no compile target. Gazelle follows that file's
compiler-observed imports, including transitive source and JSON inputs,
declarations, and npm type references. The compile rule enforces its supported
source formats and requires each direct npm dependency's store File to match a
nearest link on the consumer's chain or a retained source's importer chain.
Retained inputs whose observed npm lookups use different supplying importers than the consumer retain the source lookup context in source_node_modules. An empty intermediate importer adds no requirement; an ancestor importer can supply a declaration companion shadowed on the consumer's chain. Each context keeps its nearest package links and their declaration companions at their original importer paths. Different importer locations may use different versions of the same package; different store Files at the same actual link path are an error. A partial update requires the needed importer target to exist; otherwise, include its package in the Gazelle update. A kept source_node_modules must supply the required importer scopes.
Source-mode compiler owners, including explicit resolve owners, remain
dependencies while Gazelle follows their compiler-observed implementation imports
under the consuming target's configuration. Ordinary import traversal stops at
emitted compiler owners and generated outputs. A reached source kept
in this compilation's srcs still contributes its import closure. Labels use
the nearest existing Bazel package. Gazelle does not create exports, widen
visibility or include unrelated neighboring sources. A borrowed authored
TypeScript, JavaScript or declaration file also retains its nearest
package.json unless a compiler dependency supplies that original scope,
preserving package-private imports and module format. The manifest needs the
same eligibility and Bazel visibility as the file. Other runtime assets still
need an existing data owner.
Kept roots contribute the same compiler-observed closure whether discovery starts from a tsconfig or package exports. Gazelle expands direct source labels, literal native filegroup.srcs, literal alias.actual chains and declared output names from BUILD facts, normalizing each nested label in its own package while retaining the written source label. Generated outputs keep their existing producer boundary; Gazelle does not read stale generated contents to discover their imports.
A literal empty filegroup is known to contain no files. A glob, select, custom provider, non-default output group or unobserved external membership is unknown. If automatic closure requires it, Gazelle stops before writing BUILD files and names the compiler, forwarding label and unsupported attribute. Use explicit source-file labels, or keep the whole compiler rule (or ignore its package) and maintain its sources and deps manually. A kept attribute alone does not make unknown membership discoverable.
Automatic source and dependency updates also require an observed program under the selected compiler configuration. An external, generated, opaque or unavailable tsconfig does not establish an empty closure. Gazelle refuses the update before writing BUILD files, including for generated proto wrappers. Select an authored, readable configuration, or keep the whole rule (or ignore its package) and maintain its complete inputs and dependencies. Keeping every closure attribute also leaves those fields manual; keeping only tsconfig leaves them automatically managed.
An implementation import from a compiler owner requires a known emit mode to choose between that owner’s source closure and declarations. Gazelle reads absent or literal emit values; it does not evaluate expressions such as select. An unknown mode stops automatic closure before BUILD publication. Use a literal value, or keep the whole consuming rule and maintain its complete inputs and dependencies. Authored declaration boundaries use their observed closure in either mode; they do not require this mode decision.
Gazelle checks the declared compiler-owner graph for an updated binary or Node test. Each reached compiler owner must establish emit = True or expose known sources that need no TypeScript transformation. This applies inside the update too when a keep marker prevents promotion. Dependencies and forwarding labels must be literal BUILD facts; a select, variable or other expression cannot establish the closure even for an emitted owner. Gazelle refuses before writing any BUILD file. Use literal labels and emission settings, or keep the whole consuming rule and maintain its runtime closure manually. A borrowed declaration supplies typing; its runtime implementation must be explicitly included or supplied by an owner. An observed consumer outside the update adds no demand until it reaches a regenerated compiler owner.
A borrowed authored module cannot use a generated package.json as its
package scope: the checker reads the module at its source path, while the
manifest exists at its output path. Gazelle reports this conflict whether or
not a stale manifest exists in the checkout. Keep the manifest authored, or
generate the module and manifest together in the same output layout. Explicit
imports of generated JSON still use the declared output file.
BUILD output declarations are recorded before metadata drives package or emission
discovery. Generated manifests supply neither authored emission roots nor foreign
project markers, npm self-reference names or manifest dependency unions. Authored
programs under such a scope report the same layout conflict with or without a
checkout copy. Automatic membership also cannot be discovered through a generated
tsconfig.json or generated relative extends file: generation reports a conflict
before writing BUILD files. Use authored discovery metadata, or select a generated
config under another filename on an explicitly kept compiler rule. The latter
remains a declared File input; Gazelle does not inspect its checkout copy.
Compiler wildcard discovery excludes generated outputs before choosing between same-stem extensions. Native protobuf outputs participate when the provider index completes, so a checkout copy of value_pb.ts cannot hide an authored value_pb.js root or its dependencies. Gazelle then reapplies kept roots and refreshes membership from the new compiler observation. The relisted closure still uses Gazelle's native directory and exclusion facts: an excluded or unavailable required input reports a conflict before BUILD files are written.
Discovery understands literal out/outs names and literal ts_codegen.out_dir
values; implicit outputs hidden inside a macro are outside this discovery contract.
A computed explicit declaration leaves other paths in its Bazel package unknown;
Gazelle does not evaluate Starlark. Unknown metadata cannot become authored merely
because a checkout file appears. Optional discovery is omitted with a diagnostic,
without creating or withdrawing TypeScript rules. A required config, relative
extends, inherited Vitest config selection, package scope or file input reports a
conflict, as does discovery that
would withdraw an existing unkept compiler owner. Explicit producer labels and
kept or independently named targets retain their existing Bazel contracts.
A known literal producer remains usable beside an unknown declaration, and a
separate BUILD package has its own provenance boundary.
Optional application discovery also ignores generated index.html checkout
copies; authored HTML and the existing application entry names still select a
development server.
When a test's closure adds any label to srcs, Gazelle also writes test_srcs
from its original test roots. Only those roots become runner entries
and shard inputs; the complete helper closure remains available for compilation
and runtime imports. Removing the extra labels removes the
redundant test_srcs selection.
Direct TypeScript, JavaScript and imported JSON outside the consumer's directory
tree retain their paths in an all-source closure (emit = False) within the workspace. A source-mode consumer of a relocated dependency uses the shared runtime layout while preserving its TypeScript bytes and original compiler source identities.
A workspace member's npm store rejects published Files outside the member directory,
where it cannot preserve relative imports. Move those files inside the member
or import them through a separately published package; adding a compiler owner
alone does not repair the npm layout. This restriction includes foreign
passthrough declarations left at their original paths, even when a type import can be
erased from an implementation. srcs has no checker-only declaration mode:
its declarations are published interface inputs, including possible ambient
effects and references from the emitted interface. Gazelle retaining such an
input does not establish that the member can be staged in the npm store.
Emitted ts_compile and ts_test programs preserve relative module paths under a common logical source root in the compiler's output namespace. The producer records exact source/runtime and source/declaration File pairs, including generated inputs. Consumers of relocated dependencies place links to their canonical outputs; unbundled binaries execute those published Files. Checked tsgo declarations support the same mixed-source layout. Source-mode consumers follow relocated dependencies without emission; all-source closures retain original paths. See Shared Source Layout.
A workspace member can publish self-contained emitted outputs from its own borrowed sources. Its npm store rejects canonical module and declaration links selected for copying because the copy would lose the dependency's package scope and npm importer. Publish the dependency separately through npm or include its sources in the member.
Only runtime roles constrain runtime placement: a declaration's extra package.json in type_inputs remains compiler-only. Generated JavaScript, JSON and declarations retain their canonical producer Files when dependency links are placed.
A compiler-observed import of a file excluded by Gazelle stops generation
unless a declared generator owns it. # gazelle:ignore leaves a BUILD file
untouched: eligible imports from that directory still resolve, but its unimported data files do
not become inputs of an ancestor TypeScript target.
Within a package, a *.test.* or *.spec.* file is a test file, a .d.ts,
.d.mts or .d.cts a declaration, and every other listed file a library
file. A file tsgo could have listed -- .ts, .tsx, .mts, .cts, .js,
.jsx, .mjs, .cjs -- is a src only when the program lists it: one the
tsconfig's exclude leaves out is neither a src nor data. Every other regular
file under the package's tree -- not a BUILD.bazel, BUILD or package.json,
not the package's own tsconfig.json, not under a deeper package -- is a data
src of the package: a .json, a .css, an image or a fixture. Metadata-only
package manifests use package_scopes for runtime modules or type_inputs for
declarations; an explicit JSON import keeps the manifest in srcs.
A declared scalar output remains generated even when a local run leaves a copy
on disk. Gazelle leaves outputs declared by out or outs, including a
genrule's, out of ordinary compiler membership. An imported output retains its
existing compiler owner, found through srcs or an explicit gazelle:resolve.
Without one, supported
scalar files can become direct srcs; ts_codegen dependencies also supply
their runtime and declaration outputs. Gazelle does not traverse stale generated
files' imports. Scalar output selection requires the compiler's
file probes; a skipped missing output directory cannot
supply that identity. The same guide names the supported formats and declaration
emission constraints.
An eligible JavaScript twin beside an owned declaration is also a src of that
owner when they share the same stem (x.mjs beside x.d.mts): tsc drops it from the program and
resolves ./x.mjs to the declaration, so the listing never names the module
itself. A retained foreign declaration keeps its twin, with that module's runtime package scope, only when the twin's exports_files makes it visible to the consumer. Otherwise Gazelle does not infer runtime imports from an import of a declaration
or discover independently authored JavaScript hidden behind it. Such runtime
inputs need compiler-observed membership or an existing declared owner that
supplies them. Missing implementations are rejected by validation or the runtime
consumer when required.
Two programs importing each other's files is a dependency cycle between two Bazel targets, which Bazel rejects with the loop of labels when it loads them. Directories inside one program are one target, so a mutual import between them is nothing.
Where a Program Is Not¶
A directory that is not a package gets Empty for every kind Gazelle writes --
ts_compile, ts_test, ts_config, filegroup(vitest_config) and
filegroup(wrangler_config) under the names it would use -- so a rule an
earlier run left there is withdrawn, and the run names the BUILD file to delete
when one of them was in it: Gazelle cannot delete the file, and an empty BUILD
file keeps the directory a Bazel package. Two things are written there all the
same: a ts_config over a tsconfig.json a program's extends chain names
(the root's shared base), and at the repository root the filegroups over a
config the tests below name and over the wrangler config it names. A directory
inside a ts_codegen's out_dir is that target's output: nothing under it is
a source, and a BUILD file there is emptied and named the same way.
What Gazelle Writes¶
| Rule | Name | Attributes Gazelle owns |
|---|---|---|
ts_compile |
the directory's basename, root at the repository root |
srcs, deps, tsconfig, visibility |
ts_test |
<basename>_test |
srcs, deps, tsconfig, config, config_srcs, config_node_modules, workers_pool, wrangler_config, coverage_provider |
ts_config |
tsconfig |
src, deps, visibility |
ts_dev_server |
dev (dev_server when the compile target is dev) |
entry_point, plugin, node_modules, visibility |
filegroup |
vitest_config |
srcs, visibility |
filegroup |
wrangler_config |
srcs, visibility |
node_modules |
node_modules |
deps, parent, hoist, visibility |
node_modules_member |
node_modules/<member name> |
member, visibility |
npm_virtual_store |
node_modules/.pnpm |
Per package: a ts_compile when the program has a library file, holding the
library files, every owned declaration and the data files; a ts_test when it
has a test file, holding the test files and every owned declaration, and the
data files when no ts_compile is written; tsconfig = ":tsconfig" on both,
naming the ts_config over the package's own tsconfig.json. A hand-written
ts_codegen in the package's BUILD file is a dependency of every target Gazelle
writes there; Gazelle never writes one. A program listing only declaration files
writes neither target and says so under -ts_verbose. An application with non-test program
sources and a package index.html or listed main.ts[x]/app.ts[x] also gets a
default oj dev target. Gazelle updates its entry point, npm tree, plugin and
visibility, and removes a generated dev target when its application entry
disappears. It preserves server, port, host, open and # keep values
(Dev Server).
A src whose name Bazel cannot spell (a : in it) is dropped and named in the
log; a name that opens a label (@, //) is pinned to the package with a
leading :.
Per lockfile importer, package or not: a node_modules whose deps are the
importer's declared dependencies, devDependencies and
optionalDependencies as hub labels (@npm//web:react; @npm//:react for
the root's) and whose parent is the importer above's target, the
lockfile's root importer naming hoist = ":node_modules/.pnpm/node_modules"
instead; one
node_modules_member per link: entry, node_modules/<member name> over the
member's view; and, at the repository root, the lockfile's package,
npm_virtual_store(name = "node_modules/.pnpm") from @npm//:defs.bzl
(node_modules). A directory that is no importer
withdraws its node_modules and every node_modules_member; a hand-written
one there is kept under # keep.
# packages/core/BUILD.bazel -- tsconfig.json here, the sources under src/
load("@rules_typescript//ts:defs.bzl", "ts_compile", "ts_config", "ts_test")
ts_compile(
name = "core",
srcs = [
"src/index.ts",
"src/parse.ts",
],
package_scopes = ["package.json"],
tsconfig = ":tsconfig",
visibility = ["//visibility:public"],
deps = ["@npm//packages/core:zod"],
)
ts_test(
name = "core_test",
srcs = ["src/parse.test.ts"],
tsconfig = ":tsconfig",
deps = [
":core",
"@npm//packages/core:vitest",
"@npm//packages/core:zod",
],
)
ts_config(
name = "tsconfig",
src = "tsconfig.json",
visibility = ["//visibility:public"],
deps = ["//:tsconfig"],
)
A ts_test runs under the vitest config plain vitest would read for its
files: a vitest.config.*, else a vite.config.*, in vitest's own order of
extensions (.ts, .mts, .cts, .js, .mjs, .cjs), so a package that
configures vitest through vite -- a define, a plugin, a resolve.alias --
runs as pnpm vitest runs it. One beside the tests goes into config by
name. With none there, Gazelle walks up to the directory plain vitest runs
from, the nearest one holding a package.json or the repository root, and
takes the config it holds: that directory gets a public filegroup named
vitest_config over the file, and the test names it by label. A directory
between the two with no package.json is passed over, as vitest run from the
package root passes it over. The directory holding the config has to be a
package itself, or the root, for the label to reach it; otherwise the run says
so and the test gets no config. Without the config the tests run in plain
Node, so a worker's defineWorkersConfig pool becomes no pool and a dependency
that only resolves through Vite (test.server.deps.inline) fails at import
time. The config is listed by tsgo from its own directory, as vitest loads it,
and the listing is followed through first-party source modules, stopping at
declared generated-output owners and workspace-member packages. The declaring importers or member links of its npm packages go in
config_node_modules, whose links and store closures use the same runfiles
staging as data. Gazelle leaves authored data unchanged, including importer
targets, and recomputes config_node_modules when imports or config selection
change. Test-program npm deps and the test's importer chain stay their own;
first-party runtime owners remain in deps. A stale generated file's imports
contribute no config inputs or dependencies. Source modules remain the test's
config_srcs, spelled from the test's package -- a path under it, or
//<package>:<file> for a config
an ancestor package exports -- and the
rule writes each at its own path in the runfiles, where the config's relative
imports resolve (A config file). A module
outside the config's package is said and gets no entry: a config's modules are
its package's files.
A Workers-Pool Config¶
A config that imports @cloudflare/vitest-pool-workers runs its tests inside
workerd, and the pool reads the wrangler config wrangler.configPath names --
that key alone; a wrangler.jsonc beside a config naming none is nothing to
it. Gazelle reads the one string literal in the config matching
wrangler[\w.-]*\.(jsonc|json|toml) and makes the file a label in the
config's package: filegroup(name = "wrangler_config") beside vitest_config,
withdrawn when the literal or the file goes. A config naming two files, or a
file outside its package, is said and gets none. The ts_test whose config's
edges name the pool gets wrangler_config -- the filegroup's label from a
package below, the file's name from the config's own -- and, when the lockfile
declares @vitest/coverage-istanbul, coverage_provider = "istanbul" with
that package in deps in the importer's spelling; the pool refuses v8
coverage, and without the package the run says so and writes neither. The
filegroup follows the literal and is written when the package generates; the
attributes follow the pool's edge and are written with deps, from the
config's listing. A config that names a wrangler config and installs no pool
gets the filegroup and no test names it.
workers_pool is the node_modules importer or node_modules_member link from the resolved pool import,
including an import in a relative helper below the entry config. It selects
the pool whose Wrangler prepares wrangler_config and stages that pool for
the runtime config. The importers in config_node_modules remain the config's
whole npm closure, including workspace-member links. Importers of the same full
pool resolution share preparation: all runtime links remain staged, and Gazelle
selects a stable owner. Different versions or peer resolutions require separate
test targets because one Wrangler action uses one parser. Without Wrangler preparation no selection is emitted. Removing
the pool import withdraws workers_pool on the next run.
Existing hand-written tests that omit workers_pool keep their declared test
chain's pool selection; the generated explicit owner takes precedence.
The tsconfig and Its ts_config¶
Every target compiles under the package's own tsconfig.json: its lib,
types, paths, jsx and strictness. The rule reads every compiler option
from the tsconfig (where compiler options come
from), so no
attribute restates one to the compiler. The ts_config beside the file is what
makes it a label, and it declares what the rule needs from the file before any
action reads it: deps, the extends chain, and the two values that name an
output -- jsx = "preserve" when that is the chain's effective jsx
(jsx: preserve) and
module when the chain's is one tsgo emits
(The Module Format). All three
are Gazelle's to recompute on every run.
ts_config.jsx is written as "preserve" when the chain's effective jsx is
preserve, read leaf-wins as tsc reads it and inherited through extends, and
removed otherwise; no other value is written, since no other value names an
output. ts_config.module is written the same way when the chain's effective
module is one tsgo emits -- commonjs, node16, node18, nodenext,
lowercased as tsgo prints it -- and removed for an ES kind or preserve,
which oxc emits with no twin to name.
ts_config.deps is the extends chain. Gazelle reads the package's
tsconfig.json and writes a dep on the ts_config of every tsconfig.json
its extends names, one specifier or an array, each a relative path resolved
from the file. A tsconfig.json that is no program of its own gets a
ts_config in its directory because a chain names it, which is how the root's
shared base becomes //:tsconfig. A base of another name
(tsconfig.base.json) has no ts_config to name, and the run says so: declare
that dep by hand under # keep. A specifier that resolves through
node_modules ("@tsconfig/node20/tsconfig.json") is skipped with a warning,
since only configs on disk are read. Without the dep the base is no input to
the action, and tsgo reports TS5083: Cannot read file before it reaches the
sources.
# workers/proxy/test/BUILD.bazel; its tsconfig.json extends ../tsconfig.json
ts_config(
name = "tsconfig",
src = "tsconfig.json",
visibility = ["//visibility:public"],
deps = ["//workers/proxy:tsconfig"],
)
deps is Gazelle's, recomputed on every run, so a run corrects the label when
the base moves or goes away. A hand-written value needs a # keep on its line
to survive the next run (attributes Gazelle
owns).
A Declaration the tsconfig Names¶
A path-shaped compilerOptions.types entry, "./worker-configuration.d.ts" in
the form wrangler writes, names a file the program stages, and the rule reads
the entry from the tsconfig itself. The listing carries it as an edge from the
tsconfig to the file, and it resolves as any edge does: to the rule whose
srcs hold the file, or to the ts_codegen whose outs declare it. An out
that is not in the checkout is listed by no program, so Gazelle reads the entry
from the chain itself, resolved against the package's directory as tsc
resolves it, and writes the codegen into deps. Nothing else is written: no
types, no filegroup. A copy of a declared out left on disk by a local
wrangler types is no rule's src: the out is the codegen's, and the program
that lists it depends on the codegen. The codegen is named for what it writes,
never for its directory: the directory's name is the package's ts_compile,
and a hand-written rule of another kind holding that name keeps the merger
from writing the compile. The run names such a rule; rename it.
# workers/proxy/BUILD.bazel -- the ts_codegen is hand-written; Gazelle leaves it
ts_codegen(
name = "worker_types",
srcs = ["wrangler.jsonc"],
outs = ["worker-configuration.d.ts"],
args = ["--config", "wrangler.jsonc", "--out", "{out}", "--srcs", "{srcs}"],
generator = "@rules_typescript//tools/codegen:wrangler_types",
node_modules = ":node_modules",
visibility = ["//workers/proxy:__subpackages__"],
)
ts_compile(
name = "proxy",
srcs = [
"src/handler.ts",
"wrangler.jsonc",
],
tsconfig = ":tsconfig",
visibility = ["//visibility:public"],
deps = [":worker_types"],
)
Import Resolution¶
deps is written from the listing, one label per edge target. An edge is one
file reaching another: an import as written, a declare module "<name>"
augmentation of a module the program holds, a /// <reference path> or
/// <reference types> directive, a compilerOptions.types entry, the JSX
runtime a .tsx file imports under react-jsx without writing the import, the
importHelpers module. An augmentation adds no file -- its module is in the
program by an import elsewhere, or the checker reports TS2664 -- and tsgo
lists it (Augmented via) under the source-built
compiler, not the lockfile's binary.
tsgo resolved each under the program's own options --
its paths, its moduleResolution, the imports map of its package.json,
the exports maps under node_modules -- so Gazelle reads no specifier and
applies no resolution rule of its own. It maps the file tsgo landed on to a
label:
- A file under
node_modulesis an npm package: the bare specifier's package when the edge is an import and the lockfile mentions that name, else the package the path names by the segments after its lastnode_modules/(two when the first is a scope). The label is spelled through the lockfile gate. The@types/*twin of a package the lockfile mentions is the importer's to declare beside it and gets no label of its own; a@types/<name>package installed with no<name>beside it is the label,@npm//web:types_mdast. - A
/// <reference types>directive in a file undernode_modulesis the target's edge when the file it landed on is the@types/<name>package an importer at or above the target's package declares, spelled as that importer's,@npm//:types_node: TypeScript's primary lookup for the directive walksnode_modules/@typesup from the tsconfig's directory -- the chain -- before the referencing file's own directory, so that copy is the one the program loads, and the link has to be staged for the build's program to load it too. A directive the chain does not answer resolved beside the referencing package's own tree and is nothing. The directive is the edge of the rule whose files reach the referencing file, thets_compile's or thets_test's. - A workspace member imported by its name -- a bare specifier whose
package is a
link:name in the lockfile, or the manifest name of an importer -- is the name as the nearest importer at or above the package that has it resolves it: the importer's link target where it links the member,//web:node_modules/@acme/ui; the importer-scoped label where it declares the name with a version,@npm//packages/bundler-plugin:example-transformfor the bundler plugin's"example-transform": "1.1.13"beside the memberpackages/transform, since pnpm installs the published package there; where no importer above links or declares it there is no label and one line names the member, since a target resolves through its importers alone. The name of the nearestpackage.jsonabove the importing file, a subpath included, is a self-reference, which tsc resolves through that manifest'sexportsto a file of the member: a first-party file, resolved as any owned file is -- the member'sts_compilefrom ats_testor a package below it, nothing from the member's ownts_compile-- and no line. - An owned file, reached by a relative path or a
pathsalias, adds no dependency when the importing rule itself owns it. Anotherts_compileowner exportsTsInfoand remains a dependency, including within the same package; a hand-written owner under# keepanswers as a generated one does. Membership in ats_testdoes not export a library. - A file under a
ts_codegen'sout_diris the codegen, matched by the root the path sits under, deepest root first across indexed providers and observed BUILD declarations. Native providers take precedence at the same root; declared roots remain available with partial indexing or-index=false. Ordinary imports and config imports retain that generated identity, so checkout copies and their stale imports never become borrowed source inputs. - A scalar generated output keeps its declared compiler owner before and
after generation, even when excluded. Without one, supported files enter
srcsdirectly; declarations and JSON fromts_codegeninstead reach consumers through its provider. Direct TypeScript and JavaScript inputs fromts_codegenretain the producer dependency for its runtime and declaration outputs. Scalar lookup follows compiler extension precedence; see generated-output resolution. - An eligible source with no indexed target owner becomes a direct
srcslabel and contributes its compiler-observed import closure, under the source-mode restriction above. Declaration-only packages can supply these inputs too. Gazelle rejects excluded imports unless a declared generated-output owner supplies them; unsupported or unlabelable files receive a diagnostic. - The compiler's own libs (
lib.dom.d.tsand its kin: under../from the lockfile's binary, which reads them from beside itself, underbundled:///libs/from the source build, which embeds them) are nothing.
The tsgo action checks every edge against deps from the same listing
(Deps Have to Be Direct), so
a build over what Gazelle wrote has no undeclared import to report;
//tests/integration:gazelle_roundtrip_test builds Gazelle's output and pins
it.
Core Gazelle's # gazelle:resolve typescript <repository path> <label> names
the label for a first-party file and wins over every first-party case above; a
file under node_modules is the gate's alone
(# gazelle:resolve).
A ts_test's deps is the union of :<basename>, the package's ts_compile
when there is one; the edges of every file the package owns -- the test files,
the library files and the declarations, since the test runs the package's code
and needs its npm closure; the edges of its vitest config and of the modules
the config reaches; and the nearest
package.json's dependencies and devDependencies, each spelled as an edge
would be, a member's name as its link target and every other name through the
gate.
So a test carries the packages the config and the manifest name and no source
imports (vitest, a pool package, jsdom), and none of them needs a # keep.
runner and data are the owner's attributes: Gazelle writes neither, and a
hand-written value survives every run without # keep
(Runners).
Gazelle recognizes the built-in Node runner through apparent and canonical
labels, including aliases. Canonical recognition uses the repository containing
the extension: build it from your rules_typescript module dependency. A prebuilt
extension from an arbitrary repository does not establish canonical runner
ownership in another workspace.
The Lockfile Gate¶
Every npm label passes through the root pnpm-lock.yaml. An edge the lockfile
never mentions, under the specifier's package or the listed file's -- one a
nested package-lock.json installed, one a stale store supplied -- gets no
label and one line naming the importer, the specifier and the name. The hub is
built from that lockfile, so @npm//:<name> for such a package is a target
that cannot exist, and no such target fails analysis for every target in the
build where a missing dep fails one import with TS2307.
With no root lockfile at all, npm imports get no dep, said once per run.
The spelling follows the importer. The file tsgo listed carries the exact
version the importing file's own package.json resolved, and the hub declares
each importer's resolutions under the importer's directory beside the root's:
@npm//web:marked beside @npm//:marked. A name is spelled under the nearest
importer on the chain above the importing file that declares it, the root
last. A flat label fails analysis when no declared importer context selects
its store File as the nearest binding. Every ts_compile and ts_test gets
node_modules, the nearest lockfile importer's target at or above the package -- the root's for a
package under no importer -- which is that chain.
Verifying a Run¶
//tests/integration:gazelle_roundtrip_test pins four properties against a
nested workspace: the output builds, generating it twice from scratch produces
byte-identical BUILD files, bazel test //... passes on that output, and the
set of test targets is unchanged across a delete-and-regenerate. A run that
deletes a test still builds and is still idempotent, so check the test-target
set on your own repository:
bazel query 'tests(//...)' | sort > before
bazel run //:gazelle
bazel query 'tests(//...)' | sort | diff before -
Gazelle's Go language, in gazelle_ts, turns # gazelle:exclude *_test.go
into a deletion stub named <dirbase>_test, and a hand-written go_test of
that name goes with it.
Getting the Clean-Tree Diff to Empty¶
Once a repository has settled, a Gazelle run on an unmodified checkout should change nothing. Check without writing anything:
Three things commonly keep that diff non-empty on a hand-written BUILD file, and none is drift:
- Gazelle's own rendering. It writes a one-element list inline
(
deps = ["//pkg"]) and names a generated file by its producing label where you wrote the filename. Reformat the file to match. - A hand-written rule under a name Gazelle would use. In a directory that
is no package, every rule Gazelle would write is withdrawn under the names it
would use --
<dirbase>,<dirbase>_test,tsconfig,vitest_config,wrangler_config-- so a rule of yours under one of them is proposed for deletion on every run.# keepabove the rule holds it. - A hand-narrowed attribute it merges.
visibilityis a merged attribute and generated rules carry//visibility:public, so a target restricted to["//myapp:__subpackages__"]comes back public on every run. Pin it with# keep:
ts_compile(
name = "internal",
srcs = ["index.ts"],
# keep
visibility = ["//myapp:__subpackages__"],
)
# keep is Gazelle's own directive. Above an attribute it means "never
touch this value"; above a whole rule, "never touch this rule". See the
Directives Reference.
Exported Files at New Package Boundaries¶
When ordinary generation creates a child package, existing literal
exports_files entries beneath that boundary move to the canonical child
owner. Visibility and licenses remain unchanged. Consumers must use the new
child label; Gazelle does not add compatibility aliases. If required exports
are kept or have computed visibility or licenses, Gazelle refuses the update
before writing any BUILD files. Remove the keep marker or use literal
attributes before creating the child package.