ADR 0002: rules: — generic + rule-specific per-code configuration¶
Status: Accepted, implemented (internal/config, cmd/vlotpipe/main.go,
internal/rules/baseline/cachedrunners.go, internal/fixer). Supersedes
an earlier draft of this same ADR that proposed two separate sections
(check.cached_runners, check.severity); this revision unifies them
(and the already-shipped fix: section — see Migration below) under
one consistent shape instead of growing a new, differently-structured
config section per feature.
Context¶
Three real, distinct customization needs came up in quick succession this session:
fix.exclude/fix.timeout_minutes(already shipped this session,internal/config,internal/fixer) — exclude specific codes from--fix, override the timeout value it inserts.- Runner cache classification (
PERF001/LEAN010downgrade toinfoon an unrecognizedruns-on:label rather than staying silent or firing at full severity — a repo owner who knows a self-hosted label already has persistent caching, per the realgha-hmak-webexample fromhmak-web2, should be able to say so and get silence, not just a quieter guess). - Severity overrides (a repo's own risk calculus might disagree
with a rule's shipped default — treat
SEC001aswarninginstead ofblocker, orSTRUCT001asblockerinstead ofinfo).
Building each as its own bespoke config section (fix:, then a
proposed check: with two unrelated sub-keys) works but doesn't scale:
every future customization need would invent another shape, another
Go struct, another place to look. The actual pattern underneath all
three is the same one: a property, scoped to one rule code, with a
default the rule already has. That's worth naming once and reusing.
Decision¶
One named section, keyed by rule code:
rules:
SEC001:
severity: warning # generic — every rule accepts this
STRUCT002:
max_steps: 25 # special — only STRUCT002 knows what this means
PERF001:
cached_runners: ["gha-hmak-web", "*"]
LEAN010:
cached_runners: ["gha-hmak-web", "*"]
TIMEOUT001:
fix: false # generic — disable *only this code's* autofix
fix_default: 15 # special — override the value TIMEOUT001's fix inserts
AZR001:
fix_default: 15
Generic properties — meaningful for every rule, same name and behavior everywhere, so they don't need documenting on each individual rule page:
severity: blocker|warning|info— replaces the rule's shippedSeverity()for that code. Applied once, early, before the--severityfloor,select-based gating,report.select-based display, and thereport.topush payload all consume it — downgradingSEC001towarningmust also mean it stops failingcheck's default blocker-only gate, not just change color in text output.fix: false— for a code that's normally infixer.FixableCodes, skip the automatic edit entirely; the finding still fires and gets reported as usual. (true/omitted is the existing default — fix if fixable.)
Special properties — rule-specific, each one documented on that
rule's own docs/rules/<CODE>.md page rather than anywhere central,
since there's no shared meaning across rules to describe once:
STRUCT002.max_steps— the step-count threshold (currently the top-levelmax_steps_per_job— see Migration).PERF001.cached_runners/LEAN010.cached_runners— runner labels (exact match, or"*"wildcard — same convention asignore: path: "*") known to already have persistent caching; suppresses the rule entirely for a job on a matching runner, not just a severity downgrade, since this is an asserted fact from the repo owner, not a heuristic guess. Deliberately never folds intolooksLikeEphemeralRunner(the shared classification functionSEC010also uses, for an unrelated attack-surface reason) — a runner known to have a cache says nothing about whether it's safe to run untrusted fork-PR code on it. Consulted only byPERF001/LEAN010's ownCheckfunctions.TIMEOUT001.fix_default/AZR001.fix_default— the value inserted by--fix(replacesfix.timeout_minutes— see Migration).
Implementation shape¶
internal/config.Config gains Rules map[string]RuleConfig, where
RuleConfig carries the generic fields (Severity *string, Fix
*bool) plus a Raw map[string]any (or similar) escape hatch for
special properties, decoded via yaml.Node rather than a fixed struct
— special properties differ per rule, so a single flat struct with a
field for every rule's every special property doesn't scale the way
the generic ones do. Each rule that wants a special property reads it
out of cfg.Rules["ITS_OWN_CODE"].Raw with its own type assertion and
a sensible fallback on absence/wrong type, rather than config needing
to know the shape of every rule's special config in advance.
The Rule interface (internal/rules/rules.go) still only receives
*model.Pipeline — unchanged from ADR 0002's original reasoning:
threading config through all ~24 registered rules' signatures for the
handful that need it is too large a blast radius. Rules needing special
properties (PERF001, LEAN010, STRUCT002) get them the same way
STRUCT002's max_steps_per_job already works today — called directly
from cmd/vlotpipe/main.go as an "ad-hoc scan-time check" outside the
rules.Run() registry, or via a package-level setter
(baseline.SetCachedRunners) populated once before rules.Run,
whichever fits the specific rule's existing call shape. severity/fix
(the generic properties) apply as a post-processing step over the
already-collected violation list in run(), alongside where select/
report.select filtering already happens — no rule-side change needed
for those two at all.
Migration¶
Two already-shipped things move under rules: rather than staying
where they are, both while this is still pre-1.0 with no external
users to break:
max_steps_per_job(top-level) →rules.STRUCT002.max_steps.fix.exclude/fix.timeout_minutes(shipped this session) →rules.<CODE>.fix: false/rules.<CODE>.fix_default, one entry per code instead of one shared list —fix.exclude: [TIMEOUT001, AZR001]becomes two entries,rules.TIMEOUT001.fix: falseandrules.AZR001.fix: false, which reads more verbosely for the "exclude several at once" case but composes correctly with each code's ownfix_default/other special properties living right next to it instead of in a separate section.
Decided at implementation time: a clean break, no deprecated aliases.
Both max_steps_per_job and fix: were removed from internal/config.Config
outright rather than kept alongside rules: — pre-1.0, no external
users, and a maintained alias would be extra surface for a shape this
ADR already superseded. fix.exclude's prefix-matching behavior
(fix.exclude: [AZR] disabling every AZR* code's autofix in one
entry) does not carry over: rules: is keyed by exact code only, by
design (see Decision above), so the same result now takes one entry
per code. .vlotpipe.yml's template (internal/config's template
const) was updated in the same change to show rules: instead of the
old fix: example.
Consequences¶
docs/rules/README.mddocuments the two generic properties once, centrally (severity,fix);STRUCT002,PERF001,LEAN010,TIMEOUT001, andAZR001's own pages each document their special property in place of the config they replace — the same split as this ADR's Decision section.docs/SELECTIVE_ENFORCEMENT.mdcross-referencesrules.<CODE>.severityagainstselect/report.select: the former decides what severity a participating code counts as, the latter which codes participate at all — related but distinct, and now stated explicitly there.- The
.vlotpipe.ymltemplate (internal/config'stemplateconst) shows arules:example (severity, fix/fix_default, max_steps, cached_runners) in place of the oldfix:example. PERF001/LEAN010'scached_runnersis verified end-to-end (internal/rules/baseline/baseline_test.go'sTestCachedRunnersSuppressPerf001AndLean010): a matching label suppresses the finding entirely, not just downgrades it the way an unrecognizedruns-on:already did — andrules.<CODE>.severityis verified to affect thecheckgate itself (cmd/vlotpipe/main_test.go'sTestRuleSeverityOverrideAffectsGateNotJustDisplay), not just display, matching this ADR's explicit requirement in Decision.