Skip to content
github-actions[bot] edited this page Oct 1, 2026 · 6 revisions

Stacks & Versioning

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.

The model: stack name + range, never a versioned stack name

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.

React 14 and React 15, both valid — in one pack

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".

Detection: where a version comes from, and how much we trust it

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]
Loading

Audible filtering — you can always see what the gate did

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:
    WARN: rules/effects-r15.md: invalid stack range(s) react: "15..16" — fix the range; until then the file is mis-gated
    
    A valid range that no version can satisfy (payload: ">=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.json range 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 extends entries) both ship rules/testing.md, the higher-priority one wins and the other copy — with its own stacks: 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 read AGENTS.md too, so a rule that leaked into that block would reach them as well.

Coverage: the same model, asked backwards

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).

Versioning strategy: two tiers, mirroring the ecosystem

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.

Known limits

Version-aware routing is maturing. Tracked in issue #212:

  • Monorepos: solved per-package, not per-version-within-a-package. A monorepo with react@14 in one package and react@15 in another is handled — bluetemberg sync fans 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_modules gives an exact version for every package manager; without an install, only package-lock.json is read.

See also

Clone this wiki locally