|
| 1 | +# ADR-0002: Dependency Catalog |
| 2 | + |
| 3 | +**Status:** Accepted |
| 4 | +**Date:** 2026-07-11 |
| 5 | +**Supersedes:** [ADR-0001](./0001-template-dep-checker-architecture.md) |
| 6 | + |
| 7 | +## Context |
| 8 | + |
| 9 | +ADR-0001 built `tools/dep-checker` on a principle it called **real-state extraction**: |
| 10 | +instantiate each generator with an empty `VirtualProjectState`, call `Generate()`, and read |
| 11 | +back whatever `package.json` it wrote. The ADR claimed this was *"strictly accurate"* and |
| 12 | +*"can never drift"* from real output. |
| 13 | + |
| 14 | +It is neither. In production the bot produced commit `997e489` — titled |
| 15 | +`chore(deps): bump @clerk/clerk-react in templates`, containing 13 files touching ark-ui, |
| 16 | +better-auth, vitest, next, react, prettier, vite, posthog and react-router, and **zero clerk |
| 17 | +changes**. It was merged to `main`. |
| 18 | + |
| 19 | +Three flaws, each fatal on its own. |
| 20 | + |
| 21 | +**1. Reading back an empty-Answers run is not real state.** Generators branch on |
| 22 | +`ctx.Answers`; 15 of them do. Calling `Generate()` with `Answers: map[string]interface{}{}` |
| 23 | +executes one arbitrary branch. `auth_clerk_frontend` scaffolds `@clerk/clerk-react` when no |
| 24 | +framework is chosen and `@clerk/nextjs` when it is — so `@clerk/nextjs ^7.4.2` was **never |
| 25 | +checked once** in the tool's lifetime. The scan did not observe real state; it observed one |
| 26 | +default slice of it. |
| 27 | + |
| 28 | +**2. Scanning and patching disagreed about what a dependency is.** The scanner read *values* |
| 29 | +out of generated JSON. The patcher had to write back to a *Go string literal in source*, which |
| 30 | +it located by regex (`patch.go:96`). Nothing guarantees a value observed in the output exists |
| 31 | +as a literal in the source. `auth_clerk_frontend` holds its version in a variable |
| 32 | +(`version := "^5.61.3"`), so the patch could never match, and errored every run. The tool |
| 33 | +could see a dependency it was structurally incapable of patching. |
| 34 | + |
| 35 | +**3. Failure was not contained.** The patch error set `PATCH_FAILED`, broke the loop, and ran |
| 36 | +`git checkout main` **without resetting the working tree** — leaving every generator patched |
| 37 | +before clerk dirty on disk. `git add generators/` in the next iteration swept them into an |
| 38 | +unrelated commit. That is `997e489`. |
| 39 | + |
| 40 | +The `Deprecated:` fields, the ecosystem table, and the registry checkers all worked. The |
| 41 | +failure was entirely in *where versions live*. |
| 42 | + |
| 43 | +Two further defects share the same root. Versions living in 40 hand-written files means the |
| 44 | +same package can be pinned twice at different versions, and it was: `vitest` sat at `^4.1.7` |
| 45 | +and `^4.1.8`, `bcryptjs` at `^2.4.3` and `^3.0.3` — drift the bot itself caused by dying |
| 46 | +mid-run. And `dep-checker` reimplemented semver comparison from scratch |
| 47 | +(`checkers.go:46-105`), getting zero-major wrong: `^0.45.2 → 0.46.0` is a **breaking** change |
| 48 | +under semver, but it was classified `"minor"` and batched as safe. `drizzle-orm` is pinned at |
| 49 | +`^0.45.2`. |
| 50 | + |
| 51 | +## Decision |
| 52 | + |
| 53 | +**Pinned versions move out of generators and into a Catalog: `internal/deps/`.** |
| 54 | + |
| 55 | +There is exactly one Pin per dependency, repo-wide. Generators name the packages they need; |
| 56 | +the Catalog supplies the version. |
| 57 | + |
| 58 | +```go |
| 59 | +// internal/deps/npm.go — machine-owned; the only file the bot rewrites |
| 60 | +var npm = map[string]string{ |
| 61 | + "@clerk/clerk-react": "^5.61.3", |
| 62 | + "@clerk/nextjs": "^7.4.2", |
| 63 | + "vitest": "^4.1.8", |
| 64 | +} |
| 65 | + |
| 66 | +// generators/react_app/generator.go |
| 67 | +"dependencies": deps.NPM("react", "react-dom"), |
| 68 | +"devDependencies": deps.NPM("vite", "@types/react"), |
| 69 | +``` |
| 70 | + |
| 71 | +Ecosystem becomes a property the Pin **declares**, not something inferred from which file a |
| 72 | +generator happened to write. Adding Rust means adding a `Registry` implementation, not |
| 73 | +teaching a scanner a new filename. |
| 74 | + |
| 75 | +This collapses the failure modes rather than patching them: |
| 76 | + |
| 77 | +- **Scan** is a Go import of the Catalog. No `Generate()`, no Answers, no branches to be blind |
| 78 | + to. `@clerk/nextjs` becomes visible because a Catalog has no `if` statements. |
| 79 | +- **Patch** rewrites one machine-owned file with one canonical shape. The value the scanner |
| 80 | + read *is* the literal the patcher writes. The reverse-mapping problem ceases to exist. |
| 81 | +- **Divergent pins become unrepresentable.** One key, one version. |
| 82 | +- **Classification** delegates to `internal/versioning`, which already implements caret and |
| 83 | + zero-major correctly and is tested (`TestConstraint_Caret_ZeroMajor`). A Pin update is |
| 84 | + low-risk **iff the new version satisfies the existing Pin's constraint** — which is what the |
| 85 | + caret already means. `^0.45.2` does not permit `0.46.0`, so Drizzle is correctly treated as |
| 86 | + breaking, with no special case. |
| 87 | + |
| 88 | +### Proposing changes |
| 89 | + |
| 90 | +| Kind | Vehicle | Ownership | |
| 91 | +|---|---|---| |
| 92 | +| Satisfies the existing constraint | **Rollup** — one batched PR, all packages | Bot. Force-rebuilt every run. Excludes any package holding an open dep PR of its own. | |
| 93 | +| Breaks the existing constraint | **Migration** — one PR per package | Human, from the moment it is raised. Bot never touches it again. | |
| 94 | +| Deprecated | Issue only, **never a PR** | Human. | |
| 95 | + |
| 96 | +A deprecated package is excluded from the Rollup entirely. Every deprecation in the repo today |
| 97 | +(`@clerk/clerk-react` → `@clerk/react`, `@vercel/flags` → `flags`, `@types/bcryptjs` → delete, |
| 98 | +`plausible-tracker` → replace) needs a **rename or a removal**. ADR-0001's rule — *"open the |
| 99 | +version-bump PR if a newer version exists under the same name"* — resolves none of them: |
| 100 | +bumping to the latest *deprecated* version fixes nothing. |
| 101 | + |
| 102 | +A Rollup whose CI goes red is not bisected by the bot. A human ejects the offending package |
| 103 | +into its own Migration PR, and the next Rollup rebuild drops it via the same open-PR check |
| 104 | +that protects Migrations. One rule, both cases. |
| 105 | + |
| 106 | +### Manifest bumps are derived at release, not stored in the PR |
| 107 | + |
| 108 | +ADR-0001 bumped `manifest.go` inside the dep PR. That stores a **relative** operation |
| 109 | +(`0.8.0 → 0.9.0`) computed against `main` at PR-creation time and applies it at *merge* time, |
| 110 | +when `main` has moved. With several dep PRs open, the resulting version is a function of merge |
| 111 | +order. Long-lived Migrations make this unsurvivable: a React Migration open for two months |
| 112 | +collides with every weekly Rollup that touches the same manifest. |
| 113 | + |
| 114 | +Manifest bumps therefore move to **release**, derived from the final state: |
| 115 | + |
| 116 | +``` |
| 117 | +dot gen-bump # every generator whose Fingerprint moved since the last tag, bumped once |
| 118 | +``` |
| 119 | + |
| 120 | +Merge order becomes provably irrelevant, because the bump is no longer a stored delta. A |
| 121 | +Migration PR touches only its own Catalog line, so it rebases cleanly for months — the Rollup |
| 122 | +edits *other lines of the same map*. |
| 123 | + |
| 124 | +### Fingerprints |
| 125 | + |
| 126 | +A **Fingerprint** is a hash of a generator's Contribution — the files it introduces — taken |
| 127 | +across every fixture that invokes it, computed by diffing `VirtualProjectState` before and |
| 128 | +after the generator runs. It is entirely in-memory: no `pnpm install`, milliseconds. |
| 129 | + |
| 130 | +The Fingerprint answers the only question that matters: *did what this generator scaffolds |
| 131 | +actually change?* Reformatting source does not move it. Editing a template does. Moving a Pin |
| 132 | +the generator names does too. |
| 133 | + |
| 134 | +Three CI rules follow: |
| 135 | + |
| 136 | +1. **PR-time** — if the diff touches `generators/**` and a Fingerprint moved, `manifest.go` |
| 137 | + must be bumped and the doc's version row must match. |
| 138 | +2. **Always** — every generator's doc version row equals its `manifest.go` Version. |
| 139 | +3. **Release-time** — every generator whose Fingerprint moved since the last tag has a bumped |
| 140 | + manifest and a synced doc. |
| 141 | + |
| 142 | +Rule 1 deliberately **does not fire** on a diff touching only `internal/deps/**`. Enforcing a |
| 143 | +manifest bump inside a dep PR is precisely the merge-order bug this ADR exists to remove. |
| 144 | + |
| 145 | +## Alternatives considered |
| 146 | + |
| 147 | +| Option | Reason rejected | |
| 148 | +|---|---| |
| 149 | +| Keep inline literals; ban variable-held versions with a lint | Fixes the clerk crash, but leaves the scanner blind to conditional deps and the regex patcher load-bearing. Treats the symptom. | |
| 150 | +| Run generators under many Answer permutations to flush out hidden deps | Machinery to recover information a Catalog never loses. Enumerating permutations is a combinatorial guess; a Catalog is a list. | |
| 151 | +| Per-generator `Dependencies` field in `manifest.go` | ADR-0001 rejected this as *"a second source of truth that can drift"* — correctly, but it drew the wrong conclusion. Drift came from versions living in **40** places. The fix is **one** place, not one-per-generator. | |
| 152 | +| Bot bisects a red Rollup automatically | ~5 extra full `test-flows` runs (37 fixtures, real `pnpm install`) per failure, to tell a human what the CI log already says. | |
| 153 | +| Allow multiple pinned variants (`bcryptjs@2`, `bcryptjs@3`) | All four divergent pins in the repo today are bugs, not intent. No legacy templates are planned. Add the escape hatch when a use for it exists. | |
| 154 | + |
| 155 | +## Consequences |
| 156 | + |
| 157 | +- **Generators no longer contain versions.** A contributor adding a package adds a Pin to the |
| 158 | + Catalog and names it in the generator. `deps.NPM()` panics on an unknown name, so a typo |
| 159 | + fails at generate time and `test-flows` catches it. |
| 160 | +- **One version per package, repo-wide.** If a genuine need to pin an old major appears, it |
| 161 | + requires a deliberate new Catalog key — it cannot happen by accident, which is how the |
| 162 | + current `vitest` and `bcryptjs` divergence happened. |
| 163 | +- **Between releases, `main` carries generators whose Pin moved but whose manifest has not.** |
| 164 | + This is the price of order-proof bumps. No user observes it: users consume released |
| 165 | + binaries, and `doctor` compares against the released manifest. |
| 166 | +- **The bot never runs `test-flows`.** The PR's own CI already does. ADR-0001's promised gate |
| 167 | + (run `test-flows`, open an issue on failure) would double the most expensive job in CI for |
| 168 | + no added signal. It was never implemented, and it should not be. |
| 169 | +- **Registry traffic drops to one request per package**, instead of one per |
| 170 | + (generator, package) pair. |
| 171 | +- **Cargo, Maven and Go are dead code today** — no generator writes `Cargo.toml`, `pom.xml`, or |
| 172 | + `go.mod`. The `Registry` interface keeps them cheap to revive; the unreachable extractors go. |
| 173 | +- `tools/dep-checker` keeps its own semver logic **nowhere**. `parseSemver`, `isOutdated`, |
| 174 | + `updateType`, `stripConstraintPrefix` and `depBumpIsMajor` are deleted in favour of |
| 175 | + `internal/versioning`. |
0 commit comments