How target visibility is implemented and enforced¶
Visibility restricts which targets are allowed to depend on a given target. Blade stores each target's permissible-callers set as a small collection of normalized patterns and checks every dep edge against it during the dependency-analysis phase — once per build, not at runtime.
| File | Role |
|---|---|
src/blade/target.py |
_init_visibility, _check_visibility, _match_visibility |
src/blade/target_pattern.py |
normalize, match for pattern strings |
src/blade/dependency_analyzer.py |
Driving the per-edge check during analysis |
src/blade/config.py |
global_config.default_visibility, legacy_public_targets |
1. Attribute shape and defaults¶
visibility accepts:
'PUBLIC'or['PUBLIC']— visible globally.- An explicit list of target patterns:
['//path:target', ':sibling', '//path:*', '//path/...']. []— private to the BUILD file the target was defined in.None(omitted) — falls back to defaults, see below.
When visibility is omitted, the resolution is:
- If the target's key appears in
global_config.legacy_public_targets, treat as{'PUBLIC'}. This is the migration ramp for projects that used to default everything to public. - Otherwise use
global_config.default_visibility, which itself defaults to an empty set (private). Blade 2.0+ made private the default; thelegacy_public_targetslist is the explicit knob for keeping the previous behaviour on a per-target basis while the project is being audited.
target._visibility is the normalized set actually consulted by the
check. _visibility_is_default records whether the user wrote
visibility= explicitly — used only to make diagnostics clearer
("defaults to private, see the docs").
2. Normalization¶
_init_visibility() walks each pattern through
target_pattern.normalize(v, self.path):
'//foo:bar'→'foo:bar'':sibling'→'<current_path>:sibling'(the current BUILD's package)'//foo:*'→'foo:*'(every target infoo/)'//foo/...'→'foo:...'(every target infoo/recursively)
'PUBLIC' is treated as a sentinel and stored as-is; no other
normalization is applied. The result is a set[str] — small,
hashable, and cheap to test against.
3. Enforcement: when and where¶
The check runs once per build, during dependency_analyzer.analyze_deps(),
right after deps are expanded. For each direct edge A -> B,
_check_visibility(A, B) evaluates _match_visibility(A, B):
- Same package? If
A.path == B.path, allow. Targets sharing a BUILD file are always visible to each other — that's the smallest unit of "I clearly know what I'm doing." 'PUBLIC'in B's set? Allow.- Exact key in B's set? (
A.key in B._visibility) Allow. - Pattern match? Iterate patterns;
target_pattern.match(A.key, p)handles:*and:...wildcards. Match → allow.
If none match, the check reports:
<source location of A>: error: <A>: Not allowed to depend on "//<B>"
because of its visibility,
<source location of B>: info: which is declared here
If B's visibility defaulted to private, the diagnostic adds the "no explicit visibility declaration, defaults to private, see the docs" hint, since that's the case where a user is most likely to be confused about why something they wrote is hidden.
The cost is O(deps × patterns). Visibility sets are tiny in practice (usually one to three patterns) and matching is set-membership plus a short iteration — no caching is needed.
4. Special cases and bypasses¶
- Same-package targets bypass visibility entirely. This is the
rule that lets
cc_testreach a sibling library's private internals without per-test exemptions — they're in the same BUILD, so the check is effectively a no-op. - System libraries (
#dl,#pthread, ...) are constructed withvisibility=['PUBLIC']hard-coded, so they're always reachable. - Implicit dependencies (gtest auto-injected into
cc_test, SCM stamp, ...) are added through_add_implicit_libraryand go into the normal deps list. They are not bypassed; the implicit dep library still has to have appropriate visibility, otherwise the injection would silently fail every build. In practice these libs arePUBLICby convention. - Prebuilt and foreign cc libraries use the standard mechanism — no special path.
- Unloaded targets are not checked. Lazy BUILD loading means a workspace can have visibility-restricted targets that never get examined because nothing in the requested set reaches them. This is by design: the analysis only runs on the closure of what's being built.
5. Implementation details and UX optimizations¶
- Private by default since 2.0, plus a
legacy_public_targetsramp. This is the kind of change you can't push to a large monorepo overnight; the ramp lets a project flip the default per-target as it audits, rather than having to convert everything at once. ':...'recursive patterns are evaluated by string match, not by walking the target tree. They're cheap (one prefix comparison) and do the right thing because target keys are canonicalized to<path>:<name>early.- Diagnostics carry both endpoints. Failures point at A's BUILD line (where the dep was declared) and at B's BUILD line (where the visibility was declared). Users don't have to bounce between files to figure out what's wrong.
- No memoization of match results. The check runs once per analysis
phase and the patterns are simple, so caching would add code without
saving time. If the suite grew, the cache would be a one-liner over
(A.key, frozenset(B._visibility)). - Same-package bypass is the most-used implicit rule. It is why a
lot of BUILD files have no
visibility=at all: the targets that need to talk to each other are siblings, and the same-package rule makes it work without any declaration. Visibility only comes into play when an outside package wants in. - Visibility is enforced, not used for filtering. Blade does not
drop a forbidden dep from the graph; it reports an error and stops.
This is intentional — silently elide would mask actual bugs (a typo
in
depslooking like "the lib is hidden from me").