lint: add 7 architectural-sync cops (catches 10 real bugs)#2593
Merged
dgageot merged 4 commits intodocker:mainfrom Apr 29, 2026
Merged
Conversation
rubocop-go grew a much friendlier API: a *cop.Pass that exposes Filename(),
IsBlackBoxTest(), Report(node, format, args...) and ForEachConst/Func/...,
plus an embeddable cop.Meta struct for cop metadata.
Adopt all of it in the lint/ package:
- Drop the local 'offense()' wrapper and 'importPath()' helper, now
provided as cop.NewOffense's new ast.Node-based form and cop.ImportPath.
- Use p.Filename(), p.IsBlackBoxTest(), p.PackageName() everywhere
instead of fset.Position(...).Filename / strings.HasSuffix dances.
- Replace 'len(file.Imports) == 0' early-exits with the equivalent
p.File.Imports loop guard inline (kept as-is for clarity).
- Move metadata into cop.Meta so the four Name/Description/Severity
methods per cop disappear; add a New<Cop>() constructor for each.
- main.go now passes an explicit []cop.Cop to runner.New instead of
going through cop.Register/cop.All.
Net change: -27 lines, with each cop's intent visible in fewer lines.
Assisted-By: docker-agent
ConfigLatestTagConsistency: detect mismatched omitempty/omitzero between json and yaml struct tags in pkg/config/latest. Caught one real bug: Defer DeferConfig had json:"defer" but yaml:"defer,omitempty", so JSON always serialised the empty struct while YAML omitted it. ConfigVersionsRegistered: ensure pkg/config/versions.go calls Register for every pkg/config/vN/ and pkg/config/latest/ on disk; missing entries silently fail at runtime as 'unsupported config version'. TUIViewPurity: forbid receiver mutations inside View() string methods on TUI models (Bubble Tea purity contract). Slice cache idioms (nil, [:0], append(s, ...)) are auto-exempt; intentional click-zone caches use //rubocop:disable Lint/TUIViewPurity (the //nolint prefix would collide with golangci-lint's nolintlint validation of unknown linter names). Surfaced 5 existing mutations: 3 dialog click-zone caches + 2 tabbar scroll-state mutations now annotated with their justification. Assisted-By: docker-agent
RuntimeEventRegistry: every event constructor in pkg/runtime/event.go
("&XxxEvent{Type: "yyy"}") must have a matching entry in the
registry map in pkg/runtime/client.go. Without it the remote-runtime
client silently drops the event with a Debug-level log, so a sub-
agent's MessageAddedEvent / ModelFallbackEvent / SubSessionCompletedEvent
would never reach a remote frontend. The cop ran on the existing tree
and surfaced exactly those three missing entries — now added.
RuntimeSessionScoped: any *Event struct with a SessionID field must
expose GetSessionID() string on its pointer type so it satisfies the
SessionScoped interface used by the persistence pipeline:
if scoped, ok := event.(SessionScoped); ok && scoped.GetSessionID() != sess.ID {
return // forwarded sub-agent event — drop
}
Skipping the method silently bypasses that filter (the type assertion
fails, the conditional short-circuits) and lets sub-agent events leak
into the parent session's transcript. The cop surfaced six events with
this hole — UserMessageEvent, StreamStartedEvent, StreamStoppedEvent,
SessionTitleEvent, SessionSummaryEvent, SessionCompactionEvent — now
all implementing the method.
Both cops follow the same idioms as the existing seven (cop.Meta,
New<Cop>() constructor, Check(p *cop.Pass), p.Report(node, ...)).
Assisted-By: docker-agent
HookConfigSync: cross-package check that the EventXxx constants in pkg/hooks/types.go and the HooksConfig fields in pkg/config/latest/ types.go stay 1:1, matched by the snake_case wire string. Drift in either direction silently breaks at runtime: a new event without a HooksConfig field is unconfigurable, a new field without an event constant is parsed but never dispatched. Codebase is currently in sync (22 events ↔ 22 fields); the cop locks that property in. HookBuiltinsRegistered: intra-package check that every "const Foo = \"bar\"" exported from pkg/hooks/builtins/*.go (excluding builtins.go and tests) appears as the first arg of a RegisterBuiltin(Foo, ...) call in pkg/hooks/builtins/builtins.go. Adding a new builtin file ships its constant + impl but the wiring lives elsewhere; forgetting it compiles cleanly and only blows up at runtime as 'unknown builtin'. 10 builtins are registered today and the cop keeps that invariant. Both cops verified to fire on three drift scenarios (removed event constant, removed config field, removed Register call) and to stay silent on the in-sync codebase. Assisted-By: docker-agent
gtardif
approved these changes
Apr 29, 2026
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.
Adds 7 new project-specific cops to the custom rubocop-go linter, focused on architectural sync invariants that golangci-lint cannot reach. Caught 10 real bugs along the way and locked in 5 architectural caches with documented justifications.
New cops
Lint/ConfigLatestTagConsistencyjsonandyamlstruct tags inpkg/config/latest/agree onomitempty/omitzero. FrozenvN/versions are exempt.Defer DeferConfighadjson:"defer"butyaml:"defer,omitempty", so JSON always serialised the empty struct while YAML omitted it. Fixed withomitzero(consistent with the existinglifecycle.gopattern;DeferConfig.IsEmpty()makesomitzerosemantically correct).Lint/ConfigVersionsRegisteredpkg/config/versions.gocallsRegisterfor everypkg/config/vN/andpkg/config/latest/package on disk.Lint/TUIViewPurityView() stringmethods on TUI models do not mutate the receiver (Bubble Tea purity). Slice-cache idioms (nil,[:0],append) are auto-exempt; intentional click-zone caches use//rubocop:disable Lint/TUIViewPurity(the//nolintprefix would collide with golangci-lint'snolintlint).tabbar.go(scroll-state mutations) + 3 in dialog click-zone caches; all annotated with one-line justifications.Lint/RuntimeEventRegistry&XxxEvent{Type: "yyy"}constructor inpkg/runtime/event.gohas a matching entry in the decoder registry map inpkg/runtime/client.go.MessageAddedEvent,ModelFallbackEvent,SubSessionCompletedEventwere silently dropped by remote-runtime clients (only aslog.Debug("invalid_type")log line). Now registered.Lint/RuntimeSessionScoped*Eventstruct with aSessionIDfield implementsGetSessionID() string(theSessionScopedinterface used by the persistence pipeline).UserMessageEvent,StreamStartedEvent,StreamStoppedEvent,SessionTitleEvent,SessionSummaryEvent,SessionCompactionEvent. WithoutGetSessionID, the persistence-observer's sub-session filterif scoped, ok := event.(SessionScoped); ok && scoped.GetSessionID() != sess.IDshort-circuits and lets sub-agent events leak into the parent's transcript. Now all six implement the method.Lint/HookConfigSyncEventXxxconstants inpkg/hooks/types.goandHooksConfigfields inpkg/config/latest/types.gostay 1:1, matched by snake-case wire string.Lint/HookBuiltinsRegisteredconst Foo = "wire"inpkg/hooks/builtins/*.go(excludingbuiltins.goand tests) appears as the first arg of aRegisterBuiltin(Foo, …)call inpkg/hooks/builtins/builtins.go.Why these cops
The codebase has several multi-file architectural invariants that the Go compiler cannot enforce:
pkg/config/vN/directories ↔versions.goregistrySessionIDfield ↔SessionScopedinterfaceEventXxxconstants ↔ YAML schema fields ↔ in-process builtin registryView()is supposed to be a pure function of stateEach of these is a "two declarations that must agree" pattern. Forgetting one of the slots compiles cleanly and only blows up at runtime — usually as a silent drop, not an error. The cops fail the lint gate at PR time instead.
All seven cops follow the same idioms as the existing four (
config_package_name.go,config_version_constant.go,config_version_import.go,latest_imports_predecessor.go): embedcop.Meta, expose aNew<Cop>()constructor, implementCheck(p *cop.Pass), report viap.Report(node, format, args...).Bugs caught & fixed
Cumulative state
11 cops total, 10 real bugs caught & fixed, 5 architectural caches documented, 3 pure preventive invariants locked in.
Validation
mise lint✅ (golangci-lint, 11 custom cops,go mod tidy --diff)mise test✅ (105 packages, 0 failures)HookConfigSync).