Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
50 changes: 42 additions & 8 deletions CONTRIBUTING.md
Original file line number Diff line number Diff line change
Expand Up @@ -47,14 +47,48 @@ build):
tagging a release).
- gosec, govulncheck, and semgrep clean; CodeQL gated against a baseline.

Structural ratchets (see the
[structural gates issue](https://github.com/txn2/m6t/issues/19)) are plain Go
tests that fail on architectural decay: package-size budgets, an import
ratchet, exported-surface budgets, a god-object budget on the backend
coordinator struct (AST field/method ceilings pinned to actuals), dead-package
and noop-interface detection, and an integration guard proving integration
tests actually ran. **Ceilings carry zero slack and only move down.** Raising
one is a regression that must be explicitly justified in the PR.
### Structural ratchets

The per-function linters all evaluate code *inside* one function, so a
god-package assembled from a hundred small, tidy functions passes every one of
them. The structural gates bound what those linters cannot see. They are plain
Go tests in the repository root — no external tooling — so `make test` runs
them and `make verify` gates on them.

| Gate | What it bounds | Where |
|---|---|---|
| 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 is reachable from `main` | `package_graph_test.go` |
| Import graph | What is allowed to depend on what | `package_graph_test.go` |
| No-op interface | Interfaces implemented only by stubs | `noop_interface_test.go` |
| Integration guard | Tagged tests are actually executed | `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 carry zero slack and only move down.** Every ceiling is pinned at
the measured actual, next to the gate that enforces it, with a comment saying
what it is for. Raising one is a regression: it belongs in the PR that needs
it, on that line, with the reason. There is no suppression comment and no
escape hatch — the justification in review *is* the mechanism.

Two deliberate exceptions, both documented at the constant:

- **LOC ceilings carry headroom.** A line-count ceiling pinned to the exact
current count is a freeze, not a ratchet — one more line of doc comment would
fail the build. They are seeded as policy and re-pinned against real
measurements once the backend services land
([#2](https://github.com/txn2/m6t/issues/2),
[#5](https://github.com/txn2/m6t/issues/5)).
- **The `App` coordinator's ceilings will rise as services land**, one composed
handle at a time, each in the PR that adds it. What the gate stops is the
accumulation nobody decided on.

The ESLint suppressions ceiling is **0** — the frontend baseline starts empty
and stays empty. (That figure is checked against the gate by the agreement test,
like every other floor on this page.)

Hard rules:

Expand Down
124 changes: 124 additions & 0 deletions frontend_ratchet_test.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,124 @@
package main_test

import (
"encoding/json"
"fmt"
"sort"
"strings"
"testing"
)

// The frontend suppressions ratchet.
//
// ESLint's bulk-suppressions file is what lets the complexity gates run at
// error level without a mass rewrite: existing violations are baselined, new
// ones fail. That is only true while the baseline shrinks. Left unwatched it
// becomes the opposite — the place violations go to be forgotten, one
// `--suppress-rule` at a time.
//
// So the count is pinned, and it only ratchets down. Growing it requires
// editing the number here, in the PR that grows it, with the reason.
//
// Run: go test -run TestFrontendSuppressionsOnlyShrink .

// maxFrontendSuppressions caps the total suppressed ESLint violations.
//
// Pinned at 0: the scaffold has none, and the complexity budgets were sized so
// that honest code passes. A PR that needs to raise this is a PR that should
// have split a component instead.
//
// Prune entries a fixed file no longer needs before touching this number:
//
// cd frontend && npx eslint . --prune-suppressions
const maxFrontendSuppressions = 0

// suppressionsFile is the ESLint bulk-suppressions baseline, at ESLint's
// default location.
const suppressionsFile = "frontend/eslint-suppressions.json"

// TestFrontendSuppressionsOnlyShrink fails when the baseline holds more
// suppressed violations than the pin allows.
func TestFrontendSuppressionsOnlyShrink(t *testing.T) {
total, byRule := countSuppressions(t, readRepoFile(t, suppressionsFile))
t.Logf("%s: %d suppressed violations (ceiling %d)", suppressionsFile, total, maxFrontendSuppressions)

if total > maxFrontendSuppressions {
t.Errorf("%s suppresses %d violations (%s), exceeding the ceiling of %d — "+
"fix the code rather than baselining it; if a suppression is genuinely "+
"warranted it needs maintainer sign-off and a lower ceiling in the same PR",
suppressionsFile, total, strings.Join(byRule, ", "), maxFrontendSuppressions)
}
}

// countSuppressions totals the suppressed violations in an ESLint
// bulk-suppressions document and summarises them per rule.
//
// The format is {file: {rule: {count: n}}}, so the total is the sum of every
// count — a file-level or rule-level tally would undercount a single file that
// baselines many violations of one rule.
func countSuppressions(t *testing.T, raw string) (total int, byRule []string) {
t.Helper()
var doc map[string]map[string]struct {
Count int `json:"count"`
}
if err := json.Unmarshal([]byte(raw), &doc); err != nil {
t.Fatalf("parsing %s: %v", suppressionsFile, err)
}

perRule := map[string]int{}
for _, rules := range doc {
for rule, entry := range rules {
perRule[rule] += entry.Count
total += entry.Count
}
}
for rule, n := range perRule {
byRule = append(byRule, fmt.Sprintf("%s x%d", rule, n))
}
sort.Strings(byRule)
return total, byRule
}

// TestSuppressionCountingSumsEveryEntry pins the counter. A counter that
// tallied files or rules instead of violations would report 1 for a file
// baselining twenty violations of one rule — the exact case the ratchet is
// meant to stop.
func TestSuppressionCountingSumsEveryEntry(t *testing.T) {
tests := []struct {
name string
raw string
wantTotal int
wantRules []string
}{
{
name: "empty baseline",
raw: `{}`,
wantTotal: 0,
},
{
name: "one file, one rule, many violations",
raw: `{"src/a.ts":{"complexity":{"count":20}}}`,
wantTotal: 20,
wantRules: []string{"complexity x20"},
},
{
name: "counts sum across files and rules",
raw: `{"src/a.ts":{"complexity":{"count":2},"sonarjs/cognitive-complexity":{"count":1}},
"src/b.ts":{"complexity":{"count":3}}}`,
wantTotal: 6,
wantRules: []string{"complexity x5", "sonarjs/cognitive-complexity x1"},
},
}

for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
total, byRule := countSuppressions(t, tt.raw)
if total != tt.wantTotal {
t.Errorf("total = %d, want %d", total, tt.wantTotal)
}
if strings.Join(byRule, ",") != strings.Join(tt.wantRules, ",") {
t.Errorf("byRule = %v, want %v", byRule, tt.wantRules)
}
})
}
}
176 changes: 176 additions & 0 deletions godobject_budget_test.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,176 @@
package main_test

import (
"go/ast"
"testing"
)

// The god-object gate. The package-size budget is gameable in exactly the
// direction that matters here: moving code out of app.go into sibling files
// shrinks the line count while the App struct keeps every field and every
// method. This gate caps the struct itself.
//
// App is m6t's coordinator. Wails binds it to the frontend and it composes the
// backend services (git, pty, kube, helm; DESIGN.md §3.2) as they land, so it
// is the one type in the tree that everything else will be reachable through —
// which is precisely why it needs a ceiling from day one rather than after it
// has grown into a decomposition project.
//
// Run: go test -run TestAppGodObjectBudget .
const (
// maxAppFields caps fields on the App struct. Pinned at today's actual
// with zero slack.
//
// This ceiling WILL need raising as backend services land, and that is the
// design: each service arrives as one composed handle, in a PR that says so
// on this line. What it stops is the accumulation nobody decided on — six
// loose fields where one owner struct belonged. If raising it by more than
// one per service, the question to answer in review is why the service is
// not one handle.
maxAppFields = 1

// maxAppMethods caps methods with an App receiver, counting value and
// pointer receivers alike. Pinned at today's actual with zero slack.
//
// Every exported method here is also Wails-bound API — it crosses the
// bridge into TypeScript — so this ceiling doubles as the budget on the
// backend's public surface. Behaviour belongs on the service that owns it,
// reached through a handle, not on the coordinator.
maxAppMethods = 1

// appCoordinatorType is the struct these ceilings bound.
appCoordinatorType = "App"

// appPackageDir holds the coordinator.
appPackageDir = "internal/app"
)

// TestAppGodObjectBudget fails when the coordinator gains fields or methods
// beyond the pinned ceilings. Unlike a line-count budget these numbers cannot
// be satisfied by shuffling code between files: they only come down through
// real decomposition — moving state and behaviour onto the service that owns
// it.
func TestAppGodObjectBudget(t *testing.T) {
fields, methods := countCoordinator(t)
t.Logf("%s coordinator: %d fields, %d methods (ceilings %d / %d)",
appCoordinatorType, fields, methods, maxAppFields, maxAppMethods)

if fields > maxAppFields {
t.Errorf("%s has %d fields, exceeding the ceiling of %d — group the new state into a service handle rather than holding it directly, or justify the raise on maxAppFields in this PR",
appCoordinatorType, fields, maxAppFields)
}
if methods > maxAppMethods {
t.Errorf("%s has %d methods, exceeding the ceiling of %d — move behaviour onto the service that owns it (and remember every exported method here is also Wails-bound API), or justify the raise on maxAppMethods in this PR",
appCoordinatorType, methods, maxAppMethods)
}
}

// countCoordinator parses the coordinator's package and returns the struct's
// field count and the number of methods declared on it.
func countCoordinator(t *testing.T) (fields, methods int) {
t.Helper()
files := parsePackage(t, appPackageDir)

found := false
for _, file := range files {
for _, decl := range file.Decls {
switch d := decl.(type) {
case *ast.FuncDecl:
if name, ok := receiverTypeName(d); ok && name == appCoordinatorType {
methods++
}
case *ast.GenDecl:
if n, ok := structFieldCount(d, appCoordinatorType); ok {
fields = n
found = true
}
}
}
}
if !found {
t.Fatalf("did not find `type %s struct` in %s — if the coordinator was renamed, retarget this gate rather than deleting it",
appCoordinatorType, appPackageDir)
}
return fields, methods
}

// structFieldCount returns the field count of the named struct, counting each
// name in a grouped declaration (`a, b int` is two) and each embedded field as
// one. The bool is false for any declaration that is not that struct.
//
// Embedded fields count deliberately: embedding a struct to inherit its
// methods is a way of growing the coordinator without naming a field.
func structFieldCount(decl *ast.GenDecl, typeName string) (int, bool) {
for _, spec := range decl.Specs {
ts, ok := spec.(*ast.TypeSpec)
if !ok || ts.Name.Name != typeName {
continue
}
st, ok := ts.Type.(*ast.StructType)
if !ok {
continue
}
count := 0
for _, field := range st.Fields.List {
if len(field.Names) == 0 {
count++ // embedded
continue
}
count += len(field.Names)
}
return count, true
}
return 0, false
}

// TestGodObjectMetricCountsGroupedAndEmbeddedFields pins the metric. A field
// counter that missed grouped or embedded declarations would let the
// coordinator grow while reporting a flat number — the failure mode that makes
// a ratchet worthless.
func TestGodObjectMetricCountsGroupedAndEmbeddedFields(t *testing.T) {
const src = `package sample

type Embedded struct{}

type Target struct {
a, b int
c string
Embedded
}

type Other struct{ x, y, z int }

func (t *Target) PointerMethod() {}
func (t Target) ValueMethod() {}
func (o *Other) NotCounted() {}
func Free() {}
`
file := parseSource(t, src)

fields, found := 0, false
methods := 0
for _, decl := range file.Decls {
switch d := decl.(type) {
case *ast.FuncDecl:
if name, ok := receiverTypeName(d); ok && name == "Target" {
methods++
}
case *ast.GenDecl:
if n, ok := structFieldCount(d, "Target"); ok {
fields, found = n, true
}
}
}

if !found {
t.Fatal("structFieldCount did not find the Target struct")
}
// a, b, c, and the embedded field.
if want := 4; fields != want {
t.Errorf("fields = %d, want %d (grouped names and embedded fields each count)", fields, want)
}
// Both receiver forms count; the other type's method and the free function do not.
if want := 2; methods != want {
t.Errorf("methods = %d, want %d (value and pointer receivers both count)", methods, want)
}
}
Loading