Contracts: gofastr verify

go build answers "does this compile". go vet answers "is anything obviously wrong". Neither answers the question you actually have before merging:

Is this still a good GoFastr application?

gofastr verify answers that one. It discovers your route table, entity declarations, permission strings, and rendering surface, then reports where they no longer hold to the framework's contract, with the reason and the fix attached to every finding.

1 lines
gofastr verify

What it checks

Source discovery follows a symlinked analysis root and linked package directories, visiting each resolved directory once to stop cycles. Build outputs (dist, bin, build, tmp), dependencies, and hidden/underscore subtrees are skipped; explicitly selecting a root with one of those names still scans that root.

An unreadable analysis root is a fatal discovery error. An unreadable subdirectory is skipped without stopping discovery of its readable siblings. Source files that cannot be read are recorded alongside parse failures in the report's unparsed count and its text footer. A run with no readable Go, CSS, or JavaScript source files fails with a discovery error instead of reporting a clean pass; a directory containing only configuration or excluded build output is not a successful analysis. contracts.NewPass still permits an empty pass for inspection, but contracts.Run refuses to analyze it, so both gofastr verify and the gofastr build contract gate reject it.

Rules are grouped into capabilities, and each capability is also a filter: gofastr verify routing security runs two of them.

CapabilityCatches
routingduplicate routes, :id where ServeMux wants {id}, lowercase methods, in-page state modelled as navigation, routes no test mentions, path prefixes matched without a segment boundary
permissionsper-user data exposed with no owner field, mutating routes outside any guarded group, auth configured but never mounted
securitySQL built by concatenation, POST forms with no CSRF input, render.HTML on a concatenation, cookies missing HttpOnly/Secure/SameSite, committed credentials, X-Forwarded-Proto spliced into a URL without an http/https check, request bodies decoded with encoding/json outside core/handler's strict binder
datawrites whose result and error are both discarded (_, _ = db.Exec(…), or a bare db.Exec(…) statement)
entitiesMCP tools on an entity with CRUD disabled, entities opted into anonymous access, a CRUD entity exposed with no auth wired (every operation 401s)
architectureimports that point up the layer stack, explicitly forbidden edges
renderingCSS outside the design system, stylesheets with no owner, location.href used as navigation, bespoke EventSource, inline style=, <script> with an inline body, var(--…) in project CSS naming a token the theme does not emit, design-system CSS hardcoding a literal a theme token already declares, the owned-style (*.style.css) checks
accessibilitythe static WCAG floor: missing alt text, unnamed controls and landmarks, incomplete form controls, implicit heading levels, elements missing required metadata
performanceregexps compiled per call, N+1 queries, reflection on the request path
testingroutes, permissions, roles, entity operations, lifecycle hooks, and event subscribers no test exercised; disabled tests; a line-coverage floor; an unreadable coverage manifest
aihand-rolled CRUD for a declared entity, subsystems a battery already provides, raw SQL bypassing entity scoping

gofastr verify --list prints the whole catalog. gofastr verify --explain GOFASTR1002 prints one rule in full.

Strict by default, relaxed on purpose

Every rule in the catalog is enforced at its declared severity. There is no opt-in, because a rule you have to switch on is a rule nobody switches on. There are exactly two ways to say no, and both leave a written trace.

Configuration moves severity in either direction: nothing is quieter than declared unless you write it down, but a team that wants routing/untested-route to be an error rather than a warning is making a real choice and can. Either way it appears in the report footer.

One instance, at the site:

2 lines
//gofastr:allow(GOFASTR1902) public webhook receiver; signature is verified in the handler
r.Handle("POST", "/hooks/stripe", stripeWebhook)

The reason is required: a directive without one is itself a finding (GOFASTR0001). So is a directive naming a rule that does not exist (GOFASTR0003), or one that stops matching anything (GOFASTR0002), so suppressions cannot quietly accumulate. //gofastr:allow(all) is rejected outright: it would absorb every rule added afterwards.

Put the directive at the end of the offending line, or on its own line directly above it. //gofastr:allow-file(RULE) reason covers a whole file.

