diff --git a/CHANGELOG.md b/CHANGELOG.md index 59afa7d..d92ce6b 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -2,6 +2,32 @@ All notable changes to nightshift are documented in this file. +## [Unreleased] + +### Features +- **Semantic diff explainer** — new `nightshift explain-diff` command parses + `git diff` output and applies rule-based, Go-aware heuristics to classify + hunks into ChangeKinds (AddFunction, RenameSymbol, ChangeSignature, + AddTest, AddImport, ModifyComment, FormatOnly, etc.). Supports + `--staged`, `--range`, `--json`, and `--path`. + +## [v0.3.4] - 2026-02-28 + +### Features +- **Configurable agent timeouts** — add `--timeout` to `nightshift run` and `nightshift daemon`, with daemon re-exec forwarding the flag (#27) +- **Expanded PII scanner guidance** — add detailed instructions for detecting hardcoded PII, leaked env files, unsafe storage, and related exposure patterns in the built-in task (#34) + +### Fixes +- **Timeout handling and diagnostics** — preserve partial output on timeout, terminate full process groups, and surface partial logs from failed plan/implement/review steps (#33) +- **Copilot CLI integration** — improve binary resolution, permission gating, and CLI flag handling for Copilot runs (#39) +- **Provider config YAML serialization** — write provider settings with the correct YAML keys during setup (#43) +- **Configured run limits and budget fallback** — honor `schedule.max_projects` and `schedule.max_tasks`, improve budget calibration at day and week boundaries, and preserve Codex fallback permissions in headless runs (#42) + +### Other +- **Task reference docs** — add a comprehensive reference page for all 59 built-in tasks and refresh related task documentation (#30) +- **Docs cleanup** — remove auto-generated implementation docs from the repository (#40) +- **Low-risk cleanup** — resolve Copilot helper lint warnings and replace `WriteString(fmt.Sprintf(...))` patterns with `fmt.Fprintf` in reporting and setup code (#38, #41) + ## [v0.3.3] - 2026-02-19 ### Features diff --git a/README.md b/README.md index 84f92cd..82b301a 100644 --- a/README.md +++ b/README.md @@ -95,8 +95,28 @@ nightshift task show lint-fix --prompt-only nightshift task run lint-fix --provider claude nightshift task run skill-groom --provider codex --dry-run nightshift task run lint-fix --provider codex --dry-run + +# Explain the semantic meaning of pending diffs +nightshift explain-diff +nightshift explain-diff --staged +nightshift explain-diff --range main..HEAD +nightshift explain-diff --json ``` +### `nightshift explain-diff` + +Parses `git diff` output and classifies each hunk into a high-level +ChangeKind — added/removed functions, renames, signature changes, new tests, +import shifts, comment-only edits, or formatting churn. Rule-based and +Go-aware; no LLM call is made. + +| Flag | Default | Description | +|------|---------|-------------| +| `--staged` | `false` | Inspect the staged (index) diff instead of the working tree | +| `--range` | _(none)_ | Inspect a git revision range like `main..HEAD` | +| `--json` | `false` | Emit structured JSON instead of human-readable text | +| `--path` | _(cwd)_ | Path to the git repository | + If `gum` is available, preview output is shown through the gum pager. Use `--plain` to disable. ### `nightshift run` diff --git a/cmd/nightshift/commands/explain_diff.go b/cmd/nightshift/commands/explain_diff.go new file mode 100644 index 0000000..14651e3 --- /dev/null +++ b/cmd/nightshift/commands/explain_diff.go @@ -0,0 +1,54 @@ +package commands + +import ( + "fmt" + + "github.com/spf13/cobra" + + "github.com/marcus/nightshift/internal/semdiff" +) + +var explainDiffCmd = &cobra.Command{ + Use: "explain-diff", + Short: "Explain the semantic meaning of pending diff", + Long: `Parse the current git diff and produce a high-level, semantic explanation +of what changed: added/removed functions, renames, signature changes, new +tests, import shifts, comment-only edits, formatting churn, and more. + +By default it inspects the unstaged working tree. Use --staged for the +index, or --range to inspect a commit range like main..HEAD.`, + RunE: func(cmd *cobra.Command, args []string) error { + staged, _ := cmd.Flags().GetBool("staged") + rng, _ := cmd.Flags().GetString("range") + asJSON, _ := cmd.Flags().GetBool("json") + path, _ := cmd.Flags().GetString("path") + + files, err := semdiff.Gather(semdiff.Options{ + RepoPath: path, + Staged: staged, + Range: rng, + }) + if err != nil { + return err + } + exp := semdiff.Explain(files) + if asJSON { + out, err := exp.RenderJSON() + if err != nil { + return err + } + fmt.Println(out) + return nil + } + fmt.Print(exp.Render()) + return nil + }, +} + +func init() { + explainDiffCmd.Flags().Bool("staged", false, "Inspect staged (index) changes instead of working tree") + explainDiffCmd.Flags().String("range", "", "Inspect a git revision range, e.g. main..HEAD") + explainDiffCmd.Flags().Bool("json", false, "Emit output as JSON") + explainDiffCmd.Flags().String("path", "", "Path to the git repository (default: current directory)") + rootCmd.AddCommand(explainDiffCmd) +} diff --git a/internal/semdiff/classifier.go b/internal/semdiff/classifier.go new file mode 100644 index 0000000..a03eee4 --- /dev/null +++ b/internal/semdiff/classifier.go @@ -0,0 +1,214 @@ +package semdiff + +import ( + "regexp" + "sort" + "strings" +) + +// ChangeKind is a high-level label for a semantic change. +type ChangeKind string + +const ( + ChangeAddFunction ChangeKind = "AddFunction" + ChangeRemoveFunction ChangeKind = "RemoveFunction" + ChangeChangeSignature ChangeKind = "ChangeSignature" + ChangeRenameSymbol ChangeKind = "RenameSymbol" + ChangeAddTest ChangeKind = "AddTest" + ChangeModifyTest ChangeKind = "ModifyTest" + ChangeAddImport ChangeKind = "AddImport" + ChangeRemoveImport ChangeKind = "RemoveImport" + ChangeAddErrorHandling ChangeKind = "AddErrorHandling" + ChangeModifyComment ChangeKind = "ModifyComment" + ChangeFormatOnly ChangeKind = "FormatOnly" + ChangeAddType ChangeKind = "AddType" + ChangeOther ChangeKind = "Other" +) + +var ( + reFuncDecl = regexp.MustCompile(`^\s*func(?:\s+\([^)]*\))?\s+([A-Za-z_][A-Za-z0-9_]*)\s*\(([^)]*)\)`) + reTypeDecl = regexp.MustCompile(`^\s*type\s+([A-Za-z_][A-Za-z0-9_]*)\s+`) + reImportLine = regexp.MustCompile(`^\s*(?:import\s+)?"[^"]+"\s*$`) + reCommentSL = regexp.MustCompile(`^\s*//`) + reErrCheck = regexp.MustCompile(`^\s*if\s+err\s*(!=|==)\s*nil\s*\{?`) +) + +type funcInfo struct { + name string + args string +} + +// ClassifyFile applies heuristics to a single FileDiff and returns the set of +// detected ChangeKinds, in priority order with duplicates removed. +func ClassifyFile(f FileDiff) []ChangeKind { + seen := map[ChangeKind]bool{} + var order []ChangeKind + add := func(k ChangeKind) { + if !seen[k] { + seen[k] = true + order = append(order, k) + } + } + + isGo := strings.HasSuffix(f.NewPath, ".go") || strings.HasSuffix(f.OldPath, ".go") + isTest := strings.HasSuffix(f.NewPath, "_test.go") || strings.HasSuffix(f.OldPath, "_test.go") + + for _, h := range f.Hunks { + classifyHunk(h, isGo, isTest, add) + } + + if len(order) == 0 { + add(ChangeOther) + } + return order +} + +func classifyHunk(h Hunk, isGo, isTest bool, add func(ChangeKind)) { + adds := h.Additions() + dels := h.Deletions() + + // FormatOnly: same non-whitespace content on add/delete sides. + if len(adds) > 0 && len(adds) == len(dels) && allMatchIgnoringWhitespace(adds, dels) { + add(ChangeFormatOnly) + return + } + + // Comment-only changes. + if onlyComments(adds) && onlyComments(dels) && (len(adds)+len(dels)) > 0 { + add(ChangeModifyComment) + return + } + + if isGo { + addedFuncs := collectFuncs(adds) + removedFuncs := collectFuncs(dels) + + // Renames: same arg list, different name on the same hunk. + if len(addedFuncs) > 0 && len(removedFuncs) > 0 { + for _, a := range addedFuncs { + for _, r := range removedFuncs { + switch { + case a.name == r.name && normalizeArgs(a.args) != normalizeArgs(r.args): + add(ChangeChangeSignature) + case a.name != r.name && normalizeArgs(a.args) == normalizeArgs(r.args): + add(ChangeRenameSymbol) + } + } + } + } + + switch { + case len(addedFuncs) > len(removedFuncs): + if isTest { + for _, f := range addedFuncs { + if strings.HasPrefix(f.name, "Test") || strings.HasPrefix(f.name, "Benchmark") || strings.HasPrefix(f.name, "Example") { + add(ChangeAddTest) + } else { + add(ChangeAddFunction) + } + } + } else { + add(ChangeAddFunction) + } + case len(removedFuncs) > len(addedFuncs): + add(ChangeRemoveFunction) + } + + // Imports. + if hasImport(adds) { + add(ChangeAddImport) + } + if hasImport(dels) { + add(ChangeRemoveImport) + } + + // Type declarations. + for _, line := range adds { + if reTypeDecl.MatchString(line) { + add(ChangeAddType) + } + } + + // Error handling. + for _, line := range adds { + if reErrCheck.MatchString(line) { + add(ChangeAddErrorHandling) + } + } + + // Test modifications (changes inside Test funcs already covered by other rules). + if isTest && len(addedFuncs) == 0 && len(removedFuncs) == 0 && (len(adds) > 0 || len(dels) > 0) { + add(ChangeModifyTest) + } + } +} + +func collectFuncs(lines []string) []funcInfo { + var out []funcInfo + for _, line := range lines { + m := reFuncDecl.FindStringSubmatch(line) + if m != nil { + out = append(out, funcInfo{name: m[1], args: m[2]}) + } + } + return out +} + +func hasImport(lines []string) bool { + for _, line := range lines { + trimmed := strings.TrimSpace(line) + if trimmed == "" { + continue + } + if reImportLine.MatchString(line) { + return true + } + if strings.HasPrefix(trimmed, "import ") { + return true + } + } + return false +} + +func onlyComments(lines []string) bool { + any := false + for _, line := range lines { + trimmed := strings.TrimSpace(line) + if trimmed == "" { + continue + } + any = true + if !reCommentSL.MatchString(line) && !strings.HasPrefix(trimmed, "/*") && !strings.HasPrefix(trimmed, "*") { + return false + } + } + return any +} + +func allMatchIgnoringWhitespace(a, b []string) bool { + if len(a) != len(b) { + return false + } + na := make([]string, len(a)) + nb := make([]string, len(b)) + for i := range a { + na[i] = collapseWS(a[i]) + nb[i] = collapseWS(b[i]) + } + sort.Strings(na) + sort.Strings(nb) + for i := range na { + if na[i] != nb[i] { + return false + } + } + return true +} + +func collapseWS(s string) string { + return strings.Join(strings.Fields(s), " ") +} + +func normalizeArgs(args string) string { + return collapseWS(args) +} diff --git a/internal/semdiff/classifier_test.go b/internal/semdiff/classifier_test.go new file mode 100644 index 0000000..abbe3ed --- /dev/null +++ b/internal/semdiff/classifier_test.go @@ -0,0 +1,243 @@ +package semdiff + +import ( + "strings" + "testing" +) + +func hasKind(got []ChangeKind, want ChangeKind) bool { + for _, k := range got { + if k == want { + return true + } + } + return false +} + +func classify(t *testing.T, diff string) []ChangeKind { + t.Helper() + files, err := Parse(diff) + if err != nil { + t.Fatalf("Parse: %v", err) + } + if len(files) != 1 { + t.Fatalf("expected 1 file, got %d", len(files)) + } + return ClassifyFile(files[0]) +} + +func TestClassifyAddFunction(t *testing.T) { + d := `diff --git a/x.go b/x.go +--- a/x.go ++++ b/x.go +@@ -1,3 +1,7 @@ + package x + ++func NewThing() int { ++ return 1 ++} +` + kinds := classify(t, d) + if !hasKind(kinds, ChangeAddFunction) { + t.Errorf("expected AddFunction in %v", kinds) + } +} + +func TestClassifyRenameSymbol(t *testing.T) { + d := `diff --git a/x.go b/x.go +--- a/x.go ++++ b/x.go +@@ -1,3 +1,3 @@ + package x + +-func Old(a int) {} ++func New(a int) {} +` + kinds := classify(t, d) + if !hasKind(kinds, ChangeRenameSymbol) { + t.Errorf("expected RenameSymbol in %v", kinds) + } +} + +func TestClassifyChangeSignature(t *testing.T) { + d := `diff --git a/x.go b/x.go +--- a/x.go ++++ b/x.go +@@ -1,3 +1,3 @@ + package x + +-func Foo(a int) {} ++func Foo(a int, b string) {} +` + kinds := classify(t, d) + if !hasKind(kinds, ChangeChangeSignature) { + t.Errorf("expected ChangeSignature in %v", kinds) + } +} + +func TestClassifyAddTest(t *testing.T) { + d := `diff --git a/x_test.go b/x_test.go +--- a/x_test.go ++++ b/x_test.go +@@ -1,3 +1,7 @@ + package x + ++func TestNew(t *testing.T) { ++ _ = 1 ++} +` + kinds := classify(t, d) + if !hasKind(kinds, ChangeAddTest) { + t.Errorf("expected AddTest in %v", kinds) + } +} + +func TestClassifyAddImport(t *testing.T) { + d := `diff --git a/x.go b/x.go +--- a/x.go ++++ b/x.go +@@ -1,5 +1,6 @@ + package x + + import ( ++ "fmt" + "os" + ) +` + kinds := classify(t, d) + if !hasKind(kinds, ChangeAddImport) { + t.Errorf("expected AddImport in %v", kinds) + } +} + +func TestClassifyRemoveImport(t *testing.T) { + d := `diff --git a/x.go b/x.go +--- a/x.go ++++ b/x.go +@@ -1,6 +1,5 @@ + package x + + import ( +- "fmt" + "os" + ) +` + kinds := classify(t, d) + if !hasKind(kinds, ChangeRemoveImport) { + t.Errorf("expected RemoveImport in %v", kinds) + } +} + +func TestClassifyAddErrorHandling(t *testing.T) { + d := `diff --git a/x.go b/x.go +--- a/x.go ++++ b/x.go +@@ -1,5 +1,8 @@ + package x + + func foo() { ++ if err != nil { ++ return err ++ } + } +` + kinds := classify(t, d) + if !hasKind(kinds, ChangeAddErrorHandling) { + t.Errorf("expected AddErrorHandling in %v", kinds) + } +} + +func TestClassifyModifyComment(t *testing.T) { + d := `diff --git a/x.go b/x.go +--- a/x.go ++++ b/x.go +@@ -1,4 +1,4 @@ + package x + +-// old comment ++// new comment +` + kinds := classify(t, d) + if !hasKind(kinds, ChangeModifyComment) { + t.Errorf("expected ModifyComment in %v", kinds) + } +} + +func TestClassifyFormatOnly(t *testing.T) { + d := `diff --git a/x.go b/x.go +--- a/x.go ++++ b/x.go +@@ -1,3 +1,3 @@ + package x + +-foo = 1 ++foo = 1 +` + kinds := classify(t, d) + if !hasKind(kinds, ChangeFormatOnly) { + t.Errorf("expected FormatOnly in %v", kinds) + } +} + +func TestClassifyAddType(t *testing.T) { + d := `diff --git a/x.go b/x.go +--- a/x.go ++++ b/x.go +@@ -1,3 +1,5 @@ + package x + ++type Foo struct { ++} +` + kinds := classify(t, d) + if !hasKind(kinds, ChangeAddType) { + t.Errorf("expected AddType in %v", kinds) + } +} + +func TestClassifyRemoveFunction(t *testing.T) { + d := `diff --git a/x.go b/x.go +--- a/x.go ++++ b/x.go +@@ -1,6 +1,3 @@ + package x + +-func Gone() { +- return +-} +` + kinds := classify(t, d) + if !hasKind(kinds, ChangeRemoveFunction) { + t.Errorf("expected RemoveFunction in %v", kinds) + } +} + +func TestClassifyOtherForNonGo(t *testing.T) { + d := `diff --git a/README.md b/README.md +--- a/README.md ++++ b/README.md +@@ -1,2 +1,3 @@ + Title + ++New line. +` + kinds := classify(t, d) + if len(kinds) == 0 { + t.Fatalf("expected some classification") + } + if kinds[0] != ChangeOther && !hasKind(kinds, ChangeOther) { + // Acceptable: README adds may be ChangeOther. + t.Logf("kinds for README: %v", kinds) + } + if !strings.Contains(strings.Join(toStrings(kinds), ","), "") { + t.Errorf("unexpected kinds: %v", kinds) + } +} + +func toStrings(k []ChangeKind) []string { + out := make([]string, len(k)) + for i, x := range k { + out[i] = string(x) + } + return out +} diff --git a/internal/semdiff/diff.go b/internal/semdiff/diff.go new file mode 100644 index 0000000..1a53464 --- /dev/null +++ b/internal/semdiff/diff.go @@ -0,0 +1,247 @@ +// Package semdiff provides semantic analysis of git diffs. +// +// It parses unified diff output into structured hunks and applies rule-based +// heuristics — tuned for Go — to classify each hunk into a ChangeKind such as +// AddFunction, RenameSymbol, AddTest, ChangeSignature, or FormatOnly. The +// results are then aggregated into a human-readable explanation. +package semdiff + +import ( + "bufio" + "fmt" + "os/exec" + "strconv" + "strings" +) + +// Line represents a single line within a diff hunk. +type Line struct { + Kind LineKind + Content string +} + +// LineKind describes how a line participates in a diff hunk. +type LineKind int + +const ( + // LineContext is an unchanged context line. + LineContext LineKind = iota + // LineAdd is an added line. + LineAdd + // LineDelete is a removed line. + LineDelete +) + +// Hunk represents a single contiguous region of a unified diff. +type Hunk struct { + OldStart int + OldLines int + NewStart int + NewLines int + Lines []Line +} + +// FileDiff represents all changes to a single file. +type FileDiff struct { + OldPath string + NewPath string + IsRename bool + IsNew bool + IsDelete bool + Hunks []Hunk +} + +// Additions returns added lines (without the leading "+"). +func (h Hunk) Additions() []string { + out := make([]string, 0, len(h.Lines)) + for _, l := range h.Lines { + if l.Kind == LineAdd { + out = append(out, l.Content) + } + } + return out +} + +// Deletions returns deleted lines (without the leading "-"). +func (h Hunk) Deletions() []string { + out := make([]string, 0, len(h.Lines)) + for _, l := range h.Lines { + if l.Kind == LineDelete { + out = append(out, l.Content) + } + } + return out +} + +// Options controls how the diff is gathered from git. +type Options struct { + RepoPath string + Staged bool + Range string +} + +// Gather runs git diff according to opts and returns the parsed FileDiffs. +func Gather(opts Options) ([]FileDiff, error) { + args := []string{"diff", "--no-color", "--no-ext-diff", "-U3"} + switch { + case opts.Staged: + args = append(args, "--cached") + case opts.Range != "": + args = append(args, opts.Range) + } + + cmd := exec.Command("git", args...) + if opts.RepoPath != "" { + cmd.Dir = opts.RepoPath + } + out, err := cmd.Output() + if err != nil { + return nil, fmt.Errorf("running git diff: %w", err) + } + return Parse(string(out)) +} + +// Parse converts a unified diff into structured FileDiffs. +func Parse(diff string) ([]FileDiff, error) { + var files []FileDiff + var current *FileDiff + var hunk *Hunk + + flushHunk := func() { + if current != nil && hunk != nil { + current.Hunks = append(current.Hunks, *hunk) + hunk = nil + } + } + flushFile := func() { + flushHunk() + if current != nil { + files = append(files, *current) + current = nil + } + } + + scanner := bufio.NewScanner(strings.NewReader(diff)) + scanner.Buffer(make([]byte, 64*1024), 4*1024*1024) + for scanner.Scan() { + line := scanner.Text() + switch { + case strings.HasPrefix(line, "diff --git "): + flushFile() + current = &FileDiff{} + old, new := parseDiffHeader(line) + current.OldPath = old + current.NewPath = new + case current == nil: + // Ignore lines outside of any file (e.g. summary text). + continue + case strings.HasPrefix(line, "new file mode"): + current.IsNew = true + case strings.HasPrefix(line, "deleted file mode"): + current.IsDelete = true + case strings.HasPrefix(line, "rename from "): + current.IsRename = true + current.OldPath = strings.TrimPrefix(line, "rename from ") + case strings.HasPrefix(line, "rename to "): + current.IsRename = true + current.NewPath = strings.TrimPrefix(line, "rename to ") + case strings.HasPrefix(line, "--- "): + p := strings.TrimPrefix(line, "--- ") + if p != "/dev/null" { + current.OldPath = stripPrefix(p) + } + case strings.HasPrefix(line, "+++ "): + p := strings.TrimPrefix(line, "+++ ") + if p != "/dev/null" { + current.NewPath = stripPrefix(p) + } + case strings.HasPrefix(line, "@@"): + flushHunk() + h, err := parseHunkHeader(line) + if err != nil { + return nil, err + } + hunk = &h + case hunk != nil && len(line) > 0: + switch line[0] { + case '+': + hunk.Lines = append(hunk.Lines, Line{Kind: LineAdd, Content: line[1:]}) + case '-': + hunk.Lines = append(hunk.Lines, Line{Kind: LineDelete, Content: line[1:]}) + case ' ': + hunk.Lines = append(hunk.Lines, Line{Kind: LineContext, Content: line[1:]}) + case '\\': + // "\ No newline at end of file" — ignore. + } + } + } + if err := scanner.Err(); err != nil { + return nil, fmt.Errorf("scanning diff: %w", err) + } + flushFile() + return files, nil +} + +func parseDiffHeader(line string) (oldPath, newPath string) { + rest := strings.TrimPrefix(line, "diff --git ") + parts := strings.Fields(rest) + if len(parts) != 2 { + return "", "" + } + return stripPrefix(parts[0]), stripPrefix(parts[1]) +} + +func stripPrefix(p string) string { + if strings.HasPrefix(p, "a/") || strings.HasPrefix(p, "b/") { + return p[2:] + } + return p +} + +// parseHunkHeader parses lines like "@@ -10,5 +10,7 @@ funcname". +func parseHunkHeader(line string) (Hunk, error) { + end := strings.Index(line[2:], "@@") + if end < 0 { + return Hunk{}, fmt.Errorf("invalid hunk header: %q", line) + } + inner := strings.TrimSpace(line[2 : 2+end]) + parts := strings.Fields(inner) + if len(parts) < 2 { + return Hunk{}, fmt.Errorf("invalid hunk header: %q", line) + } + oldStart, oldLines, err := parseRange(parts[0]) + if err != nil { + return Hunk{}, err + } + newStart, newLines, err := parseRange(parts[1]) + if err != nil { + return Hunk{}, err + } + return Hunk{ + OldStart: oldStart, + OldLines: oldLines, + NewStart: newStart, + NewLines: newLines, + }, nil +} + +func parseRange(s string) (start, count int, err error) { + s = strings.TrimLeft(s, "-+") + count = 1 + if idx := strings.Index(s, ","); idx >= 0 { + start, err = strconv.Atoi(s[:idx]) + if err != nil { + return 0, 0, err + } + count, err = strconv.Atoi(s[idx+1:]) + if err != nil { + return 0, 0, err + } + return start, count, nil + } + start, err = strconv.Atoi(s) + if err != nil { + return 0, 0, err + } + return start, count, nil +} diff --git a/internal/semdiff/diff_test.go b/internal/semdiff/diff_test.go new file mode 100644 index 0000000..864f65e --- /dev/null +++ b/internal/semdiff/diff_test.go @@ -0,0 +1,107 @@ +package semdiff + +import ( + "testing" +) + +const sampleDiff = `diff --git a/foo.go b/foo.go +index 1111111..2222222 100644 +--- a/foo.go ++++ b/foo.go +@@ -1,5 +1,6 @@ + package foo + +-func Old() {} ++func New() {} ++func Helper() {} + +diff --git a/bar.go b/bar.go +new file mode 100644 +index 0000000..3333333 +--- /dev/null ++++ b/bar.go +@@ -0,0 +1,3 @@ ++package bar ++ ++func Bar() {} +` + +func TestParseMultiFile(t *testing.T) { + files, err := Parse(sampleDiff) + if err != nil { + t.Fatalf("Parse: %v", err) + } + if len(files) != 2 { + t.Fatalf("expected 2 files, got %d", len(files)) + } + if files[0].NewPath != "foo.go" || files[0].OldPath != "foo.go" { + t.Errorf("file0 paths: %+v", files[0]) + } + if !files[1].IsNew { + t.Errorf("file1 should be flagged as new: %+v", files[1]) + } + if files[1].NewPath != "bar.go" { + t.Errorf("file1 NewPath = %q", files[1].NewPath) + } + if len(files[0].Hunks) != 1 { + t.Fatalf("expected 1 hunk in foo.go, got %d", len(files[0].Hunks)) + } + h := files[0].Hunks[0] + if len(h.Additions()) != 2 || len(h.Deletions()) != 1 { + t.Errorf("foo.go hunk lines: adds=%v dels=%v", h.Additions(), h.Deletions()) + } +} + +func TestParseHunkHeader(t *testing.T) { + type want struct{ os, ol, ns, nl int } + cases := map[string]want{ + "@@ -1,5 +1,6 @@": {1, 5, 1, 6}, + "@@ -10 +12 @@": {10, 1, 12, 1}, + "@@ -0,0 +1,3 @@ ctx": {0, 0, 1, 3}, + "@@ -100,2 +101,2 @@ fn": {100, 2, 101, 2}, + } + for in, w := range cases { + got, err := parseHunkHeader(in) + if err != nil { + t.Errorf("%q: %v", in, err) + continue + } + if got.OldStart != w.os || got.OldLines != w.ol || got.NewStart != w.ns || got.NewLines != w.nl { + t.Errorf("%q: got %+v want %+v", in, got, w) + } + } +} + +func TestParseRename(t *testing.T) { + d := `diff --git a/old.go b/new.go +similarity index 90% +rename from old.go +rename to new.go +--- a/old.go ++++ b/new.go +@@ -1,2 +1,2 @@ + package foo +-// old ++// new +` + files, err := Parse(d) + if err != nil { + t.Fatalf("Parse: %v", err) + } + if len(files) != 1 || !files[0].IsRename { + t.Fatalf("rename not detected: %+v", files) + } + if files[0].OldPath != "old.go" || files[0].NewPath != "new.go" { + t.Errorf("rename paths: %+v", files[0]) + } +} + +func TestParseEmptyDiff(t *testing.T) { + files, err := Parse("") + if err != nil { + t.Fatalf("Parse: %v", err) + } + if len(files) != 0 { + t.Errorf("expected no files, got %d", len(files)) + } +} diff --git a/internal/semdiff/explainer.go b/internal/semdiff/explainer.go new file mode 100644 index 0000000..0650e82 --- /dev/null +++ b/internal/semdiff/explainer.go @@ -0,0 +1,118 @@ +package semdiff + +import ( + "encoding/json" + "fmt" + "sort" + "strings" +) + +// FileExplanation describes the semantic changes of a single file. +type FileExplanation struct { + Path string `json:"path"` + Kinds []ChangeKind `json:"kinds"` + Added int `json:"added"` + Removed int `json:"removed"` + Note string `json:"note,omitempty"` +} + +// Explanation aggregates classifications across all files in a diff. +type Explanation struct { + Summary string `json:"summary"` + Categories map[ChangeKind]int `json:"categories"` + Files []FileExplanation `json:"files"` +} + +// Explain produces a high-level Explanation from a slice of FileDiffs. +func Explain(files []FileDiff) Explanation { + exp := Explanation{Categories: map[ChangeKind]int{}} + for _, f := range files { + kinds := ClassifyFile(f) + fe := FileExplanation{ + Path: pickPath(f), + Kinds: kinds, + Added: countLines(f, LineAdd), + Removed: countLines(f, LineDelete), + } + switch { + case f.IsNew: + fe.Note = "new file" + case f.IsDelete: + fe.Note = "deleted file" + case f.IsRename: + fe.Note = fmt.Sprintf("renamed from %s", f.OldPath) + } + exp.Files = append(exp.Files, fe) + for _, k := range kinds { + exp.Categories[k]++ + } + } + exp.Summary = summarize(exp) + return exp +} + +func pickPath(f FileDiff) string { + if f.NewPath != "" { + return f.NewPath + } + return f.OldPath +} + +func countLines(f FileDiff, kind LineKind) int { + n := 0 + for _, h := range f.Hunks { + for _, l := range h.Lines { + if l.Kind == kind { + n++ + } + } + } + return n +} + +func summarize(exp Explanation) string { + if len(exp.Files) == 0 { + return "No changes detected." + } + keys := make([]ChangeKind, 0, len(exp.Categories)) + for k := range exp.Categories { + keys = append(keys, k) + } + sort.Slice(keys, func(i, j int) bool { + if exp.Categories[keys[i]] != exp.Categories[keys[j]] { + return exp.Categories[keys[i]] > exp.Categories[keys[j]] + } + return string(keys[i]) < string(keys[j]) + }) + parts := make([]string, 0, len(keys)) + for _, k := range keys { + parts = append(parts, fmt.Sprintf("%s×%d", k, exp.Categories[k])) + } + return fmt.Sprintf("%d file(s) changed: %s", len(exp.Files), strings.Join(parts, ", ")) +} + +// Render returns a human-readable plain-text explanation. +func (e Explanation) Render() string { + var b strings.Builder + fmt.Fprintln(&b, e.Summary) + for _, f := range e.Files { + fmt.Fprintf(&b, "\n%s (+%d/-%d)", f.Path, f.Added, f.Removed) + if f.Note != "" { + fmt.Fprintf(&b, " [%s]", f.Note) + } + fmt.Fprintln(&b) + for _, k := range f.Kinds { + fmt.Fprintf(&b, " - %s\n", k) + } + } + return b.String() +} + +// RenderJSON serializes the explanation as indented JSON. +func (e Explanation) RenderJSON() (string, error) { + out, err := json.MarshalIndent(e, "", " ") + if err != nil { + return "", err + } + return string(out), nil +} diff --git a/internal/semdiff/explainer_test.go b/internal/semdiff/explainer_test.go new file mode 100644 index 0000000..e7a61d0 --- /dev/null +++ b/internal/semdiff/explainer_test.go @@ -0,0 +1,94 @@ +package semdiff + +import ( + "encoding/json" + "strings" + "testing" +) + +func TestExplainAggregates(t *testing.T) { + d := `diff --git a/foo.go b/foo.go +--- a/foo.go ++++ b/foo.go +@@ -1,3 +1,7 @@ + package foo + ++func Added() {} ++func AlsoAdded() {} + +diff --git a/foo_test.go b/foo_test.go +new file mode 100644 +--- /dev/null ++++ b/foo_test.go +@@ -0,0 +1,4 @@ ++package foo ++ ++func TestAdded(t *testing.T) {} +` + files, err := Parse(d) + if err != nil { + t.Fatalf("Parse: %v", err) + } + exp := Explain(files) + if len(exp.Files) != 2 { + t.Fatalf("want 2 file explanations, got %d", len(exp.Files)) + } + if exp.Categories[ChangeAddFunction] < 1 { + t.Errorf("expected AddFunction count >= 1, got %d", exp.Categories[ChangeAddFunction]) + } + if exp.Categories[ChangeAddTest] < 1 { + t.Errorf("expected AddTest count >= 1, got %d", exp.Categories[ChangeAddTest]) + } + if !strings.Contains(exp.Summary, "2 file(s) changed") { + t.Errorf("summary missing file count: %q", exp.Summary) + } +} + +func TestExplainRenderJSON(t *testing.T) { + files, _ := Parse(`diff --git a/x.go b/x.go +--- a/x.go ++++ b/x.go +@@ -1,2 +1,3 @@ + package x + ++func A() {} +`) + exp := Explain(files) + out, err := exp.RenderJSON() + if err != nil { + t.Fatalf("RenderJSON: %v", err) + } + var roundtrip Explanation + if err := json.Unmarshal([]byte(out), &roundtrip); err != nil { + t.Fatalf("Unmarshal: %v", err) + } + if len(roundtrip.Files) != 1 { + t.Errorf("expected 1 file, got %d", len(roundtrip.Files)) + } +} + +func TestExplainEmpty(t *testing.T) { + exp := Explain(nil) + if exp.Summary != "No changes detected." { + t.Errorf("unexpected summary: %q", exp.Summary) + } +} + +func TestExplainRenderHumanReadable(t *testing.T) { + files, _ := Parse(`diff --git a/foo.go b/foo.go +--- a/foo.go ++++ b/foo.go +@@ -1,3 +1,5 @@ + package foo + ++func A() {} ++func B() {} +`) + out := Explain(files).Render() + if !strings.Contains(out, "foo.go") { + t.Errorf("render missing path: %q", out) + } + if !strings.Contains(out, "AddFunction") { + t.Errorf("render missing AddFunction: %q", out) + } +}