| name | add-buildkit-fix |
| description | Add auto-fix support to an existing BuildKit linter rule |
| argument-hint | rule-name (e.g. StageNameCasing, FromAsCasing, NoEmptyContinuation) |
| disable-model-invocation | true |
Add Auto-Fix to BuildKit Rule
You are adding auto-fix support to an existing BuildKit linter rule for the tally project.
Rule to Add Fix: $ARGUMENTS
Step 1: Verify the Rule is Being Captured
First, confirm tally is already reporting this rule as a violation.
echo 'FROM alpine AS Builder' > /tmp/test.dockerfile
go run . check --format json /tmp/test.dockerfile 2>&1 | jq '.files[0].violations[] | {rule, message}'
If the rule doesn't appear:
- Check
internal/rules/buildkit/registry.go to see if the rule is registered
- Some rules come from parser-level warnings (
ast.Warnings) rather than the linter callback
- Parser warnings may need to be captured in
internal/dockerfile/parser.go first
Step 2: Understand the Rule
-
Fetch Docker documentation:
https://docs.docker.com/reference/build-checks/$ARGUMENTS-in-kebab-case/
Example: StageNameCasing → stage-name-casing
-
Check existing snapshots for the message format:
grep -r "$ARGUMENTS" internal/integration/__snapshots__/
-
Read the enricher pattern in internal/rules/buildkit/fixes/enricher.go
Step 3: Implement the Fix
3a: Create the Enricher Function
Create internal/rules/buildkit/fixes/$ARGUMENTS_snake_case.go:
package fixes
import (
"github.com/wharflab/tally/internal/rules"
)
func enrich${ARGUMENTS}Fix(v *rules.Violation, source []byte) {
lineIdx := v.Location.Start.Line - 1
line := getLine(source, lineIdx)
if line == nil {
return
}
v.SuggestedFix = &rules.SuggestedFix{
Description: "Description of what the fix does",
Safety: rules.FixSafe,
Edits: []rules.TextEdit{{
Location: createEditLocation(v.Location.File, v.Location.Start.Line, startCol, endCol),
NewText: "replacement",
}},
IsPreferred: true,
}
}
If the fix needs the semantic model (for cross-instruction references):
func enrich${ARGUMENTS}Fix(v *rules.Violation, sem *semantic.Model, source []byte) {
if sem == nil {
return
}
}
3b: Register in Enricher
Add to the switch in internal/rules/buildkit/fixes/enricher.go:
case "$ARGUMENTS":
enrich${ARGUMENTS}Fix(v, source)
Step 4: Handle Special Edit Types
Text Replacement (most common)
Location: createEditLocation(file, lineNum, startCol, endCol),
NewText: "replacement",
Line Deletion
To delete an entire line, span from line N to line N+1:
Location: rules.NewRangeLocation(file, lineNum, 0, lineNum+1, 0),
NewText: "",
Multi-line Edits
The fixer applies edits from end to start, so line shifts are handled automatically.
Step 5: Line Number Conventions
| Context | Convention |
|---|
v.Location.Start.Line | 1-based (from BuildKit) |
getLine(source, idx) | 0-based index |
createEditLocation(file, line, ...) | 1-based line |
rules.NewRangeLocation(file, line, ...) | 1-based line |
Common pattern:
lineIdx := v.Location.Start.Line - 1
line := getLine(source, lineIdx)
Step 6: Write Tests
Add to internal/rules/buildkit/fixes/fixes_test.go:
func Test${ARGUMENTS}Fix(t *testing.T) {
tests := []struct {
name string
source string
wantFix bool
wantEdits int
}{
{
name: "should fix",
source: "...",
wantFix: true,
wantEdits: 1,
},
{
name: "already correct",
source: "...",
wantFix: false,
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
source := []byte(tt.source)
v := rules.Violation{
Location: rules.NewRangeLocation("test.Dockerfile", 1, 0, 1, len(tt.source)),
RuleCode: rules.BuildKitRulePrefix + "$ARGUMENTS",
Message: "...",
}
enrich${ARGUMENTS}Fix(&v, source)
if tt.wantFix {
require.NotNil(t, v.SuggestedFix)
assert.Len(t, v.SuggestedFix.Edits, tt.wantEdits)
} else {
assert.Nil(t, v.SuggestedFix)
}
})
}
}
Step 7: Add Integration Tests
Add directory fixtures
Use the directory harness for normal BuildKit fix coverage:
internal/integration/fixtures/lint/$ARGUMENTS-kebab-case/Dockerfile
internal/integration/fixtures/lint/$ARGUMENTS-kebab-case/.tally.toml
internal/integration/fixtures/fix/$ARGUMENTS-kebab-case/Dockerfile
internal/integration/fixtures/fix/$ARGUMENTS-kebab-case/.tally.toml
Select the target rule in .tally.toml; add overlap rules there when the fix must be validated in a combined run. Set unsafe-fixes = true if
the fixture needs unsafe fix application.
Create/update snapshots with go-snaps:
UPDATE_SNAPS=true go test ./internal/integration -run 'Test(Lint|Fix)Fixtures' -count=1
go test ./internal/integration -run 'Test(Lint|Fix)Fixtures' -count=1
Expected snapshot files are fixture-local: result_1.snap.json, fixed_1.snap.Dockerfile, and optional report_1.snap.md.
Add explicit Go integration tests only for behavior the directory harness cannot express, such as custom CLI formats, config discovery, stdin-only
behavior, or multi-file contexts.
Step 8: Run All Checks
go test ./internal/rules/buildkit/fixes/... -v
go test ./...
make lint
UPDATE_SNAPS=true go test ./internal/integration/...
go run . check --fix /tmp/test.dockerfile && cat /tmp/test.dockerfile
Step 9: Update Documentation
Update _docs/rules/buildkit/$ARGUMENTS.mdx — add an ## Auto-fix section with a before/after example. Set the Auto-fix property row to
Yes (\--fix`)(orYes (`--fix-unsafe`)for non-safe fixes). Follow the pattern in_docs/rules/buildkit/StageNameCasing.mdx`.
Fix Safety Levels
| Level | When to Use |
|---|
rules.FixSafe | Casing changes, removing whitespace, formatting |
rules.FixSuggestion | Semantic changes that are usually correct |
rules.FixUnsafe | Changes that might alter behavior |
Position Helpers Available
getLine(source, lineIdx) - Get line content (0-based index)
createEditLocation(file, line, startCol, endCol) - Create edit location (1-based line)
ParseInstruction(line) - Tokenizer for instruction parsing
.FindKeyword("AS") - Find keyword token
.FindFlag("from") - Find flag like --from
.Arguments() - Get argument tokens
Checklist