GOFASTR1809 checks .css files discovered under the verification root outside the styling-owner paths, including embedded stylesheets and static assets: a stylesheet that is not a <name>.style.css has no owner. (This was GOFASTR1801's stylesheet check; a stylesheet file is now reported under 1809 only, never under both, and 1801 keeps covering CSS in Go strings.) Sheets whose declarations only assign custom properties (--name: value) stay quiet, as token assignments do in Go strings. Files under a testdata/ directory, *.style.css and *.tokens.css files are skipped. It does not need to trace WithCustomCSS or a file-server call. Existing path exemptions and discovery exclusions still apply. Each offending stylesheet gets one finding at its first non-comment line. The fix is to give the CSS an owner: rename the file to <name>.style.css beside the layout, screen or component it styles (app.style.css for page-wide classes) and run gofastr gen styles. A frontend that does not use framework UI can declare that exception in a leading CSS comment:

2 lines
/* gofastr:allow(GOFASTR1809) vendor stylesheet kept unchanged */
.vendor-control { display: flex; }

The comment must precede the first rule. rendering/ownerless-stylesheet can replace the ID, and allow-file also works. A missing reason does not suppress the finding.

GOFASTR1802 (hard navigation) and GOFASTR1803 (bespoke EventSource) also read the app's own .js, .mjs and .cjs files, such as static/app.js, not only scripts emitted from Go strings. Assigning location.href, location.search, location.pathname or window.location counts whatever the right-hand side is. Minified scripts (.min.js, or a line over 1000 bytes), generated files and the kit's own runtime (core-ui/runtime, framework/headless, which implement navigation and the SSE bus) are skipped. In a script, a trailing // gofastr:allow(GOFASTR1802) reason waives its own line.

GOFASTR1814 checks the owned-styles pair: every *.style.css must carry its generated sibling <name>_style.gen.go (every *.tokens.css its <name>_tokens.gen.go), and the sibling's // Source hash: line must match the CSS bytes. The generated file is the only link between the hand-written sheet and the typed class vocabulary the app calls; edit one without regenerating the other and the Go silently serves the old CSS. testdata/ trees are skipped. Regenerate with gofastr gen styles (see the CLI).

Every other owned-style check runs the same code gofastr gen styles runs before it writes Go (core-ui/ownstyle), against the same token set: the built-in theme plus every app token the program's *.tokens.css files declare (theming). The generator and gofastr verify share one program grouping — the same grouping GOFASTR1816 and GOFASTR1822 judge, per target platform — so the two cannot disagree about a sheet, and a sheet two programs share satisfies each program's tokens, not their union. Findings point at the CSS line and column. testdata/ trees are skipped here too. Inside a .css file, a /* gofastr:allow(GOFASTRnnnn) reason */ comment waives the rule on its own line when code precedes it, otherwise on the next line with code.

RuleSeverityFires on
GOFASTR1806errorvar(--name) naming a token neither the theme nor a *.tokens.css declares (in a *.style.css, reported once, by the owned-style check; a var() fallback does not waive it there)
GOFASTR1807errora value that is exactly a theme or app token's value: font-size: 0.75rem where --text-xs is 0.75rem, font-weight: 600 where --font-weight-semibold is 600, max-width: 66rem where --size-page-width is 66rem, outline-offset: 2px where --stroke-focus-offset is 2px
GOFASTR1808errora var() fallback that restates a spacing, radius, stroke, text or duration token at a value the theme does not declare. Colour fallbacks in an owned style follow the app's theme and are not judged; in design-system Go code they are (gofastr verify --explain GOFASTR1808)
GOFASTR1810errora selector naming a kit class (.cui-*, .hui-*, .fui-*, escapes decoded, so .\66 ui-card counts), a [class…] match on one ([class~="fui-card"], [class^="fui-"], `[class\="hui"], [class="ui-"]), or a [data-cui-]` attribute
GOFASTR1811error!important
GOFASTR1812errora raw width in @media (min-width: 768px); write (--above-md) or (--below-lg). Raw widths stay legal in @container
GOFASTR1813warnanimation with no @media (--reduced-motion) block for the same selector
GOFASTR1815infoevery owned sheet, listed with its owner name and class count as a candidate for framework/ui
GOFASTR1816errortwo *.style.css files with the same name (file stem) that one program links; the second ownstyle.Must would panic at init
GOFASTR1817errora rule on a kit component's root that sets anything but a placement property
GOFASTR1818errora screen's or layout's style handle used outside its own package and the one that attaches it
GOFASTR1819errorin app.style.css: a selector whose subject is not a class, or a custom property declaration
GOFASTR1820errorany owned sheet declaring a custom property named like a theme token (--color-primary)
GOFASTR1821erroran app token (*.tokens.css) whose value is another token's value of the same type: --color-brand: #18181B where --color-primary is #18181B
GOFASTR1822warnthe same literal written in two or more owned sheets of one program for the same token type; declare it once as a token
GOFASTR1825errora statement at-rule other than @layer (@import, @charset, @namespace): it counts only at the top of a stylesheet, and an owned sheet compiles after the kit's rules, so the browser drops it. Load fonts with style.FontFaceCSS

GOFASTR1823 (error) guards the kit itself, the design-system trees (core-ui/, framework/ui/, framework/uihost/, battery/ and the rest GOFASTR1807 reads there). GOFASTR1807 judges a value whole, so border: 1px solid var(--color-border) passes it, and a padding: 6px matches no token at all. GOFASTR1823 reads inside every declaration, in stylesheet strings and in builder Set pairs, and refuses a look value written as a literal:

PropertyReads
border, outline and inset-ring widths, outline offset--stroke-*
border radius (px or rem)--radii-*
transition duration or delay, or animation duration, up to 500ms--duration-*
ease, ease-in, ease-out, ease-in-out or cubic-bezier() in a transition or animation--easing-*
z-index above 10--z-*
padding, margin, gap, grid-gap, scroll margin and padding--spacing-*; an off-step value is calc(var(--spacing-sm, 4px) * n)
top, right, bottom, left, inset--spacing-* or a --ui-<component>-<part> knob
width, height, inline-size, block-size, their min/max, flex-basisa --ui-<component>-<part> knob or a --size-* token
font size--text-*
font weight (a number, bold or normal)--font-weight-*; an off-step weight is calc(var(--font-weight-semibold, 600) + 50)
the font shorthand's size, /line-height and weight--text-*, --leading-*, --font-weight-*
line height (a number other than 0 and 1, or a length)--leading-*
letter spacing--tracking-*
opacity between 0 and 1--opacity-* or a knob
box shadow with a colour or a length over 1px--shadow-* or a knob
colour, background, border, fill, stroke, text-shadow with hex, rgb(), hsl(), oklch(), white or black--color-* or a knob

Write the token or knob with today's value as the fallback, var(--stroke-thin, 1px) solid or width: var(--ui-checkbox-box-size, 18px), so the kit still draws with no theme loaded. A literal inside a var() fallback or inside a calc() that reads a token (calc(var(--radii-md, 8px) - 2px)) passes, as do zero, 1px hairlines and visually hidden boxes, em, %, ch and viewport lengths, line-height 0 and 1, opacity 0 and 1, linear and steps() timing, local stacking orders, loop periods over 500ms and animation-delay (a loop's stagger). @media conditions are never read (var() is invalid there). Dev tooling under framework/dev/ is held to the width, radius, motion and layer rows only: its chrome is not an app theme's.

GOFASTR1824 (error) holds the same trees to the reading direction. A page in Arabic, Hebrew or Persian renders <html dir="rtl">, and a physical side stays where it was: the sidebar border, the list indent and the select chevron all stay on the left-to-right side. The rule refuses left/right offsets, margin-, padding-, scroll-margin-, scroll-padding- and border-left/-right (with their -width, -style and -color), the four corner radii, text-align, float and clear set to left or right, a background position on a side with no :dir(rtl) mirror in the same file, and a four-value margin, padding, inset or border shorthand whose left and right differ. Write the logical property: inset-inline-start, margin-inline-end, border-inline-end, border-start-start-radius, text-align: start, float: inline-start, or padding-block plus padding-inline. A value with no logical spelling (a background position, a translateX travel) gets a :dir(rtl) override beside it. left: 50% and right: 50% (centring with a translate), auto, and the declarations of a :dir() override block, from its selector to its closing brace, pass. A comment that mentions :dir(rtl) counts as no mirror. Dev tooling is exempt.

GOFASTR1817 reads Go as well as CSS. A class reaches a kit root when a handle method is called inside the Class field of a framework/ui config literal, ui.Card(ui.CardConfig{Class: board.Style.Column()}), or through its With form, board.Style.ColumnWith(…). A sheet whose owner root is a kit root, Style.Scope(ui.Card(…)), has its :scope rules checked the same way. On those subjects only placement properties pass: grid-area, grid-column*, grid-row*, margin*, align-self, justify-self, place-self, order, flex, flex-grow, flex-shrink, flex-basis, width, height, inline-size, block-size and their min-/max- forms, display, position, inset*, top, right, bottom, left, z-index, visibility. Anything else, custom properties included, restyles the component.

5 lines
/* board.style.css */
.column {
  grid-column: span 2;             /* placement: fine */
  padding: var(--spacing-md);      /* GOFASTR1817: pass Padding in ui.CardConfig instead */
}

GOFASTR1818 finds the handles attached with LayoutSpec{Style: h} or Screen.WithStyle(h) and reports any use of them (h.Column(), h.Scope(…)) in a package that is neither the handle's own (where its .style.css sits) nor the one that attaches it, so main may call .WithStyle(board.Style) while package board renders with the classes. A layout's or screen's classes only apply inside that owner's root; markup another package renders is a component and gets its own <name>.style.css.

Both Go-side rules resolve a handle the way it is normally written: Style or <Owner>Style in the package that holds the generated file, or pkg.Style through an import. A handle first copied into a local variable is not traced.

A whole surface, in the contracts: section of gofastr.settings.yml:

16 lines
contracts:
  exempt:
    - "**/testdata/**"

  performance: warn          # a capability, downgraded

  rules:
    GOFASTR1003:             # a single rule
      severity: warn
      exempt: ["cmd/**"]

  coverage:
    minimum: 90
    routes: true
    permissions: true
    entities: true

Path globs are slash-separated (cmd/**, **/testdata/**); a backslash is accepted as a separator too, so a path typed on Windows works rather than silently matching nothing.

A leading / anchors a glob at the analyzed root: /gen/** exempts the root's generated subtree, not a nested pkg/gen/ subtree. Coverage minimums must be finite percentages between 0 and 100; NaN and infinity are rejected, including quoted spellings.

The app spec (gofastr.yml) is not read for this. Every relaxation is printed in the report footer: a run that passes because half of it was switched off says so.

Footer explanations and analyzer errors are sanitized for terminal control characters, just like diagnostic messages and source snippets.

The recursive CSP lints recognize // check-csp:ignore-file only as an actual standalone Go line comment. Directive-shaped text in a raw string or inside a block comment never suppresses inline-style or script findings.

The response-mount lint follows .then(this.render) to the method in the enclosing JavaScript class. Same-named methods in unrelated classes never supply a mount or an OK gate for that chain.

The required-JSON-array analyzer credits append(value.Field, more...) only when the base field is proven non-nil before the append. Merely marking the field //gofastr:required-array does not initialize it; a conditional initializer or a later possibly-nil write is not a proof.

Adopting it on an existing codebase

Strict-by-default and adoption pull against each other. Switch verify on in a mature app and you get hundreds of findings at once; nobody fixes hundreds of findings at once, so the realistic outcomes are "turn the tool off" or "downgrade every rule to warn". Both end with nothing enforced.

A baseline is the third option. Accept what is there, fail on what is added:

1 lines
gofastr verify --strict --baseline-write

That records every current finding into .gofastr-contracts-baseline.json and commits it. It refuses to run with --sarif: record the baseline and export SARIF in separate runs. This refusal happens before analysis, fixes or either artifact is written. With --json, the baseline result also includes the run's notices, including degraded changed-file checks and unmounted-package warnings. It also refuses to run with --rule or a capability filter: a baseline recorded from a narrowed run would erase every other rule's accepted debt, and the next full verify or build would fail on findings the team had already signed off. From then on the same gofastr verify --strict passes on the debt it recorded and fails on anything new. The file lives in the repository, not under .gofastr/: unlike the coverage manifest this is a reviewed decision about what debt is accepted, and it belongs in the diff where a reader can watch it shrink.

The conventional path is the contract. The build gate, the dev loop, and MCP contracts_verify all read .gofastr-contracts-baseline.json from the analysed root, and none of them can be pointed elsewhere. verify --baseline <file> overrides the path for that one invocation, useful for trying a candidate baseline before adopting it, but a baseline only verify can see is one every other gate will ignore, so the recorded file belongs at the conventional path.

Counts are keyed by (rule, file), not by line. Line numbers churn on every edit, and a baseline that goes stale on a reformat is a baseline people delete. Moving a finding within a file keeps it accepted; adding one more of the same rule to that file does not.

As you pay debt down, the report says so:

2 lines
· 12 pre-existing findings absorbed by the baseline
· 3 baseline entries are now over-accepting — re-record with gofastr verify --baseline-write

That nudge matters. A fixed finding keeps its allowance until someone re-records, and that slack is exactly where a new finding could hide. Re-recording is what makes the baseline a ratchet rather than a mute button.

Suppressing a baselined finding with //gofastr:allow does not free its slot: the finding still exists, so it keeps occupying its allowance, and a new finding of the same rule in that file still fails. The entry shows up in the over-accepting nudge instead. A re-recorded baseline, which also runs after suppressions are consumed, would no longer include it.

Only gating findings are recorded. Anything below the run's fail-on severity is left out on purpose: a baseline exists to unblock a gate, and an entry for a finding that cannot fail the run would absorb it on every later run and silence an informational signal you deliberately kept visible.

That interacts with the semantic-coverage rules specifically. They record which tests ran, which differs by environment: a CI job that excludes some packages, or a machine with Docker down, produces different findings for the same commit. Gating a shared CI on a recorded baseline of those is flaky by construction: a baseline recorded in one lane fails in the other. The usual answer is to downgrade them to info in the config, so they stay visible on every run and never gate; the filter above is what stops --baseline-write from then silencing them anyway. This repository does exactly that. See its gofastr.settings.yml.

A baseline is not a substitute for the config. Use gofastr.settings.yml when a rule's premise genuinely does not hold for a surface: that is permanent and explained. Use a baseline for debt you intend to pay.

Checking only what you changed

gofastr verify reports the whole tree, which is the wrong shape for the three moments you most want it: a pre-commit hook, a dev-loop rebuild, and a PR review. All three ask the narrower question, what did this change break, and answering it by reading a 200-line whole-repo report is answering a different one.

2 lines
gofastr verify --changed           # uncommitted work, including new files
gofastr verify --changed=main      # everything since this branch forked

The analysis still runs over the whole tree, and must: the route table, the entity list, and the coverage manifest are only meaningful whole. Only the reporting narrows. A duplicate route introduced by editing one file is still found, because the other half of the pair was analysed too.

--changed=<ref> compares against the fork point with that ref, not its tip. Otherwise a long-lived branch would report every finding in everyone else's commits as something it changed. Untracked files count as changed; a brand-new file full of findings is exactly what a pre-commit check should catch, and git diff alone never lists it.

This includes untracked files with --changed=<ref>. Paths retain their exact spelling, including Unicode, whitespace, tabs, and newlines.

A narrowed run says what it withheld:

1 lines
· 41 findings outside the changed files — run without --changed for the full picture

Outside a git repository the flag warns and reports everything, rather than silently reporting nothing.

--changed narrows the fixes too, not just the report. gofastr verify --changed --fix in a pre-commit hook rewrites only the files the change touched. Editing an unrelated file there would land an unreviewed edit in someone's commit, and the narrowed report they were reading would never mention it. Without --changed, --fix still applies everywhere.

In the dev loop

gofastr dev reports contract findings after each reload, scoped to the files you changed. Saves while analysis is running coalesce into one trailing pass, so the last save in a burst is checked without stacking concurrent analyses:

6 lines
✓ Reloaded!

⚠ contracts — findings in what you changed:
    GOFASTR1002  main.go:14:2  route DELETE /wipe/:id uses `:id` — ServeMux matches that literally…
    GOFASTR1902  main.go:14:2  DELETE /wipe/:id is registered outside any group…
    explain any of these: gofastr verify --explain <rule>

Three deliberate properties:

  • After the reload, never before it. Analysis takes about a second on a large tree, and a second added to every save is the difference between a loop people use and one they turn off. The server restarts first; this runs behind it and prints when ready.
  • Compact. Rule ID, location, one line. The reasoning, the example, and the fix are what gofastr verify --explain is for. Repeating them on every save would bury the loop. Long reports are capped, and say how many they hid.
  • Quiet when nothing changed. An unchanged report is not reprinted; fixing the last finding prints contracts: clean once. A loop that repeats itself is a loop you stop reading.

The baseline applies here too, so an existing project sees only what it is adding. And failures of the analysis itself are said out loud, once: a config that will not parse, a tree that cannot be scanned, a corrupt baseline. In a loop, silence reads as "clean", so nothing is allowed to fail silently: even a changed-set that cannot be computed (a repository with no commits yet, say) falls back to whole-tree reporting and says so rather than silently widening.

Semantic coverage

Line coverage answers "did this statement run". It cannot answer:

  • Did a request ever reach this route through the real router, the real middleware chain, and the real auth check?
  • Was this permission ever evaluated?
  • Did anything ever call this entity's delete endpoint?
  • Did this OnBeforeCreate hook ever actually fire?
  • Did anything ever publish the event this handler subscribes to?
  • Has a request ever arrived holding this role?

A handler can sit at 100% line coverage because a unit test called the function directly, while no request has ever reached it through the router. framework/semcov records what a test run genuinely exercised into .gofastr/semantic-coverage.json, and the testing rules diff that against the discovered surface. Every test binary merges into the same file under an OS lock on .gofastr/semantic-coverage.lock, so packages that finish together keep each other's records.

The manifest sits under the project root unless GOFASTR_SEMANTIC_COVERAGE_DIR or GOFASTR_AXE_COVERAGE_DIR is set, in which case the tests, the server and gofastr verify all use that directory. Set it the same way for all three, or verify reads a manifest the tests never wrote.

Six dimensions are recorded, each through a hook the runtime packages expose rather than by importing the recorder (the router's SetServeHook method, access.SetObserver, hook.SetObserver, event.SetObserver), so core/router, framework/access, framework/hook, and framework/event stay dependency-free leaves and a production binary pays one atomic load per check:

DimensionRecorded whenRule
Routesa request matches a registered pattern, after the route gate and before the middleware chainGOFASTR1101
Permissionsaccess.Can / CanResource / RequirePermission evaluates one, whatever the verdictGOFASTR1102
Entity operationsa CRUD route for a declared entity is servedGOFASTR1103
Lifecycle hooksa registered hook body actually runsGOFASTR1107
Event subscribersthe type a handler subscribes to is publishedGOFASTR1108
Rolesa caller holds the role during a permission checkGOFASTR1109

Two of those distinctions carry the weight. A permission denial is recorded as coverage, because a test asserting a rejection proves the boundary exists at least as well as one asserting a grant; the failure worth catching is a check that is never reached at all. And a hook firing is only recorded when something is registered for that lifecycle point; ExecuteHooks runs on every CRUD operation regardless, so crediting the call would hand every entity full hook coverage on its first request.

The role check inverts the usual worry. A role granting too little surfaces as a broken feature someone reports; a role granting too much surfaces as nothing at all, until it is used. Recording which roles a suite actually authenticated as is how the second kind becomes visible.

Hooks and event subscribers are the two surfaces with no callers to follow. Nothing in the codebase invokes them by name, so a rename on the emitting side, order.placed becoming order.completed, leaves the subscriber compiling, the tests green, and the notification never sent.

framework.TestHarness records automatically, so a suite already using it gets coverage without changing a line. Drive the app in-process some other way and call it directly:

4 lines
func TestCheckout(t *testing.T) {
    framework.RecordSemanticCoverage(t, app)
    // …
}

A test that builds the binary and drives it over real HTTP is a different case: the requests happen in a subprocess that never touched the harness, so nothing records and a thorough e2e suite scores zero. Set GOFASTR_SEMANTIC_COVERAGE=1 on the server's environment and it records what it serves:

1 lines
srv.Env = append(os.Environ(), "PORT="+addr, "GOFASTR_SEMANTIC_COVERAGE=1")

The e2e test gofastr generate emits already does this. The manifest is written from the request path rather than at shutdown, because a harness usually SIGKILLs the server. Flushing costs one write per newly-seen route, not one per request.

Set GOFASTR_NO_SEMANTIC_COVERAGE to turn in-process recording off for a suite.

Absence and drift are treated differently, on purpose. No manifest at all, as in a fresh clone whose tests have never run, reports at info level and does not fail: walling the first verify behind a full test run teaches people to skip verify. A manifest that exists but misses a route is real drift and fails. Run go test ./... once and the manifest exists from then on. The file lives under .gofastr/ (gitignored, wiped by make clean), so it never ships.

What this deliberately does not check

Three things are absent on purpose rather than pending.

Generated files. Analyzers skip anything carrying the full // Code generated … DO NOT EDIT. line (either half alone exempts nothing: the header is repo content, and a bare DO NOT EDIT comment must not buy a whole-tree exemption): the developer cannot fix a finding there, only the generator can, and a finding nobody can act on trains people to stop reading. This is why cmd/check-csp still exists alongside the rendering rules. It scans generated output at make build time, where the generator's product is exactly what needs checking. The two are a division of labour, not a duplication: contracts polices what you write, check-csp polices what the build emits.

Middleware execution. Recording which middleware ran needs a wrapper around every registered middleware, permanently, on the request path, and the rule it would enable ("you registered middleware that never ran") almost never fires, because app.Use middleware runs on every request by definition. Group-scoped middleware is the interesting case, and route coverage already tells you whether those routes were reached. A permanent per-request cost for a rule that cannot realistically fire is the wrong trade.

API versions. framework/experimental/apiversions is an experimental surface, explicitly outside the stability contract (framework/ARCHITECTURE.md). Rules that pin an experimental API's shape would make it harder to change, which is the opposite of what "experimental" is for.

If either becomes worth having, the analyzer surface is the place to add it: framework/contracts/analyzers, one file per capability.

Output for agents

--json emits every diagnostic with its complete rule attached: the reason, the fix, the bad/good example pair, the doc topic. One finding is a complete work item, so an agent acting on it needs no second call:

1 lines
gofastr verify --json | jq '.diagnostics[] | select(.severity=="error")'

The --json shape

schema is the first field so a reader can branch on it before parsing anything else. It is bumped when the shape changes in a way a consumer must notice.

21 lines
{
  "schema": 1,
  "tool": "gofastr verify",
  "root": "/abs/path",
  "config": "gofastr.settings.yml",
  "passed": false,
  "failOn": "error",
  "vet":   { "ran": true, "passed": true },
  "counts": { "errors": 1, "warnings": 3, "infos": 1, "total": 5 },
  "summary": [ { "capability": "routing", "errors": 1, "warnings": 2, "infos": 0 } ],
  "diagnostics": [ … ],
  "suppressed": 2,
  "baselined": 65,
  "baselineFixed": 3,
  "unparsed": 1,
  "outsideChange": 41,
  "relaxations": [ "rule GOFASTR1101 → info" ],
  "analyzerErrors": [],
  "timings": [ { "name": "rendering", "ms": 656.3, "diagnostics": 180 } ],
  "durationMs": 705.3
}

With --json, stdout is exactly one JSON document on failures too, including invalid flags and unknown --explain rules. Operational errors carry an error string; human-readable error details go to stderr. Report root is the absolute analysed directory even when vet stops the run before contracts execute.

capabilities appears when the run was scoped (gofastr verify routing), config when a config file was found, and baselined, baselineFixed, outsideChange, analyzerErrors and timings are omitted when empty, so absence means zero, never unknown. A skipped vet stage omits passed entirely: a stage that never ran has no verdict, and serialising false there would read "did not check" as "failed".

unparsed counts files the parser rejected. They produced no findings from any analyzer, so without the count a tree mid-edit reads as clean for exactly the files nobody could read. A tree that does not parse is normal in the dev loop and reported rather than fatal. go vet runs first and is where a genuine syntax error stops the run.

passed is the field to gate on; it already accounts for a failed vet stage and for analyzer errors, either of which means "could not look" rather than "nothing found". baselined, baselineFixed, and outsideChange report what the run did NOT show: without them a narrowed or baselined run is indistinguishable from a clean whole-tree one.

Each diagnostic carries its complete rule under ruleDoc, so one finding is a self-contained work item:

16 lines
{
  "rule": "GOFASTR1003",
  "slug": "routing/untested-route",
  "capability": "routing",
  "severity": "warn",
  "file": "app.go",
  "line": 294,
  "column": 2,
  "message": "no test file mentions /api/tables/customers",
  "snippet": "fwApp.Router().HandleFunc(\"GET\", \"/api/tables/customers\", …)",
  "suggestion": "add a test that requests /api/tables/customers — …",
  "evidence": { "method": "GET", "pattern": "/api/tables/customers" },
  "ruleDoc": { "id": …, "title": …, "summary": …, "why": …, "fix": …,
               "examples": [ { "bad": …, "good": … } ],
               "doc": "routing", "severity": …, "autofix": false }
}

file is relative to root. snippet is omitted for a redacted finding. The hardcoded-secret rule never echoes what it matched, over any output. evidence is per-rule structured detail; treat unknown keys as additive.

--sarif verify.sarif writes SARIF 2.1.0 for GitHub code scanning and IDE inline diagnostics. Artifact URIs are relative to the analysed root, and the run declares that root in originalUriBaseIds, so annotations map correctly whether you ran verify from the repository root or pointed it at a subdirectory.

Every report carries the vet stage that ran before the analyzers:

1 lines
"vet": { "ran": true, "passed": true }

go vet is a precondition, not a check: half the analyzers cannot read what they need from a tree the compiler rejects, so a failing vet stops the run and the document says so (passed: false, no diagnostics, and vet's own output attached). --no-vet records {"ran": false, "skipped": "--no-vet"} rather than omitting the field, because "we did not check" must never read as "it was fine".

The vet stage runs GoFastr's analyzers too

A framework-tree vettool build failure includes the compiler/linker output in the failed vet stage, rather than only reporting that the build failed.

The vet stage is two passes. Plain go vet runs first, then the type-aware analyzers the framework's own CI runs over its code: an unbounded request or response body read, a credential compared with ==, a secret file written world-readable, an HTTP client with no timeout, a recovered panic logged raw, and about thirty more, plus the stock x/tools passes go vet leaves off (nilness, sqlrowserr, …). The gofastr binary serves as the vettool (go vet -vettool=<gofastr>), so the analyzers match the CLI you run and there is nothing else to install. Both passes must pass. A tree that fails plain go vet stops there, so its type errors print once.

The analyzers' type checker is the one compiled into the gofastr binary, and it cannot read a newer toolchain's export data. When the module builds with a newer Go minor than the binary (say go1.28 against a gofastr built with go1.27), verify skips the analyzer pass, says so, and keeps plain go vet; a gofastr built with that Go runs them again. The --json report carries the reason in vet.suiteSkipped, so a passing vet stage that ran no analyzers does not read as a full check. Verify on the GoFastr repository itself builds its cmd/vettool instead, with the repository's toolchain, and fails if that tool does not build. An app's own cmd/vettool directory changes nothing.

Apps that passed verify before this pass existed can fail it on upgrade: an http.Get with no timeout, a 0644 secret file. Fix the finding, or mark it when it is the shape on purpose. --no-vet skips both passes.

A finding that is the shape on purpose takes the same marker the contracts use, with a reason, on its line or the line above:

2 lines
//gofastr:allow(clienttimeout) health probe against a local port, bounded by ctx
resp, err := http.Get(probeURL)

The reason is mandatory; a bare marker silences nothing, and neither does one whose only "reason" is separator punctuation (--, — :).

A running app built with framework.WithMCPIntrospection() also serves the catalog over MCP through contracts_list, contracts_explain, and contracts_capabilities, so an agent can read what the framework expects before writing code rather than after a build rejects it.

Under gofastr dev, two more tools turn the catalog from reference into a working loop: contracts_verify runs the analyzers over the app's own source tree and returns the findings as structured diagnostics, and contracts_fix applies a single rule's autofixes and reports which files changed. An agent can then verify → explain → fix without shelling out.

contracts_verify honours the project's baseline, so an agent's view of the tree agrees with gofastr verify, gofastr build, and the dev watcher, and reports the count it absorbed, so a clean result never hides accepted debt. It also admits what it could not check: unparsed counts files the parser rejected, and analyzerErrors lists checks that errored instead of completing, so passed: false with zero findings always comes with its reason. The one deliberate difference from gofastr verify is the missing go vet stage: the dev loop's rebuild already reports compile errors, and running vet again on every tool call would only add latency. contracts_fix deliberately does not honour the baseline: it records debt the team agreed to carry, and paying it down is the point.

Both read local source, and contracts_fix writes it, so neither is registered outside the dev loop: a deployed /mcp does not gate them, it does not have them. contracts_fix takes one rule at a time on purpose: a single rule's edits are reviewable in a way a whole-tree rewrite is not. Rules with no autofix are refused with the reason, rather than silently doing nothing.

A refused argument (an unknown rule, with its "did you mean" suggestion; an unknown capability; a rule with no autofix) answers as JSON-RPC invalid params carrying that message. A contracts_fix that stops part way answers with an isError tool result naming the files it had already rewritten, so the agent knows its last read of the tree is out of date.

Fixing

gofastr verify --fix applies the mechanical fixes and re-verifies. Only unambiguous rewrites carry one; anything needing a judgment call is left to you with the fix written out in prose. Today that is GOFASTR1005 (uppercase a lowercase method), GOFASTR1404 (add the missing cookie attributes), and GOFASTR0002 (delete a suppression that matches nothing).

The last is the one worth running regularly: suppression debt accumulates quietly, and a directive left on a moved line silently covers whatever code slid into its place. gofastr verify --rule GOFASTR0002 --fix clears the dead ones: the whole line when the directive sits on its own, only the comment when it trails a statement, never the code behind it.

--rule <id> narrows a run to one rule (or several, comma-separated, as IDs or slugs). Paired with --fix it applies exactly that rule's edits and leaves everything else alone, which keeps a fix commit reviewable:

1 lines
gofastr verify --rule GOFASTR1005 --fix

An unrecognised rule name is an error rather than an empty run, so a typo cannot read as a clean bill of health.

Applied fixes are gofmt-ed, so an edit only has to be syntactically correct rather than match the surrounding indentation. Overlapping edits are refused outright, and every edit records the text it expects to replace: a file edited since analysis is caught by content, not just by offsets that happen to still be in range. A corrupted source file is a far worse outcome than "run verify again".

GOFASTR1002 deliberately has no autofix even though the rewrite is obvious. Changing /users/:id to /users/{id} on its own turns a loud 404 into a route whose handler still reads the parameter the old way and fails quietly; the handler has to change in the same edit, and only you know what it should read.

gofastr build runs the analyzers too, but fails only on error-severity findings: a build that fails on "this route has no test" is a build people learn to bypass. --no-contracts skips it.

The build gate reads the same baseline verify does. Recording one has to fix both, or adopting a baseline leaves build permanently red and the only exit a user finds is --no-contracts, which turns every check off. New findings the baseline never recorded still stop the build.

Every rule carries at least one bad/good example pair, required rather encouraged: RegisterRules rejects a rule without one at init, the same place a malformed or duplicate rule already fails. A rule the reader cannot see is a rule they will not apply.

The catalog is checked against the analyzers, not just against itself: no rule may fire on its own documented good example, and a rule's bad example has to produce it. A rule and its documentation cannot drift apart without a test failing. Twenty-one rules whose examples need context one file cannot express, such as a coverage manifest, a multi-package layout, or a _test.go, are listed with the reason, and that list is guarded too: if such an example starts firing, the entry is stale and the test says so.

Adding a rule

Rules live in framework/contracts/catalog.go as data; the detectors live in framework/contracts/analyzers/. That split is deliberate: the catalog has to be readable and serveable without a Go parser, and a detector has to be replaceable without touching the documentation contract it satisfies.

Why and Fix are mandatory and validated at init. If you cannot write the fix, the finding is an opinion and does not belong in the catalog.

Your own rules

The pipeline is a library, and the catalog is open to registration: a project can add rules the framework could never know about, say "order writes go through the audit trail" or "handlers in internal/api never import internal/billing directly", and they ride everything the built-ins ride: config severity, off, exemptions, //gofastr:allow with a mandatory reason, the baseline ratchet, the JSON/SARIF output, and contracts_list/contracts_explain over MCP in any app process that imports them.

A custom rule picks its own uppercase ID prefix (ACME101, not GOFASTR9901); the GOFASTR namespace and its per-capability number blocks belong to the built-in catalog, which is what keeps a suppression written today from colliding with a future release. The slug's prefix must name one of the existing capabilities, which decide where the rule appears in reports.

Register the rule and its detector from an init (or before the first run), then drive the pipeline from a small command of your own:

54 lines
package main

import (
	"fmt"
	"os"

	"github.com/DonaldMurillo/gofastr/framework/contracts"
	// The built-in analyzers register themselves on import.
	_ "github.com/DonaldMurillo/gofastr/framework/contracts/analyzers"
)

func init() {
	contracts.RegisterRules(contracts.Rule{
		ID: "ACME101", Slug: "data/orders-are-audited",
		Title:      "Order writes bypass the audit trail",
		Capability: contracts.CapData, Severity: contracts.SeverityError,
		Summary: "A write to orders does not go through audit.Record.",
		Why:     "Regulated flows must leave a trail; a silent write is an audit failure.",
		Fix:     "Wrap the write in audit.Record(...).",
		Doc:     "entity-declarations",
		Examples: []contracts.Example{{
			Bad:  `db.Exec("UPDATE orders ...")`,
			Good: `audit.Record(ctx, func() { db.Exec("UPDATE orders ...") })`,
		}},
	})
	contracts.Register(&contracts.Analyzer{
		Name: "acme-audit", Doc: "audit-trail discipline for order writes",
		Rules: []string{"ACME101"},
		Run: func(p *contracts.Pass) ([]contracts.Diagnostic, error) {
			// p.Files, p.AST, p.Lines: the same pass the built-ins read.
			return nil, nil
		},
	})
}

func main() {
	cfg, err := contracts.LoadConfig(".", "")
	if err != nil {
		fmt.Fprintln(os.Stderr, err)
		os.Exit(2)
	}
	pass, err := contracts.NewPass(".", cfg)
	if err != nil {
		fmt.Fprintln(os.Stderr, err)
		os.Exit(2)
	}
	report, err := contracts.Run(pass, contracts.RunOptions{})
	if err != nil {
		fmt.Fprintln(os.Stderr, err)
		os.Exit(2)
	}
	fmt.Print(contracts.FormatText(report, contracts.TextOptions{}))
	os.Exit(report.ExitCode())
}

go run ./cmd/contracts-verify is then your project's gate: wire it into the same pre-commit hook or CI step you would wire gofastr verify into. The stock gofastr binary only ever knows the built-in catalog: custom rules live in your module, so they run through your command, where the compiler can see them.

Common mistakes

  • Suppressing instead of fixing. --explain exists because most findings have a one-line remedy. Reach for //gofastr:allow when the rule's premise genuinely does not hold here, not when the fix is inconvenient, and write the reason for a reader, not for the linter.
  • Expecting :id to work in an API route. It works in screens, which core-ui routes itself, and silently 404s in Router().Handle, which goes to ServeMux. GOFASTR1002 only fires on the second.
  • Reading a clean testing report as proof. With no manifest, those checks did not run; that is what the GOFASTR1106 info line is telling you. Run the tests first.
  • Configuring a coverage floor with no profile. coverage.minimum needs go test -coverprofile=coverage.out to have produced something to read; verify reports the missing profile rather than quietly passing.
  • Putting an architecture layering in the config and expecting the order not to matter. Layers are ordered top-first, and a package may import its own layer and anything below it. Reversing the list inverts every rule.