Rule module structure
Rules expose a small, directory-based public API. A workspace author should need one import for each tool or capability they select, and that import should make clear what it enables.
Public entrypoints
Every user-selectable rule, toolchain, or workflow lives in a named directory
whose index.js is its public import path:
Illustrative example
rules/
js/
index.js # JavaScript targets and public JavaScript declarations
biome/
index.js # Biome formatting integration
toolchain.js # private implementation
The parent rules/ directory is a namespace, not a tool. Do not add a catch-all
rules/index.js, and do not make a parent entrypoint silently enable every
child tool. Import the narrowest directory that represents the capability being
selected:
import "//rules/js";
import "//rules/js/biome";
import "//rules/workflows/fmt";
index.js owns the public contract. It exports declarations that belong in a
workspace or BUILD.js file, such as rule factories, toolchain factories,
configuration schemas, and intentional registration side effects. Its
module comment should say what importing it provides. Keep its export list
small; an exported helper is part of the supported user API.
Independently selectable facets
One case does not fit a single index.js: a tool whose capabilities are
selected separately. It gets one public entrypoint per facet, inside the tool's
directory. Ruff is the case — //rules/python/ruff/fmt enables formatting and
//rules/python/ruff/lint enables linting, so a workspace can take one without
the other:
import "//rules/python";
import "//rules/python/ruff/lint";
There is no rules/python/ruff/index.js, because enabling both facets at once
is not itself a capability a user selects.
Reach for this only when the facets are genuinely independent. A shared
toolchain, or a split that is merely an implementation detail, belongs behind
one index.js. Everything else here applies unchanged: the facet module is
public, its siblings are private, and a consumer imports the facet it selected
rather than a helper filename.
Private implementation
Put implementation modules below the entrypoint's directory and import them only from rule implementation code or focused tests. Helpers may contain task construction, source discovery, lockfile handling, or platform details, but users must not need their filenames to configure a workspace.
Create a child directory only when it is independently selected or configured
by users, such as c/cmake, js/biome, or rust/clippy. That child gets its
own index.js. Do not create directories merely to split private helpers.
Test-only support trees are not public tools. Keep them unimportable from
workspace and BUILD.js files, document that status in their module comments,
and avoid adding a public entrypoint unless a real user capability emerges.
Consumer rules
These consumers must import public entrypoints only — a directory's index.js,
or one of its facet modules as described above:
imp.workspace.jsand generated output fromimp init;- user
BUILD.jsfiles and repository examples; - user guides, rule
DOC.mdfiles, and documentation build definitions.
Rule implementation files and focused unit tests may import a private helper when that is necessary to test or compose its implementation. Such imports are not examples of the user API and must not be copied into documentation.
The public imp support APIs follow the same rule. Use
//rules/imp/test, //rules/imp/native-tool, //rules/imp/generate,
//rules/imp/mode, //rules/imp/archive, //rules/imp/lockfile, and
//rules/imp/self-tool for their respective capabilities. rulesTest() is
the selectable TEST graph root; the other modules provide graph tools,
workspace configuration, or ordinary rule-author helpers. nativeTool()
returns a lazy graph handle: the host PATH lookup happens only when selected
work consumes it. nativeToolSpec() resolves that handle into the tool-spec
shape exec.action()'s legacy-tool-spec bridge accepts (graph_core.js's
addTool) — still load-bearing (e.g. rules/python/source.js's
pythonSourceRunSpec() resolving pythonSources({ deps })'s nativeTool()
entries), not a deprecated shim. rules/rust/kache's build-cache role and
rules/c/mold's Odin-linker role used to reach this bridge dynamically via
productFor(handle, ROLE); as of #148 both are plain, statically-imported
function calls instead.
When moving a public module, update all first-party consumers in the same change. Remove the old deep import rather than leaving a compatibility shim: the canonical directory path is the only supported path.
Registration and migration checklist
Loading an entrypoint must preserve every configuration schema, default toolchain, and goal implementation that its public capability requires. Avoid cycles by keeping shared rule factories in the parent entrypoint and having a selected child entrypoint register only its own.
For each migration:
- Move the public module into its named directory and make
index.jsthe public surface. - Repoint workspace,
BUILD.js, initializer, example, and documentation imports to the directory path. - Update path-sensitive loader, API-reference, and registration tests.
- Remove the old path, then verify the canonical import both loads and registers what its capability requires.