Skip to content

Commit e49ae4d

Browse files
Merge pull request #77 from EnRaiha/feat/snip-inspect
feat: snip inspect — built-in code quality checks
2 parents 1cea5ee + 48f262e commit e49ae4d

13 files changed

Lines changed: 949 additions & 10 deletions

File tree

internal/cli/cli.go

Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -17,6 +17,7 @@ import (
1717
"github.com/edouard-claude/snip/internal/hook"
1818
"github.com/edouard-claude/snip/internal/hookaudit"
1919
"github.com/edouard-claude/snip/internal/initcmd"
20+
"github.com/edouard-claude/snip/internal/inspect"
2021
"github.com/edouard-claude/snip/internal/learn"
2122
"github.com/edouard-claude/snip/internal/tee"
2223
"github.com/edouard-claude/snip/internal/tracking"
@@ -168,6 +169,9 @@ func Run(args []string) int {
168169
case "verify":
169170
return verify.Run(cmdArgs)
170171

172+
case "inspect":
173+
return inspect.Run(cmdArgs)
174+
171175
case "trust":
172176
return runTrust(cmdArgs)
173177

@@ -431,6 +435,11 @@ Commands:
431435
trust Trust project-local filter file(s) by SHA-256 hash
432436
untrust Remove filter file(s) from the trust store
433437
proxy Passthrough without filtering
438+
inspect Code quality checks for snip's own source
439+
--dead-fields find tagged struct fields never read
440+
--append-safety find shared-state append() calls
441+
--all run both checks
442+
--json output as JSON (for CI)
434443
435444
Init flags:
436445
--agent <name> Agent to configure:

internal/cli/flags.go

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -64,7 +64,7 @@ func isStackedVerboseFlag(arg string) bool {
6464

6565
func isBuiltInCommand(arg string) bool {
6666
switch arg {
67-
case "run", "check", "init", "gain", "cc-economics", "config", "proxy", "hook", "hook-audit", "discover", "learn", "verify", "trust", "untrust":
67+
case "run", "check", "init", "gain", "cc-economics", "config", "proxy", "hook", "hook-audit", "discover", "learn", "verify", "trust", "untrust", "inspect":
6868
return true
6969
default:
7070
return false

internal/filter/actions.go

Lines changed: 4 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -249,7 +249,8 @@ func groupBy(input ActionResult, params map[string]any) (ActionResult, error) {
249249

250250
// append mode: keep original lines and add group summary at the end
251251
if getBool(params, "append") {
252-
out = append(input.Lines, out...)
252+
// safe because: fresh slice via append([]string{}, ...)
253+
out = append(append([]string{}, input.Lines...), out...)
253254
}
254255

255256
return ActionResult{Lines: out, Metadata: meta}, nil
@@ -637,7 +638,8 @@ func aggregate(input ActionResult, params map[string]any) (ActionResult, error)
637638

638639
// append mode: keep original lines and add aggregate summary at the end
639640
if getBool(params, "append") {
640-
out = append(input.Lines, out...)
641+
// safe because: fresh slice via append([]string{}, ...)
642+
out = append(append([]string{}, input.Lines...), out...)
641643
}
642644

643645
return ActionResult{Lines: out, Metadata: meta}, nil

internal/filter/actions_test.go

Lines changed: 56 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -200,6 +200,35 @@ func TestGroupByAppend(t *testing.T) {
200200
}
201201
}
202202

203+
func TestGroupByAppendAliasingIsolation(t *testing.T) {
204+
// Verify that the backing array fix prevents mutation of the original
205+
// input slice from leaking into the groupBy output.
206+
original := []string{"a.go", "b.json", "c.go"}
207+
copyForInput := make([]string, len(original))
208+
copy(copyForInput, original)
209+
210+
input := ActionResult{Lines: copyForInput, Metadata: nil}
211+
res, err := groupBy(input, map[string]any{
212+
"pattern": `\.(\w+)$`,
213+
"format": "{{.Key}}: {{.Count}}",
214+
"append": true,
215+
})
216+
if err != nil {
217+
t.Fatal(err)
218+
}
219+
220+
// Mutate the original backing array — should not affect result
221+
copyForInput[0] = "CORRUPTED"
222+
copyForInput[1] = "CORRUPTED"
223+
224+
// Original lines in result must still be intact
225+
for i, want := range original {
226+
if res.Lines[i] != want {
227+
t.Errorf("backing array aliasing at line %d: got %q, want %q", i, res.Lines[i], want)
228+
}
229+
}
230+
}
231+
203232
func TestDedup(t *testing.T) {
204233
input := lines("error: foo", "error: foo", "error: foo", "warn: bar", "warn: bar")
205234
res, err := dedup(input, map[string]any{})
@@ -269,6 +298,33 @@ func TestAggregateAppend(t *testing.T) {
269298
}
270299
}
271300

301+
func TestAggregateAppendAliasingIsolation(t *testing.T) {
302+
original := []string{"PASS a", "FAIL b", "PASS c"}
303+
copyForInput := make([]string, len(original))
304+
copy(copyForInput, original)
305+
306+
input := ActionResult{Lines: copyForInput, Metadata: nil}
307+
res, err := aggregate(input, map[string]any{
308+
"patterns": map[string]any{
309+
"pass": `^PASS`,
310+
"fail": `^FAIL`,
311+
},
312+
"append": true,
313+
})
314+
if err != nil {
315+
t.Fatal(err)
316+
}
317+
318+
copyForInput[0] = "CORRUPTED"
319+
copyForInput[1] = "CORRUPTED"
320+
321+
for i, want := range original {
322+
if res.Lines[i] != want {
323+
t.Errorf("backing array aliasing at line %d: got %q, want %q", i, res.Lines[i], want)
324+
}
325+
}
326+
}
327+
272328
func TestFormatTemplate(t *testing.T) {
273329
input := ActionResult{
274330
Lines: []string{"a", "b", "c"},

internal/filter/types.go

Lines changed: 3 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -8,12 +8,12 @@ import (
88
type Filter struct {
99
Name string `yaml:"name"`
1010
Version int `yaml:"version"`
11-
Description string `yaml:"description"`
11+
Description string // parsed from YAML but unused by behavior code
1212
Match Match `yaml:"match"`
1313
Inject *Inject `yaml:"inject,omitempty"`
14-
Streams []string `yaml:"streams,omitempty"` // "stdout", "stderr"; defaults to ["stdout"]
14+
Streams []string `yaml:"streams,omitempty"`
1515
Pipeline Pipeline `yaml:"pipeline"`
16-
OnError string `yaml:"on_error"` // "passthrough", "empty", "template"
16+
OnError string // parsed from YAML, hardcoded to passthrough in pipeline
1717
Tests []FilterTest `yaml:"tests,omitempty"`
1818
}
1919

internal/filter/types_test.go

Lines changed: 24 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -25,7 +25,6 @@ pipeline:
2525
pattern: "\\S"
2626
- action: "head"
2727
n: 5
28-
on_error: "passthrough"
2928
`
3029
var f Filter
3130
if err := yaml.Unmarshal([]byte(input), &f); err != nil {
@@ -62,9 +61,6 @@ on_error: "passthrough"
6261
if f.Pipeline[1].ActionName != "head" {
6362
t.Errorf("pipeline[1].action = %q", f.Pipeline[1].ActionName)
6463
}
65-
if f.OnError != "passthrough" {
66-
t.Errorf("on_error = %q", f.OnError)
67-
}
6864
}
6965

7066
func TestActionResultEmpty(t *testing.T) {
@@ -222,3 +218,27 @@ func TestFilterClonePreservesNilParams(t *testing.T) {
222218
t.Error("clone mutation leaked into original Params map")
223219
}
224220
}
221+
222+
func TestYAMLBackwardCompatUntaggedFields(t *testing.T) {
223+
// Verify that removing yaml:"description" and yaml:"on_error" tags
224+
// does not break YAML parsing — yaml.v3 silently ignores unknown keys.
225+
input := `
226+
name: "backward-compat"
227+
version: 1
228+
description: "This field has no yaml tag anymore"
229+
match:
230+
command: "echo"
231+
pipeline: []
232+
on_error: "passthrough"
233+
`
234+
var f Filter
235+
if err := yaml.Unmarshal([]byte(input), &f); err != nil {
236+
t.Fatalf("yaml.Unmarshal should succeed even with untagged fields: %v", err)
237+
}
238+
if f.Name != "backward-compat" {
239+
t.Errorf("Name = %q, want backward-compat", f.Name)
240+
}
241+
if f.Match.Command != "echo" {
242+
t.Errorf("Match.Command = %q, want echo", f.Match.Command)
243+
}
244+
}

internal/inspect/appendsafety.go

Lines changed: 127 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,127 @@
1+
package inspect
2+
3+
import (
4+
"fmt"
5+
"go/ast"
6+
"go/parser"
7+
"go/token"
8+
"os"
9+
"strings"
10+
)
11+
12+
type AppendSafetyChecker struct{}
13+
14+
func (a AppendSafetyChecker) Name() string { return "append-safety" }
15+
16+
func (a AppendSafetyChecker) Run(dir string) ([]Finding, error) {
17+
files, err := findGoFiles(dir, true) // skip test files
18+
if err != nil {
19+
return nil, fmt.Errorf("find go files: %w", err)
20+
}
21+
22+
var findings []Finding
23+
fset := token.NewFileSet()
24+
25+
for _, path := range files {
26+
f, err := parser.ParseFile(fset, path, nil, parser.ParseComments)
27+
if err != nil {
28+
continue
29+
}
30+
31+
/* #nosec G304 — reading project source files, not user-controlled paths */
32+
data, err := os.ReadFile(path)
33+
if err != nil {
34+
continue
35+
}
36+
lines := strings.Split(string(data), "\n")
37+
38+
ast.Inspect(f, func(n ast.Node) bool {
39+
call, ok := n.(*ast.CallExpr)
40+
if !ok {
41+
return true
42+
}
43+
44+
ident, ok := call.Fun.(*ast.Ident)
45+
if !ok || ident.Name != "append" {
46+
return true
47+
}
48+
49+
if len(call.Args) < 2 {
50+
return true
51+
}
52+
53+
firstArg := call.Args[0]
54+
isShared := isSharedState(firstArg)
55+
56+
if !isShared {
57+
return true
58+
}
59+
60+
line := fset.Position(call.Pos()).Line
61+
guarded := hasGuard(lines, line)
62+
63+
level := "risky"
64+
if guarded {
65+
level = "safe"
66+
}
67+
68+
code := strings.TrimSpace(snippet(lines, line-1))
69+
pos := fset.Position(call.Pos())
70+
71+
findings = append(findings, Finding{
72+
File: pos.Filename,
73+
Line: pos.Line,
74+
Category: "append-safety",
75+
Level: level,
76+
Message: fmt.Sprintf("append() on shared state: %s", code),
77+
Context: code,
78+
})
79+
80+
return true
81+
})
82+
}
83+
84+
return findings, nil
85+
}
86+
87+
func isSharedState(expr ast.Expr) bool {
88+
switch e := expr.(type) {
89+
case *ast.SelectorExpr:
90+
return e.X != nil
91+
case *ast.Ident:
92+
return false
93+
case *ast.IndexExpr:
94+
return isSharedState(e.X)
95+
default:
96+
return false
97+
}
98+
}
99+
100+
func hasGuard(lines []string, line int) bool {
101+
start := line - 10
102+
if start < 0 {
103+
start = 0
104+
}
105+
end := line + 5
106+
if end > len(lines) {
107+
end = len(lines)
108+
}
109+
110+
for i := start; i < end; i++ {
111+
l := strings.ToLower(lines[i])
112+
if strings.Contains(l, "clone()") ||
113+
strings.Contains(l, "fresh slice") ||
114+
strings.Contains(l, "safe because") ||
115+
strings.Contains(l, "make([]") {
116+
return true
117+
}
118+
}
119+
return false
120+
}
121+
122+
func snippet(lines []string, i int) string {
123+
if i < 0 || i >= len(lines) {
124+
return "<unknown>"
125+
}
126+
return lines[i]
127+
}

0 commit comments

Comments
 (0)