Create a New tally/* Rule
Implement a new custom tally rule (not BuildKit, not Hadolint) in the tally repository.
Input is $ARGUMENTS: a natural-language description of rule intent.
Step 0: Enter Plan Mode
Always start in plan mode, even if the rule sounds simple. Explore the codebase first to understand:
- •How existing rules handle similar constructs (heredocs, continuations, multi-stage builds)
- •Cross-rule interactions and fix priority gaps
- •BuildKit parser quirks (e.g.,
End.Line == Start.Linefor multi-line\continuations)
Present the plan for approval before writing code.
Step 0.5: Derive Rule Name From Description
Derive a concise kebab-case slug from $ARGUMENTS and use it consistently as <rule_slug>.
Rule naming requirements:
- •Prefer action-first patterns:
no-*,prefer-*,require-*,avoid-*. - •Keep it concise (typically 2-4 words).
- •Keep it descriptive and stable (avoid vague names like
best-practice-rule). - •Keep it unique across
internal/rules/tally/*.goanddocs/rules/tally/*.md. - •Final rule code is
tally/<rule_slug>.
Step 1: Define Rule Contract
Pick and lock these before coding:
- •Rule code:
tally/<rule_slug> - •Severity:
error,warning,info, orstyle - •Category:
security,correctness,maintainability,style, etc. - •Default behavior:
- •Enabled by default (
DefaultSeverity != off) or - •Off by default (
IsExperimental: trueand enabled via config)
- •Enabled by default (
- •Fix strategy (assume auto-fix is required — most tally rules should be fixable, even if partially):
- •Sync fix (
SuggestedFix.Edits) — default choice - •Async fix (
NeedsResolve: true) — only when sync cannot be reliable
- •Sync fix (
- •Coordination strategy:
- •Decide precedence if this rule overlaps with existing rules
- •Decide whether to suppress this rule, suppress only its fix, or defer by priority
Step 2: Choose Code Location
Create the rule in:
- •
internal/rules/tally/<rule_slug_with_underscores>.go
If detection logic is reusable across rules, extract helpers into focused packages:
- •
internal/shell/count.go - •
internal/shell/file_creation.go - •
internal/runmount/runmount.go
Step 3: Implement Rule Skeleton
Use this structure:
package tally
import (
"github.com/wharflab/tally/internal/rules"
)
type MyRule struct{}
func NewMyRule() *MyRule { return &MyRule{} }
func (r *MyRule) Metadata() rules.RuleMetadata {
return rules.RuleMetadata{
Code: rules.TallyRulePrefix + "<rule_slug>",
Name: "...",
Description: "...",
DocURL: "https://github.com/wharflab/tally/blob/main/docs/rules/tally/<rule_slug>.md",
DefaultSeverity: rules.SeverityStyle,
Category: "style",
IsExperimental: false,
}
}
func (r *MyRule) Check(input rules.LintInput) []rules.Violation {
// Rule logic
return nil
}
func init() {
rules.Register(NewMyRule())
}
Notes:
- •Rules self-register via
init(); no central tally registry file exists. - •If the rule needs semantic context, use
input.Semantic.(*semantic.Model)with nil/type guards. - •If configurable, implement:
- •
Schema() map[string]any - •
DefaultConfig() any - •
ValidateConfig(config any) errorviaconfigutil.ValidateWithSchema - •
resolveConfig()usingconfigutil.Resolve(...)
- •
Step 3.5: Handle Cross-Rule Interactions Early
If your rule can target the same command region as another rule, define coordination explicitly during implementation.
Use these mechanisms:
- •Detection-time gating:
- •Use
input.IsRuleEnabled("<other_rule_code>")to avoid dual suggestions on the same construct.
- •Use
- •Fix-time precedence:
- •Use
RuleMetadata.FixPriorityto enforce deterministic ordering. - •Lower priority runs first (content edits), higher priority runs later (structural transforms).
- •Use
- •Scope partitioning:
- •Narrow one rule to patterns it owns (for example, pure file-creation vs general chained RUN transformation).
Add regression tests for overlap behavior in both involved rule test files when practical.
Step 3.7: Handle Heredocs and Multi-Line Instructions
Dockerfile instructions can span multiple lines via \ continuations and heredocs (<<EOF, <<-EOF). Both detection and fix logic must account
for these:
- •Continuation lines: BuildKit's parser may report
End.Line == Start.Linefor\-continued instructions. Usesourcemap.SourceMapto scan for actual end lines (seeresolveEndLinepattern). - •Heredoc body lines:
RUNandCOPYcan use heredocs. Body lines between<<EOFandEOFbelong to the instruction but have their own indentation rules. - •
<<vs<<-: The<<-variant strips leading tabs from body lines. When a fix adds tab indentation to a heredoc instruction, it must also convert<<to<<-to avoid breaking the heredoc content. - •Test coverage: Include explicit test cases for continuation lines and heredoc variants (both
<<and<<-).
Step 4: Use Existing Analysis Primitives
Prefer existing infrastructure over ad-hoc parsing:
- •
internal/semanticfor stage/shell/variable context - •
internal/shellfor shell command parsing and command-shape detection - •
internal/sourcemapfor stable location/snippet handling - •
internal/runmountwhen mount-aware behavior matters
Do not use brittle string splitting/regex if semantic/shell helpers can model the behavior.
Step 4.5: Choose Sync vs Async Fix (Prefer Simpler)
Default to sync fixes. Use async fixes only when sync cannot be reliable.
Use sync fix when:
- •Edit locations are known during
Check(...). - •Replacement is local and deterministic.
- •No post-fix re-parse is needed.
Use async fix when:
- •Correct edits depend on content after other fixes apply.
- •A robust solution requires reparsing current file state.
- •External resolution is required (network, lookup, expensive deferred computation).
If async is required:
- •Set
NeedsResolve: true,ResolverID,ResolverData, andPriority. - •Implement/register resolver under
internal/fix/. - •Ensure resolver computes edits from current modified content.
- •Keep async scope minimal; avoid async when a sync edit can cover the case safely.
Step 5: Add Unit Tests
Create:
- •
internal/rules/tally/<rule_slug_with_underscores>_test.go
Follow existing pattern:
- •
Test...Metadata - •
Test...DefaultConfig(if configurable) - •
Test...ValidateConfig(if configurable) - •
Test...Checkwithtestutil.RunRuleTests - •
Test...CheckWithFixesfor fix-bearing rules
If you added helper packages, add dedicated tests there too (like internal/shell/*_test.go).
If rule coordination exists, add explicit overlap tests (for example: competing rules enabled simultaneously).
Coverage target:
- •Aim for >=85% coverage for each newly added rule/helper file.
- •Use package coverage as gate and review file/function gaps:
go test ./internal/rules/tally/... -coverprofile=/tmp/tally.cover go tool cover -func=/tmp/tally.cover
- •Add focused tests for any uncovered branches in new files until the target is met.
Step 6: Add Integration Coverage
- •
Create fixture:
- •
internal/integration/testdata/<rule_slug>/Dockerfile - •Optional:
.tally.tomlfor rule enablement/tuning
- •
- •
Add
TestCheckcase ininternal/integration/integration_test.go:- •Use
selectRules("tally/<rule_slug>")to isolate behavior
- •Use
- •
If the rule has fixes, add/extend
TestFixcase(s).- •Include at least one case where another relevant rule is also enabled.
- •Prefer realistic Dockerfile content instead of toy-only snippets.
- •
Update snapshots:
UPDATE_SNAPS=true go test ./internal/integration/...
Important: adding a new enabled rule can change rules_enabled values and the total-rules-enabled snapshot.
Step 6.5: Use Real-World Dockerfile Examples
When creating docs examples and integration fixtures, prefer life-like patterns from public repositories.
Recommended workflow (GitHub MCP):
- •Search GitHub code with a Dockerfile language filter:
- •Use
lang:Dockerfile(or GitHub equivalentlanguage:Dockerfile) plus rule-relevant keywords.
- •Use
- •Pull candidate files and extract representative snippets.
- •Adapt snippets minimally for deterministic tests (small, stable, focused on the behavior under test).
- •Avoid fully synthetic fixtures when a real-world pattern exists.
Step 7: Update Documentation
Update all of:
- •
docs/rules/tally/<rule_slug>.md(new rule page) - •
docs/rules/tally/index.md(tally rules table) - •
RULES.md- •tally summary table row
- •dedicated section for
tally/<rule_slug> - •namespace counts if changed
- •
README.mdsupported rules count/table when totals change - •
docs/index.mdtally rule count if shown
Step 8: Validate End-to-End
Run focused tests first, then broad checks:
go test ./internal/rules/tally/... -run <rule_slug_or_test_name_fragment> -v go test ./internal/integration/... -run <rule_slug_or_test_name_fragment> -v go test ./... make lint make cpd
If docs changed and zensical is available:
zensical build --clean
Step 9: Configuration + UX Checks
Confirm:
- •Rule can be enabled/disabled via
[rules] include/exclude - •Per-rule config in
[rules.tally.<rule_slug>]works - •Violation messages are explicit and actionable
- •
DocURLresolves to the new docs page - •Fix safety level is correct:
- •
FixSafe - •
FixSuggestion - •
FixUnsafe
- •
- •If fix overlaps with another rule, precedence is deterministic and tested.
- •Sync fix is used unless async is necessary for correctness.
Completion Checklist
- • Rule implemented in
internal/rules/tally/ - •
init()registration added - • Auto-fix implemented (sync preferred; at minimum cover the common case)
- • Config schema/default/validation implemented (if configurable)
- • Unit tests added for rule behavior and config
- • Heredoc and
\-continuation edge cases tested (detection + fix) - • Helper tests added for extracted utilities
- • New rule/helper files are covered at >=85%
- • Integration fixture +
TestCheckcase added - •
TestFixcases added for fix-capable rules - • Fixtures/docs examples are based on realistic Dockerfile patterns
- • Snapshots updated
- • Docs page + docs indexes updated
- •
RULES.mdandREADME.mdcounts/details updated - •
go test ./...,make lint, andmake cpdpass