feat: derive the version from each record's declared impact#125
Open
REPPL wants to merge 2 commits into
Open
Conversation
Phase 1 of the derived-versioning programme (itd-73 / spc-10). The version stops being a number a human picks and becomes a fact computed from what shipped. THE FIELD `impact` is new frontmatter on intents and issues, enum additive|breaking|fix| internal, set explicitly — there is no default. The earlier "issues default to fix" rule was wrong: it under-bumped genuinely feature-adding issues (iss-57 added a --source value, iss-47 added the generated CLI reference; both were filed under "### Added"), and the surface guardrail only ever backstops breaks, never additive under-bumps. `internal` is excluded from the changelog and drives no bump. Intents are never `internal` — a press-release-first intent is user-facing by definition. THE GATES Two blocker lints, wired into the real record-lint run via .abcd/record-lint.json: intent_impact_valid (no intent sits in shipped/ without a valid non-internal impact) and issue_impact_valid (no issue sits in resolved/ without a valid one). Both reuse the existing tree scanners — each was split into a scan half and a validate half, so the new rules share one traversal instead of adding a third. THE DERIVATION DeriveNext is a pure function over launch.Semver, including the pre-1.0 row where breaking bumps the MINOR. Nothing can derive 1.0.0; the first major is an explicit override. The base version is the newest git TAG, not the CHANGELOG heading — auto-release.yml creates the tag after the ship PR merges, so in that window the heading and the tag describe different releases. A heading ahead of the tag is a release in flight and refuses rather than deriving against a mismatched base. The cut set is a tree diff of end states (git ls-tree at the tag vs at HEAD), not a log walk of moves, which is what makes it immune to squash and rebase merges — the phase's declared STOP condition, pinned by a test that runs both histories. Removals travel through the cut so a supersession surfaces as a Removed line. THE BACK-FILL All 5 shipped intents and all 57 resolved issues carry an impact. The lint gates the whole resolved/ bucket, so the back-fill covers it rather than only the 17 records in the current cut. Values are agent-proposed from each record's own content and cross-checked against the changelog section the work was filed under; the maintainer confirms them in review. Assisted-by: Claude:claude-opus-4-8
CI's `check` job checks out at the default depth with `fetch-tags: false`, so
the release tags are simply absent there. TestDeriveOnThisRepo asserted "this
repo has release tags; the anchor must resolve one" and failed the job on both
runners — an assertion about the checkout, not about the code.
Derive's behaviour is correct and unchanged: with no tag it refuses fail-closed
("no release tag found — a cut needs an immutable base"), which is exactly what
a cut attempted from a tagless clone should do. That refusal is pinned
deterministically by TestDeriveRefusalKind over a fixture repo, so skipping the
opportunistic real-repo test loses no coverage of the behaviour.
Reproduced against a `git clone --no-tags` of this branch: the test failed with
CI's exact message before the change and skips after it, with the full package
green in that clone. In a checkout that does carry the tags it still runs and
reports base=v0.3.0 bump=breaking next=v0.4.0 added=17.
Assisted-by: Claude:claude-opus-4-8
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Phase 1 of the derived-versioning + auto-changelog + plugin-distribution programme
(plan:
.abcd/development/plans/2026-07-21-derived-version-and-changelog.md; spec: spc-10).Second of five stacked PRs. Based on
feat/itd-73-p0-plan-intents(#123) — GitHubretargets it to
mainonce #123 merges.It works end to end on this repo
Base read from the newest git tag, 17 records in the cut (exactly the set resolved since
v0.3.0), and the bump decided by
iss-51.What lands
The
impactfield — frontmatter on intents and issues, enumadditive|breaking|fix|internal, explicit, with no default. The plan's outcome 8 isload-bearing here: the earlier "issues default to
fix" rule under-bumped genuinelyfeature-adding issues (iss-57 added a
--sourcevalue, iss-47 added the generated CLIreference — both filed under
### Added), and the surface guardrail only ever backstopsbreaks, never additive under-bumps.
internalis excluded from the changelog and drivesno bump; intents are never
internal.Two blocker lints, wired into the real
record-lintrun via.abcd/record-lint.json:intent_impact_validandissue_impact_valid. Each existing tree scanner was split into ascan half and a validate half so the new rules share one traversal rather than adding a
third walk of the same trees.
The derivation —
DeriveNextoverlaunch.Semver, including the pre-1.0 row wherebreakingbumps the minor. Nothing can derive1.0.0. The base is the newest gittag, not the CHANGELOG heading, because
auto-release.ymltags after the ship PR mergesand in that window the two describe different releases; a heading ahead of the tag refuses as
"release in flight".
The cut set is a tree diff of end states (
git ls-treeat the tag vs at HEAD), not alog walk of moves — that is what makes it immune to squash and rebase merges.
The STOP condition — not hit
Phase 1's STOP is "the shipped-since-tag query is not deterministic across a rebase or
squash-merge".
TestShippedSinceAgreesAcrossMergeAndSquashbuilds both histories and passesunder
-race. The other pinned caveats are tested too: a within-shipped/slug rename readsas delete+add, an intent moved out of
shipped/lands in the removed set, and theheading-ahead-of-tag case refuses.
Two real bugs the independent review caught, both fixed
Deriverefused a whole release over a record it was impossible to fix. A recordrenamed or superseded since the anchor tag is read from the tag's tree, which predates
this back-fill — so
Derivedemanded animpacton a blob in immutable history while theHEAD copy was already correct. Every release would have been blocked until the rename was
reverted. Refusal is now scoped to the added side; removed records travel through so
the ship verb can still emit their
Removedline, which is what the plan requires for asupersession. Reproduced on a real clone before and after.
abcd capturesilently dropped every resolved issue.internal/core/captureenforcesadditionalProperties: falsethrough an allow-list that did not know aboutimpact, soall 57 back-filled records failed to parse:
abcd capturereportedresolved 0andcapture list --resolvedskipped everything. Nowresolved 57. This is exactly the kindof silent boundary failure that only shows up when the record and the reader are changed
by different hands.
The back-fill — this is the part that wants your eye
All 5 shipped intents (all
additive) and all 57 resolved issues carry animpact.It covers all 57 rather than only the 17 in the current cut because the lint gates the whole
resolved/bucket. Distribution: 4 additive (iss-47, iss-57, iss-74, iss-86), 1breaking (iss-51), 22 fix, 30 internal.
Values were proposed from each record's own content and cross-checked against the changelog
section the work was actually filed under, then spot-checked by a second agent. They are
agent-proposed; this review is the confirmation gate. Worth a look:
iss-51is the single record decidingv0.4.0. Labelledbreakingbecause the strictbanned_tokensschema is rejected at load, so a previously-valid config stops working.If you read that as
fix, the derived version becomesv0.3.1instead.itd-46was checked specifically for a break and ruledadditive:intent newsurvives as a deprecation-warning alias with byte-identical stdout.
internalcalls the proposer flagged as arguable: iss-77 (relocated threeuser-facing command files, but no changelog line), iss-73 (moved a shipped verb's
report output to a new location), iss-36 (banlist arming plus a corpus drain).
Verification
gofmt -l .silent ·go vet ./...·go build ./...·go test ./...·go test -raceon changelog/lint/capture ·
go run ./cmd/record-lintexit 0 (16 pre-existing WARNs, zeroblockers) ·
go run ./cmd/abcd --help.CHANGELOG.md,go.mod,go.sumand both.github/workflows/*.ymlare untouched — therelease contract (ADR-37) is preserved, not modified, and no phase in this stack edits the
changelog by hand.
Follow-ups deliberately not in this PR
abcd capture resolvecannot setimpact, so the tool's own path produces a record thenew blocker rejects. Same gap in
seedDrafton the intent side. Needs a design call on theflag surface — worth a captured issue.
internal/core/changelog/shipped.goadds a third record-filename matcher alongsidelint.intentFileReandlint.issueFileRe; flagged in a source comment for consolidation.frontmatter.Fieldsdoes not strip quotes, so a hand-authoredimpact: "fix"would failwhile the neighbouring
severity: "major"is quoted throughout the ledger. Nothing breakstoday (the back-fill wrote unquoted values); the first hand-authored quoted value would.