Repository navigation
Stacks
Bluetemberg routes content along two orthogonal axes:
- Profile (the role you build in) — frontend, backend, full-stack. See Profiles.
-
Stack (the technology you build with, and at which version) —
react,nextjs,payload, … This page covers the stack axis and how version-aware routing works.
The goal: a rule written for Payload v3 should never reach a Payload v2 project, and a React 14 rule and a React 15 rule can coexist in one pack with the right one served to each project — automatically, and visibly.
A stack is an open-vocabulary name (react). A version is a constraint layered on top via a semver range. There is no react-14 stack — there is react plus a range like >=14 <15.
This appears in three places:
| Where | Field | Shape | Purpose |
|---|---|---|---|
| A project | stacks config |
{ "react": "15.2.0" | "auto" } |
Declares/pins what the project builds on |
| A rule, guardrail, agent, or skill | stacks: frontmatter |
{ react: ">=15 <16" } |
Declares which versions the content applies to |
| A catalog pack |
stacks (name-only) |
["react"] → { react: "*" }
|
Coarse name-level routing; a file's own range refines it |
A catalog pack's stacks only apply to that pack's own files — ones found in a source dir whose package.json names the pack (installed packs, npm or local-path extends). A project's own llm/rules/naming.md is never withheld because some React pack also ships a naming rule, and it contributes no coverage for React either.
A file applies iff every named stack is present in the project AND its detected version satisfies the range. An absent/empty constraint is stack-agnostic and always applies — so a project with no stack-tagged content behaves exactly as a stackless project always did.
bluetemberg-rules-react/
rules/
effects-r14.md → stacks: { react: ">=14 <15" } # "use the old API"
effects-r15.md → stacks: { react: ">=15 <16" } # "use the new API"
naming.md → (no stacks key) # universal — both get it
A React 14 project receives effects-r14 + naming; a React 15 project receives effects-r15 + naming. Both variants are maintained and valid; the gate serves the right one. A range is a full semver expression, so a single rule can also span versions explicitly: react: "14.x || 16.x".
When a stack is not pinned in config (or is set to "auto"), Bluetemberg resolves its version per sync. Each detected stack carries a confidence and a source, in precedence order:
| Precedence | Source | Confidence | Notes |
|---|---|---|---|
| 1 |
bluetemberg.config.json stacks (pinned, not "auto") |
declared |
Asserted as fact — cheap and deterministic |
| 2 | node_modules/<pkg>/package.json |
exact |
The installed truth |
| 3 | package-lock.json |
exact |
Resolved lockfile version |
| 4 |
package.json dependency range (coerced) |
coerced |
Low confidence — a range like ^15 coerces to its lowest anchor |
Steps 2 and 3 follow Node's own module resolution. In a workspace, a package whose dependency was hoisted resolves it from the workspace root's node_modules (source shown as ../../node_modules), and from the root package-lock.json, where an entry nested under the package's own path (packages/legacy/node_modules/react) wins over the hoisted one. The climb stops at the nearest repo/workspace boundary — a directory with .git, a lockfile, pnpm-workspace.yaml, or a package.json with workspaces — so detection never reads an unrelated parent directory. The version reported is the one the package actually runs, not a coerced guess.
Prereleases are matched as the release they preview: 19.0.0-rc.1 is treated as 19.0.0. Plain semver sorts an RC below 19.0.0, which would give a team trying the next major the previous major's rules (>=18 <19.0.0 matches) and not the new ones (>=19.0.0 does not).
Inspect what the engine resolves with bluetemberg detect.
flowchart TD
A[sync starts] --> B[detect stacks<br/>config / node_modules / lockfile / package.json]
B --> C{file has stacks: range?}
C -->|no| D[owning pack's catalog<br/>name-only gate, if any]
C -->|yes| E{every stack present<br/>AND version in range?}
D --> E
E -->|yes| F[apply file]
E -->|no| G[hard-exclude<br/>+ log the reason]
B --> H[log: Detected stacks: …]
E -->|low-confidence source| I[apply + warn]
Filtering is a trust signal, not a silent side effect. A normal (non---silent) sync reports exactly what it matched against and what it withheld:
Detected stacks: nextjs@15.2.0 (node_modules), payload@3.4.1 (config)
Rules: 12 source files
10 applied · 2 filtered out by version
- payload-collections: payload >=3 <4 (you're on 2.5.0)
- next-rsc: nextjs not present
Guardrails: 1 filtered out by version
- payload-2-only: payload >=2 <3 (you're on 3.4.1)
Three guarantees back this up — guidance is never silently dropped:
-
A typo'd range warns instead of vanishing. An invalid range (
react: "15..16") would otherwise be dropped, silently widening the file to apply everywhere. Instead:A valid range that no version can satisfy (WARN: rules/effects-r15.md: invalid stack range(s) react: "15..16" — fix the range; until then the file is mis-gatedpayload: ">=4 <3") warns the same way, tagged(matches no version)— otherwise it excludes the file forever. -
A low-confidence match warns — once per stack. A version coerced from a
package.jsonrange still applies, but tells you to pin it. One line per stack, not per file, so the signal isn't drowned:WARN: payload@3.4.0 is a low-confidence detection (coerced, from package.json) gating version-tagged files (first: rules/payload-collections.md) — pin a version in bluetemberg.config.json for precision -
Two sources shipping the same file warns. When two packs (or
extendsentries) both shiprules/testing.md, the higher-priority one wins and the other copy — with its ownstacks:range — is ignored. Sync names both. A local copy is the deliberate override and stays quiet. - Every exclusion lists its reason (rules, guardrails, agents, and skills alike), so you can audit that wrong-version content was correctly withheld — hidden, not wrong-here.
-
The gate applies on every platform, Codex included. A withheld rule stays out of the managed rules block in
AGENTS.md, and a withheld agent gets no.codex/agents/*.toml. Claude Code and Copilot readAGENTS.mdtoo, so a rule that leaked into that block would reach them as well.
Gating asks "does this file apply to my project?". Coverage asks the inverse — "is there guidance for what I build with?" — and answers it from the same declared ranges, so the two can never disagree.
The covered ranges for a stack are the stacks: ranges declared by the guidance actually available (the project's own source dir, extends entries, installed packs, external sources), resolved with the same precedence the gate uses: a file's own range wins, else the catalog pack-level tag (*, name-level), else stack-agnostic (contributes nothing). A stack with any declared range never falls back to * — otherwise the wildcard would satisfy every version and flatten coverage back into "does a react pack exist?".
A query for (stack, version) therefore has four outcomes:
| Outcome | Meaning |
|---|---|
covered (matched range) |
A declared range satisfies the version — e.g. react: ">=18 <19" covers 18.3.1
|
covered (name-level, *) |
A catalogued pack targets the stack, but nothing declares a version range for it — real guidance, not version guidance, so it ranks in its own weakCoverage tier |
gap: version-uncovered
|
Guidance for the stack exists, but no covered range satisfies this version — "we ship React 18 rules; this repo is on 19" |
gap: no-coverage
|
Nothing targets this stack at all |
Note the third row carefully: a single stack-agnostic file inside a pack the catalog tags ["react"] covers every React version. Coverage says so honestly — that file really does apply — but a version era whose only guidance is generic is not the same as one with a rule written for it, so those buckets are ranked separately rather than folded into "covered".
Read it with bluetemberg detect and bluetemberg coverage for one project, or bluetemberg scan-org to rank across N repos by how many are affected: gaps first (nothing covers them), then weakCoverage (covered name-level only).
When a stack's guidance differs across versions, there are two established ways to model it. Bluetemberg follows both, at different scales — the same pattern TypeScript and DefinitelyTyped use:
| Tier | Pattern | Industry analog | Bluetemberg |
|---|---|---|---|
| 1 | One artifact, version-keyed selector — ship all variants, the consumer's version picks | TS typesVersions; browserslist + @babel/preset-env
|
Per-rule stacks: range — available now |
| 2 | Separate published version lines — fork the package when the delta is too big for one |
@types/react@16 vs @17 vs @18; react-router@5 vs @6
|
stackPeerDependencies (pack version forks per stack) — roadmap
|
The switch rule both ecosystems use: use the in-artifact selector (Tier 1) until the divergence breaks it, then fork the version line (Tier 2). For the React 14/15 case, Tier 1 is almost always sufficient — keep both rule sets in one pack and let the range gate serve each. Reach for Tier 2 only when one pack genuinely cannot hold both cleanly.
Most AI-rules tools bake the framework version into prose ("you are an expert in React 19…"). Bluetemberg's structured, range-based gating — plus the audible output above — is the differentiator. Keep version a constraint, never part of a stack name.
Version-aware routing is maturing. Tracked in issue #212:
-
Monorepos: solved per-package, not per-version-within-a-package. A monorepo with
react@14in one package andreact@15in another is handled —bluetemberg syncfans out and gates each package against its own detected stacks. What's still not expressible is two versions of one stack inside a single package (the detection model resolves one version per stack name per directory). -
Cross-stack matching is AND-only. "applies to A≥3 or B≥2" across two different stacks is not expressible (OR within one stack via
||is). -
The detection table is built in. Stacks outside the default table (
nextjs,react,payload,angular,vue,svelte,astro,solid,tailwind,drizzle,trpc) are detected only when their stack name equals the npm package name, or when pinned in config. -
Uninstalled pnpm/yarn projects fall back to a coerced range. Once installed,
node_modulesgives an exact version for every package manager; without an install, onlypackage-lock.jsonis read.
-
Configuration →
stacks— declaring/pinning project stacks -
Writing Rules →
stacks— the frontmatter constraint -
Commands →
bluetemberg detect— inspect resolved versions - Guardrails — the same gate applies to guardrails