diff --git a/internal/aiagents/enrich/npm/detect.go b/internal/aiagents/enrich/npm/detect.go index d40624b..4dc3505 100644 --- a/internal/aiagents/enrich/npm/detect.go +++ b/internal/aiagents/enrich/npm/detect.go @@ -29,7 +29,28 @@ type Detection struct { } // Detect parses cmd and returns the package-manager classification, or nil. +// Compound shell commands joined by &&, ||, ;, |, &, or newline are split into +// segments and each is classified independently; the strongest detection wins +// (install > uninstall > publish > audit > exec > other). func Detect(cmd string) *Detection { + segments := splitShellSegments(cmd) + if len(segments) == 0 { + segments = []string{cmd} + } + var best *Detection + for _, seg := range segments { + d := detectSegment(seg) + if d == nil { + continue + } + if best == nil || kindRank(d.CommandKind) > kindRank(best.CommandKind) { + best = d + } + } + return best +} + +func detectSegment(cmd string) *Detection { tokens, err := shlex.Split(cmd) if err != nil || len(tokens) == 0 { // Fall back to whitespace split; shlex fails on unbalanced quotes. @@ -60,6 +81,87 @@ func Detect(cmd string) *Detection { } } +// splitShellSegments splits cmd at unquoted shell control operators +// (&&, ||, ;, |, &, newline). Single and double quotes are respected; +// backslash escapes the next byte outside single quotes. +func splitShellSegments(cmd string) []string { + var ( + segments []string + cur strings.Builder + inSingle bool + inDouble bool + ) + flush := func() { + s := strings.TrimSpace(cur.String()) + if s != "" { + segments = append(segments, s) + } + cur.Reset() + } + for i := 0; i < len(cmd); i++ { + c := cmd[i] + if !inSingle && c == '\\' && i+1 < len(cmd) { + cur.WriteByte(c) + cur.WriteByte(cmd[i+1]) + i++ + continue + } + if !inDouble && c == '\'' { + inSingle = !inSingle + cur.WriteByte(c) + continue + } + if !inSingle && c == '"' { + inDouble = !inDouble + cur.WriteByte(c) + continue + } + if !inSingle && !inDouble { + switch c { + case '\n', ';': + flush() + continue + case '&': + if i+1 < len(cmd) && cmd[i+1] == '&' { + flush() + i++ + continue + } + flush() + continue + case '|': + if i+1 < len(cmd) && cmd[i+1] == '|' { + flush() + i++ + continue + } + flush() + continue + } + } + cur.WriteByte(c) + } + flush() + return segments +} + +func kindRank(kind string) int { + switch kind { + case "install": + return 6 + case "uninstall": + return 5 + case "publish": + return 4 + case "audit": + return 3 + case "exec": + return 2 + default: + return 1 + } +} + func managerFromBinary(bin string) (Manager, bool) { switch bin { case "npm": diff --git a/internal/aiagents/enrich/npm/detect_test.go b/internal/aiagents/enrich/npm/detect_test.go index 2c6be0c..57443aa 100644 --- a/internal/aiagents/enrich/npm/detect_test.go +++ b/internal/aiagents/enrich/npm/detect_test.go @@ -67,6 +67,57 @@ func TestDetectIgnoresUnrelatedCommands(t *testing.T) { } } +func TestDetectCompoundCommands(t *testing.T) { + cases := []struct { + cmd string + want Manager + kind string + }{ + {"cd /tmp/app && pnpm install && pnpm run build", PNPM, "install"}, + {"cd /tmp/app && pnpm install", PNPM, "install"}, + {"npm ci && npm run build", NPM, "install"}, + {"pnpm run build && pnpm install", PNPM, "install"}, + {"npx -y create-vite my-app && cd my-app && pnpm install", PNPM, "install"}, + {"echo hi; yarn add lodash", Yarn, "install"}, + {"pnpm install || echo failed", PNPM, "install"}, + {"cat package.json | jq . && pnpm i", PNPM, "install"}, + {"cd /tmp && pnpm install &", PNPM, "install"}, + {"pnpm install\npnpm run build", PNPM, "install"}, + {"cd /tmp && echo 'foo && bar' && pnpm add zod", PNPM, "install"}, + {"cd /tmp && echo \"foo; bar\" && yarn install", Yarn, "install"}, + {"pnpm remove react && pnpm install", PNPM, "install"}, + {"pnpm run build && pnpm publish", PNPM, "publish"}, + {"pnpm audit && pnpm run build", PNPM, "audit"}, + {"npx create-react-app foo && cd foo", NPX, "exec"}, + } + for _, tc := range cases { + t.Run(tc.cmd, func(t *testing.T) { + d := Detect(tc.cmd) + if d == nil { + t.Fatalf("expected detection for %q", tc.cmd) + } + if d.Manager != tc.want { + t.Fatalf("manager: got %s want %s", d.Manager, tc.want) + } + if d.CommandKind != tc.kind { + t.Fatalf("kind: got %s want %s", d.CommandKind, tc.kind) + } + }) + } +} + +func TestDetectCompoundIgnoresUnrelated(t *testing.T) { + for _, cmd := range []string{ + "cd /tmp && ls", + "git pull && cargo build", + "echo hi; echo bye", + } { + if d := Detect(cmd); d != nil { + t.Errorf("expected nil for %q, got %+v", cmd, d) + } + } +} + func TestConfidenceLabels(t *testing.T) { if got := confidence(NPM); got != "high" { t.Errorf("npm confidence: %s", got)