Skip to content

Commit b2cee58

Browse files
authored
feat(cli): add structlint suggest — propose config diffs and file moves (#19)
## Summary - New \`structlint suggest\` command. Runs the same engine as \`validate\`, then maps each violation to a concrete proposal: config addition (unified diff), file move (\`git mv\`), file create, or a review note. - Print-only, **exits 0 even with proposals present** — this is advisory; violation gating stays \`validate\`'s job. - Tenth PR in the roadmap (Wave 3 — Suggest). Spec: [\`docs/specs/010-suggest.md\`](docs/specs/010-suggest.md). ## Design highlights - **Never loosens \`disallowed\`** — deliberate prohibitions become a note ("the rule is deliberate; review it or remove the path"), never a config_add. Test-asserted. - **JSON contract versioned from day one** (\`version: 1\`) so editors and AI agents can build against a stable shape. Documented in \`docs/user/cli-reference.md\`. - **configDiff is text-level, not YAML re-marshal**: insertion into the original file text preserves comments, ordering, quoting — the diff applies cleanly with \`patch -p1\`. Round-trip test asserts this end-to-end. - **Proposal deduping**: many files sharing an extension collapse into one \`config_add\` proposal. Sorted deterministically. ## The fix loop \`\`\`bash structlint suggest --format json > /tmp/report.json jq -r .configDiff /tmp/report.json | patch -p1 jq -r '.proposals[] | select(.kind==\"move\") | .command' /tmp/report.json | sh structlint validate \`\`\` ## Test plan - [x] \`go test -race ./...\` — 153 pass (7 new binary-based): - JSON contract v1 shape - per-code proposals (config_add / move / note) - disallowed_* NEVER loosened (no proposal touches disallowed section) - **round-trip**: pipe configDiff through \`patch(1)\`, re-run validate, exit 0 - exit 0 with proposals; exit non-zero on operational error - move emits \`git mv <from> <to>\` - [x] \`make build && ./bin/structlint validate\` — self-dogfood, exit 0. - [x] Manual: scratch tree with stale config → \`suggest\` output includes correct additions, git mv command for placement violation, and the note-only entry for \`.env.local\`.
1 parent 9e3ddd1 commit b2cee58

7 files changed

Lines changed: 920 additions & 0 deletions

File tree

‎docs/user/cli-reference.md‎

Lines changed: 49 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -106,6 +106,55 @@ structlint hook install [options]
106106

107107
Running the command twice is a no-op. YAML edits refuse (with a suggested snippet) when the target file uses anchors/aliases, since round-tripping would lose them.
108108

109+
### suggest
110+
111+
Runs the same engine as `validate`, then proposes fixes: config additions (rendered as a unified diff), file moves (as `git mv` commands), and creates. Print-only — never writes; **exits 0 even when proposals exist** (advisory tool; violation-gating is `validate`'s job).
112+
113+
```bash
114+
structlint suggest [options]
115+
```
116+
117+
| Option | Default | Description |
118+
|--------|---------|-------------|
119+
| `--path` | `.` | Directory to analyze |
120+
| `--format` | `text` | Output format: `text` or `json` |
121+
122+
Violation → proposal mapping:
123+
124+
| Code | Proposal |
125+
|------|----------|
126+
| `unallowed_directory` | Add generalized glob to `dir_structure.allowedPaths` |
127+
| `unallowed_file_pattern` | Add `*.ext` (or exact name) to `file_naming_pattern.allowed` |
128+
| `disallowed_directory`, `disallowed_file_pattern` | Note only — deliberate prohibitions are **never** loosened |
129+
| `placement_violation` | `git mv <from> <to>` where `<to>` is the placement rule's expected location |
130+
| `missing_required_*` | Create at the required path |
131+
| `boundary_violation`, `parse_error`, `walk_error` | Note only — no mechanical fix |
132+
133+
**JSON contract (v1)** — versioned from day one; consumed by editors and AI agents:
134+
135+
```json
136+
{
137+
"version": 1,
138+
"configPath": ".structlint.yaml",
139+
"proposals": [
140+
{"kind": "config_add", "section": "dir_structure.allowedPaths", "value": "tools/**", "reason": "...", "paths": ["tools"]},
141+
{"kind": "move", "from": "stray.sql", "to": "migrations/stray.sql", "command": "git mv stray.sql migrations/stray.sql", "reason": "...", "paths": ["stray.sql"]}
142+
],
143+
"configDiff": "--- a/.structlint.yaml\n+++ b/.structlint.yaml\n@@ ..."
144+
}
145+
```
146+
147+
`configDiff` is built by **line-level insertion into the original config text**, not by re-marshalling the YAML — so comments, ordering, and quoting are preserved and the diff applies cleanly with `patch -p1`.
148+
149+
Fix loop:
150+
151+
```bash
152+
structlint suggest --format json > /tmp/report.json
153+
jq -r .configDiff /tmp/report.json | patch -p1 # apply config changes
154+
jq -r '.proposals[] | select(.kind=="move") | .command' /tmp/report.json | sh
155+
structlint validate # confirm fixes
156+
```
157+
109158
### version
110159

111160
Display version information.

‎internal/app/app.go‎

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -24,6 +24,7 @@ func New() *cli.Command {
2424
validateCmd,
2525
clilib.NewInitCmd(),
2626
clilib.NewHookCmd(),
27+
clilib.NewSuggestCmd(),
2728
clilib.NewVersionCmd(),
2829
clilib.NewCompletionCmd(),
2930
},

‎internal/cli/suggest.go‎

Lines changed: 119 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,119 @@
1+
package cli
2+
3+
import (
4+
"context"
5+
"encoding/json"
6+
"fmt"
7+
"os"
8+
9+
"github.com/AxeForging/structlint/internal/suggest"
10+
"github.com/urfave/cli/v3"
11+
)
12+
13+
// NewSuggestCmd builds the `structlint suggest` command.
14+
func NewSuggestCmd() *cli.Command {
15+
return &cli.Command{
16+
Name: "suggest",
17+
Usage: "propose config changes and file moves that would resolve current violations",
18+
Description: "Runs the same engine as validate, then maps each violation to a proposal. " +
19+
"Print-only — never writes; exit 0 even when proposals exist (advisory tool).",
20+
Flags: []cli.Flag{
21+
&cli.StringFlag{
22+
Name: "path",
23+
Value: ".",
24+
Usage: "directory to analyze",
25+
},
26+
&cli.StringFlag{
27+
Name: "format",
28+
Value: "text",
29+
Usage: "output format: text or json",
30+
},
31+
},
32+
Action: func(_ context.Context, cmd *cli.Command) error {
33+
cfg, err := LoadConfigForContext(cmd)
34+
if err != nil {
35+
return err
36+
}
37+
path := cmd.String("path")
38+
if path == "" {
39+
path = "."
40+
}
41+
42+
// Discover the actual config path the same way LoadConfigForContext
43+
// did (so the diff header names the file the user sees).
44+
configPath := cmd.String("config")
45+
46+
report, err := suggest.Analyze(cfg, configPath, path)
47+
if err != nil {
48+
return err
49+
}
50+
51+
switch cmd.String("format") {
52+
case "json":
53+
enc := json.NewEncoder(os.Stdout)
54+
enc.SetIndent("", " ")
55+
if err := enc.Encode(report); err != nil {
56+
return err
57+
}
58+
default:
59+
renderText(report)
60+
}
61+
return nil
62+
},
63+
}
64+
}
65+
66+
func renderText(report *suggest.Report) {
67+
if len(report.Proposals) == 0 {
68+
fmt.Println("No proposals — nothing structlint suggest can help with.")
69+
return
70+
}
71+
fmt.Printf("Suggestions for %s\n", report.ConfigPath)
72+
73+
var (
74+
adds []suggest.Proposal
75+
moves []suggest.Proposal
76+
creates []suggest.Proposal
77+
notes []suggest.Proposal
78+
)
79+
for _, p := range report.Proposals {
80+
switch p.Kind {
81+
case suggest.KindConfigAdd:
82+
adds = append(adds, p)
83+
case suggest.KindMove:
84+
moves = append(moves, p)
85+
case suggest.KindCreate:
86+
creates = append(creates, p)
87+
default:
88+
notes = append(notes, p)
89+
}
90+
}
91+
if len(adds) > 0 {
92+
fmt.Println("\n== Config additions ==")
93+
for _, p := range adds {
94+
fmt.Printf(" + %s: %q (%s)\n", p.Section, p.Value, p.Reason)
95+
}
96+
}
97+
if len(moves) > 0 {
98+
fmt.Println("\n== File moves ==")
99+
for _, p := range moves {
100+
fmt.Printf(" %s\n reason: %s\n", p.Command, p.Reason)
101+
}
102+
}
103+
if len(creates) > 0 {
104+
fmt.Println("\n== Create ==")
105+
for _, p := range creates {
106+
fmt.Printf(" touch %s (%s)\n", p.Path, p.Reason)
107+
}
108+
}
109+
if len(notes) > 0 {
110+
fmt.Println("\n== Review ==")
111+
for _, p := range notes {
112+
fmt.Printf(" %s\n paths: %v\n", p.Reason, p.Paths)
113+
}
114+
}
115+
if report.ConfigDiff != "" {
116+
fmt.Println("\n== Config diff (apply with `patch -p1`) ==")
117+
fmt.Print(report.ConfigDiff)
118+
}
119+
}

‎internal/suggest/configdiff.go‎

Lines changed: 211 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,211 @@
1+
package suggest
2+
3+
import (
4+
"bytes"
5+
"fmt"
6+
"os"
7+
"path/filepath"
8+
"regexp"
9+
"strings"
10+
)
11+
12+
// buildConfigDiff produces a unified diff that, applied against the
13+
// original config text, adds the values from every config_add proposal
14+
// under the correct section. We NEVER re-marshal the YAML: re-marshal
15+
// would destroy comments, ordering, and quoting, making the diff
16+
// unappliable to the user's real file.
17+
//
18+
// The strategy is line-based:
19+
// 1. Read the config file.
20+
// 2. For each unique (section, value), locate the section's list in the
21+
// text and insert `- "value"` after the last existing item at the
22+
// same indent.
23+
// 3. Emit a `diff -u` between original and modified text.
24+
//
25+
// Sections missing from the file get appended at the end.
26+
func buildConfigDiff(configPath string, proposals []Proposal) (string, error) {
27+
adds := groupBySection(proposals)
28+
if len(adds) == 0 {
29+
return "", nil
30+
}
31+
origBytes, err := os.ReadFile(configPath)
32+
if err != nil {
33+
return "", fmt.Errorf("read config for diff: %w", err)
34+
}
35+
orig := string(origBytes)
36+
modified := orig
37+
for section, values := range adds {
38+
modified = insertUnderSection(modified, section, values)
39+
}
40+
if modified == orig {
41+
return "", nil
42+
}
43+
return unifiedDiff(configPath, orig, modified), nil
44+
}
45+
46+
func groupBySection(proposals []Proposal) map[string][]string {
47+
out := map[string][]string{}
48+
for _, p := range proposals {
49+
if p.Kind != KindConfigAdd || p.Value == "" || p.Section == "" {
50+
continue
51+
}
52+
out[p.Section] = append(out[p.Section], p.Value)
53+
}
54+
return out
55+
}
56+
57+
// sectionListStart matches a YAML key at the start of a line, at any indent.
58+
// We use it to locate the beginning of a nested list under a header.
59+
var listItemRE = regexp.MustCompile(`^(\s*)- `)
60+
61+
// insertUnderSection appends `- "value"` lines under the given nested key
62+
// path (e.g. "dir_structure.allowedPaths"). It looks for the innermost key
63+
// as a line ending in `:` at some indent, then walks forward while lines
64+
// are `- ` items at a deeper indent. Values are inserted after the last
65+
// existing item at the same indent.
66+
func insertUnderSection(text, section string, values []string) string {
67+
parts := strings.Split(section, ".")
68+
if len(parts) == 0 {
69+
return text
70+
}
71+
target := parts[len(parts)-1]
72+
lines := strings.Split(text, "\n")
73+
// Find the "target:" line. We accept any depth match; if the config has
74+
// two headers with the same leaf name we take the first (users rarely
75+
// duplicate section names, and this fallback is documented).
76+
targetIdx := -1
77+
for i, line := range lines {
78+
if strings.HasSuffix(strings.TrimRight(line, " \t"), target+":") {
79+
targetIdx = i
80+
break
81+
}
82+
}
83+
if targetIdx == -1 {
84+
// Append at end.
85+
return text + fmt.Sprintf("\n# added by structlint suggest\n%s:\n%s\n",
86+
target, renderItems(values, " "))
87+
}
88+
// Walk forward collecting list items directly under target.
89+
itemIndent := ""
90+
lastItemIdx := targetIdx
91+
for i := targetIdx + 1; i < len(lines); i++ {
92+
match := listItemRE.FindStringSubmatch(lines[i])
93+
if match == nil {
94+
// Blank line? Continue past it inside the same block.
95+
if strings.TrimSpace(lines[i]) == "" {
96+
continue
97+
}
98+
// Non-item line at any deeper indent means we've left the block.
99+
break
100+
}
101+
itemIndent = match[1]
102+
lastItemIdx = i
103+
}
104+
if itemIndent == "" {
105+
// Empty list or `[]` syntax; guess a reasonable indent.
106+
itemIndent = strings.Repeat(" ", leadingSpaces(lines[targetIdx])+2)
107+
}
108+
newItems := make([]string, 0, len(values))
109+
for _, v := range values {
110+
newItems = append(newItems, fmt.Sprintf(`%s- %q`, itemIndent, v))
111+
}
112+
// Splice after lastItemIdx.
113+
updated := make([]string, 0, len(lines)+len(newItems))
114+
updated = append(updated, lines[:lastItemIdx+1]...)
115+
updated = append(updated, newItems...)
116+
updated = append(updated, lines[lastItemIdx+1:]...)
117+
return strings.Join(updated, "\n")
118+
}
119+
120+
func leadingSpaces(s string) int {
121+
for i, r := range s {
122+
if r != ' ' && r != '\t' {
123+
return i
124+
}
125+
}
126+
return len(s)
127+
}
128+
129+
func renderItems(values []string, indent string) string {
130+
var b bytes.Buffer
131+
for _, v := range values {
132+
fmt.Fprintf(&b, "%s- %q\n", indent, v)
133+
}
134+
return b.String()
135+
}
136+
137+
// unifiedDiff produces a `diff -u`-style patch between orig and modified.
138+
// Only the changed hunks are emitted; unchanged head/tail are elided.
139+
func unifiedDiff(path, orig, modified string) string {
140+
origLines := strings.Split(orig, "\n")
141+
modLines := strings.Split(modified, "\n")
142+
143+
rel := filepath.ToSlash(path)
144+
var out bytes.Buffer
145+
fmt.Fprintf(&out, "--- a/%s\n", rel)
146+
fmt.Fprintf(&out, "+++ b/%s\n", rel)
147+
148+
// Diff via the simple additive-only algorithm since we only ever INSERT
149+
// lines; no removals, no in-line edits. Walk both, emit context around
150+
// the inserted lines.
151+
i, j := 0, 0
152+
const contextLines = 3
153+
for i < len(origLines) || j < len(modLines) {
154+
// Fast-forward through matching lines.
155+
for i < len(origLines) && j < len(modLines) && origLines[i] == modLines[j] {
156+
i++
157+
j++
158+
}
159+
if i >= len(origLines) && j >= len(modLines) {
160+
break
161+
}
162+
// Detect the block of insertions in modified until they realign with
163+
// origLines[i].
164+
insertStart := j
165+
for j < len(modLines) && (i >= len(origLines) || modLines[j] != origLines[i]) {
166+
j++
167+
}
168+
// Emit hunk header with context.
169+
hunkStartOrig := max0(i - contextLines)
170+
hunkStartMod := max0(insertStart - contextLines)
171+
origHunk := origLines[hunkStartOrig:i]
172+
modInserts := modLines[insertStart:j]
173+
modTrailContext := []string{}
174+
trailEnd := min(i+contextLines, len(origLines))
175+
if trailEnd > i {
176+
modTrailContext = origLines[i:trailEnd]
177+
}
178+
origHunkLen := len(origHunk) + len(modTrailContext)
179+
modHunkLen := len(origHunk) + len(modInserts) + len(modTrailContext)
180+
fmt.Fprintf(&out, "@@ -%d,%d +%d,%d @@\n",
181+
hunkStartOrig+1, origHunkLen,
182+
hunkStartMod+1, modHunkLen,
183+
)
184+
for _, l := range origHunk {
185+
fmt.Fprintf(&out, " %s\n", l)
186+
}
187+
for _, l := range modInserts {
188+
fmt.Fprintf(&out, "+%s\n", l)
189+
}
190+
for _, l := range modTrailContext {
191+
fmt.Fprintf(&out, " %s\n", l)
192+
}
193+
i = trailEnd
194+
j += len(modTrailContext)
195+
}
196+
return out.String()
197+
}
198+
199+
func max0(v int) int {
200+
if v < 0 {
201+
return 0
202+
}
203+
return v
204+
}
205+
206+
func min(a, b int) int {
207+
if a < b {
208+
return a
209+
}
210+
return b
211+
}

0 commit comments

Comments
 (0)