4.1 Macro Design & Rule-Authoring Starlark

The first trap in advanced Starlark is that the same file extension hides two different jobs. A .bzl file can define a macro that makes BUILD files easier to write, or it can define a rule that gives Bazel a new kind of target to analyze. Both feel like "writing build logic." They are not the same kind of logic.

Use this section as a decision article, not a syntax tour. The question is always: which phase should carry the abstraction? Loading-phase code shapes declarations while Bazel is reading packages. Analysis-phase rule code defines the contract of a configured target: attributes in, providers and declared actions out. Once that line is visible, macro design, symbolic macro restrictions, finalizers, and collection choices stop looking like scattered Starlark trivia.

The Running Example: rules_glyph

Most of Level 4 draws its code from one place. rules_glyph is a small but complete teaching ruleset for a fictional compiled language — a real, buildable repository (bazel build //examples/basic:hello works) that exercises the same boundaries a production ruleset does: a public API entry point, custom providers, declared actions, a toolchain, transitions, an aspect, a module extension, tests, and release scaffolding. From here on, most code blocks are trimmed views of actual files in that repo, linked with example: references so you can open the full version instead of trusting a look-alike snippet. Its README is the reference map. These articles are the teaching path.

The Phase Test

Start with 4.1.1 Macro vs Rule Decision Framework even if you already have a favorite answer. It gives the escalation ladder: use an existing rule directly when it is clear enough, wrap existing rules in a macro when BUILD files need a better API, and write a custom rule when the abstraction needs providers, actions, outputs, runfiles, toolchains, or analyzed dependency data.

If the answer stays in loading, decide which macro shape fits. 4.1.2 Legacy Macros is the flexible baseline: a .bzl function called from a BUILD file that instantiates ordinary targets and then largely disappears from Bazel's model. It is simple and compatible, but label context, native, generated names, and bazel query --output=build become survival skills. 4.1.3 Symbolic Macros (Bazel 8+) is the stricter Bazel 8+ form: typed attrs, target namespace rules, macro-scoped visibility, and explicit select() behavior. It is structured macro design, not a half-step toward rules.

4.1.4 Rule Finalizers is the exception pattern. A finalizer is still a symbolic macro, but it runs after the other non-finalizer targets in the package have been declared. Use it for package-wide declaration policy or generated package checks. It can see loading-phase attribute representations. Do not read it as analysis, because it cannot inspect providers, configured values, actions, or toolchain resolution.

The Data-Shape Test

After the phase choice, ask what kind of data crosses the boundary. 4.1.5 depset vs list teaches the graph-shaped collection choice. A list is fine for bounded local data: this target's direct files, a few modes, a direct provider list. A depset is for transitive data that moves through providers, runfiles, action inputs, aspects, or command lines without flattening the dependency closure at every node. That collection choice becomes part of the rule contract: if a provider field really means "all files below me," publishing it as a list bakes in repeated copying where the graph wanted sharing.

4.1.6 set (Bazel 8.1+) teaches the local membership side for Bazel versions with Starlark set support. A set is useful for deduplication, membership checks, and set algebra inside one implementation, especially when code is drifting toward dictionary keys as a membership workaround. It is not a replacement for depset, because it does not preserve transitive graph sharing across dependency edges. The split is practical: set for local questions, depset for propagated dependency data, list for small ordered or bounded values.

What Goes Wrong

Most mistakes here smear two powers together. A macro that tries to inspect the resolved value of a configurable attribute is asking a loading-phase tool to know analysis-phase information. A finalizer that wants providers has the same problem. A provider that flattens every dependency into a list is treating graph-shaped data as local data. The details differ, but the error is the same: the code forgot which phase or boundary it was operating in.

Read the section in that order: 4.1.1 Macro vs Rule Decision Framework for the decision, 4.1.2 Legacy Macros and 4.1.3 Symbolic Macros (Bazel 8+) for macro API shapes, 4.1.4 Rule Finalizers only when the package-wide epilogue pattern is real. It is safe to skim finalizers on a first pass if you are writing a normal wrapper macro. Do not skip 4.1.5 depset vs list before 4.2 Custom Rules, Providers & Actions, and read 4.1.6 set (Bazel 8.1+) when local membership logic is starting to use dictionary keys as a workaround.

think

Decide: A .bzl abstraction starts by shaping BUILD declarations, but it now needs to inspect configured dependencies and publish their transitive files. Which phase boundary determines whether this is a macro or a rule, and which collection should carry the files?

Reveal

Declaration shaping belongs to a loading-phase macro. Inspecting configured dependencies and publishing analyzed data require a rule, because those operations need analysis-phase providers and ctx.

Carry the transitive files as a depset through the provider and into depset-aware action or runfiles APIs. Use a list or set only for bounded local work, and flatten the depset only at a consumer that genuinely requires a list.

key takeaway

Advanced Starlark is less about clever syntax than about respecting boundaries. Macros shape packages during loading. Rules define configured target contracts during analysis. Finalizers are a narrow loading-phase policy tool, and collection choices carry the same discipline: local data can be local, but graph-shaped data should stay graph-shaped.