Skip to content

Add the structural ratchet gates - #22

Merged
cjimti merged 1 commit into
feat/1-quality-leash-scaffoldfrom
feat/19-structural-ratchets
Aug 3, 2026
Merged

cjimti merged 1 commit into
feat/1-quality-leash-scaffoldfrom
feat/19-structural-ratchets

Conversation

@cjimti

@cjimti cjimti commented Aug 3, 2026

Copy link
Copy Markdown
Member

Closes #19. Stacked on #21 (feat/1-quality-leash-scaffold) — #19 needs the Makefile, make verify and internal/, none of which exist on main until #21 merges. Merge #21 first, then this retargets to main cleanly.

What this adds

Ten structural gates, as plain Go tests in the repository root. No external tooling, no new dependencies — make test runs them, so make verify gates on them.

The per-function linters (gocyclo, gocognit, revive) all evaluate code inside one function. A god-package assembled from a hundred small, tidy functions passes every one of them. These gates bound what those linters cannot see: how big a package is, how much it exports, how many packages exist, what depends on what, and how much state the coordinator holds.

Gate What it bounds File
Package size Lines and files per package package_budget_test.go
Package pin Every package has a ratchet entry package_budget_test.go
Exported surface Package-scope exported identifiers surface_budget_test.go
God-object Fields and methods on the App coordinator godobject_budget_test.go
Dead package Every package reachable from main package_graph_test.go
Import graph What may depend on what package_graph_test.go
No-op interface Interfaces implemented only by stubs noop_interface_test.go
Integration guard Tagged tests actually execute integration_guard_test.go
Frontend ratchet ESLint suppressions only shrink frontend_ratchet_test.go
Wiring guard The gates above still run structural_gates_test.go

Ceilings, pinned at measured actuals

App coordinator      1 fields, 1 methods
internal/app        67 LOC, 2 exported (App, Options)
internal/buildinfo  74 LOC, 2 exported (Get, Info)
.  (root)           22 LOC, 0 exported
eslint suppressions  0

Reproduce: go test -count=1 -run 'TestPackageSizeBudget|TestPackageExportedSurfaceBudget|TestAppGodObjectBudget' -v .

Ceilings only move down. There is no suppression comment and no escape hatch — raising one belongs in the PR that needs it, on that line, with the reason. The justification in review is the mechanism.

Negative-case proof

Every gate was run against a scratch violation and reverted. None of these are hypothetical:

Violation applied Gate output
Extra field on App App has 2 fields, exceeding the ceiling of 1
Extra method on App App has 2 methods, exceeding the ceiling of 1
New exported identifier internal/app exports 3 identifiers (App, Options, Probe)
Unimported internal/orphan not reachable from main through non-test imports
Same package, unpinned no entry in structuralPins; add one in this PR
3 ESLint suppressions suppresses 3 violations (complexity x3), exceeding the ceiling of 0
Tagged test, no runner execute nowhere and rot silently
200 filler lines 269 LOC (ceiling 200)
main importing buildinfo imports [internal/app internal/buildinfo], pinned as [internal/app]
Gate file given a build tag excluded from the default test run

Each gate also has a unit test on its own metric — TestCountGoFileDetectsGeneratedCode, TestExportedNamesCountsPackageScopeOnly, TestGodObjectMetricCountsGroupedAndEmbeddedFields, TestReachabilityFollowsTransitiveImports, TestNoopDetectionDistinguishesStubsFromBehaviour, TestIntegrationTagDetection, TestSuppressionCountingSumsEveryEntry, TestWiringDetectorsFire. A detector that never fires and a working gate look identical from the outside; these pin the difference.

Two deliberate deviations from a literal reading of the issue

1. LOC ceilings carry headroom. The issue says every ceiling equals today's actual with zero slack. For line counts that is a freeze, not a ratchet — one more line of doc comment in buildinfo.go would fail the build. They are seeded as policy (200 / 150 / 60, roughly 2–3x current) and documented at locCeilingNote with the reasoning, to re-pin against real measurements once #2 and #5 land. Every count gate — fields, methods, packages, exported names, suppressions — is pinned exactly, because those do not grow through ordinary editing.

2. The App ceilings at 1/1 will rise as services land. That is the mechanism working: each backend service arrives as one composed handle, in a PR that says so on that line. What the gate stops is the accumulation nobody decided on — six loose fields where one owner struct belonged. It does mean #2 and #5 will each touch these numbers.

Scope notes

  • Structural ratchet gates: budgets, god-object ceilings, dead-code detection #19 lists PTY service #2 and Project registry and tabs #5 as dependencies and both are still open, so the LOC budget and integration guard are pinned against a two-package scaffold rather than real service packages. The integration guard is armed but quiet — no tagged tests exist yet, and it fires the moment one lands without a runner wired into verify.
  • The no-op interface gate matches implementations by method-name set, not full type checking, to avoid pulling in x/tools. That is the conservative direction: a false positive needs a type that shares every method name with an interface and has nothing but empty bodies — itself the smell being hunted. Documented at the gate.
  • The import graph is pinned here and enforced by depguard in .golangci.yml. Deliberate duplication: the test survives a lint-config edit.

Also in this PR

  • CONTRIBUTING.md documents all ten gates, the zero-slack policy, and both deviations.
  • pins_test.go gains an agreement check so the ESLint suppressions ceiling stated in CONTRIBUTING cannot drift from the gate.
  • Two fixes from adversarial review of this diff: skipDir no longer prunes build/ (a pruned directory is a place a package could live outside every ceiling), and hasBuildConstraint now stops at the package clause so a gate file holding a //go:build string as a fixture does not read as constrained itself.

Verification

make verify green. Coverage unchanged — every file here is _test.go, so nothing enters the coverage denominator.

Ten plain Go tests in the repo root that make architectural decay a build
failure rather than a review opinion: package size, the pinned package
list, exported surface, the App coordinator's field/method ceilings,
dead-package detection, the pinned import graph, no-op-only interfaces,
an integration-tag guard, the ESLint suppressions ratchet, and a guard
that the gates themselves still run.

Ceilings are pinned at measured actuals and only move down. Two
exceptions are documented at the constant: LOC ceilings carry headroom
(pinning a line count is a freeze, not a ratchet) and re-pin once #2/#5
land, and the coordinator's ceilings rise one composed handle at a time
as services arrive.

Each gate has a unit test on its own metric, because a detector that
never fires and a working gate look identical from the outside.

Closes #19
@cjimti
cjimti merged commit 6209bc1 into feat/1-quality-leash-scaffold Aug 3, 2026
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant