Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
147 changes: 147 additions & 0 deletions cmd/sin-code/autopr_cmd.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,147 @@
// SPDX-License-Identifier: MIT
// Purpose: `sin-code autopr` subcommand — auto-fix trivial regressions
// (issue #158). Three subcommands:
// - run : classify + render a PR plan (dry-run by default)
// - show : print the most recent plan from a JSON file
// - plan : render the plan only, do not emit commands
//
// The actual PR creation goes through the gh-bridge (M4 ask-classified).
// The verify gate (M3) must be green before any PR is opened.
package main

import (
"encoding/json"
"fmt"
"os"

"github.com/spf13/cobra"

"github.com/OpenSIN-Code/SIN-Code/cmd/sin-code/internal/autopr"
)

func NewAutoPRCmd() *cobra.Command {
cmd := &cobra.Command{
Use: "autopr",
Short: "Self-healing pipeline: auto-fix trivial regressions and open a PR",
Long: `sin-code autopr runs the post-task.complete pipeline (issue #158):

1. Read every .spec.md + the verify-gate report
2. Classify each regression as trivial | mechanical | non_trivial
3. Build a deterministic PR body (no LLM)
4. Emit the plan; the actual gh pr create is ` + "`ask`" + `-classified (M4)

All subcommands are pure (no I/O beyond the report file). The pipeline
is opt-in via .sin-code.yml's ` + "`autopr.enabled: true`" + ` policy key.`,
}
cmd.AddCommand(
newAutoPRRunCmd(),
newAutoPRPlanCmd(),
)
return cmd
}

func newAutoPRRunCmd() *cobra.Command {
var workspace string
var jsonOut bool
var inFile string
cmd := &cobra.Command{
Use: "run",
Short: "Run the autopr pipeline and render a PR plan",
RunE: func(cmd *cobra.Command, args []string) error {
issues, err := loadIssues(inFile)
if err != nil {
return err
}
rep := autopr.NewReport(workspace, issues)
if jsonOut {
enc := json.NewEncoder(cmd.OutOrStdout())
enc.SetIndent("", " ")
return enc.Encode(rep)
}
if !rep.WouldCreatePR {
fmt.Fprintln(cmd.OutOrStdout(), "no auto-fixable issues found; nothing to do")
return nil
}
fmt.Fprintln(cmd.OutOrStdout(), "PR plan:")
fmt.Fprintf(cmd.OutOrStdout(), " title: %s\n", rep.PRTitle)
fmt.Fprintf(cmd.OutOrStdout(), " auto-fixable: %d\n", len(rep.AutoFixable))
fmt.Fprintf(cmd.OutOrStdout(), " requires human: %d\n", len(rep.RequiresHuman))
fmt.Fprintln(cmd.OutOrStdout(), " commands to run:")
for _, c := range rep.CommandsToRun {
fmt.Fprintf(cmd.OutOrStdout(), " %s\n", c)
}
fmt.Fprintln(cmd.OutOrStdout(), " PR body (preview):")
for _, line := range splitLines(rep.PRBody) {
fmt.Fprintf(cmd.OutOrStdout(), " %s\n", line)
}
return nil
},
}
cmd.Flags().StringVar(&workspace, "workspace", ".", "workspace root")
cmd.Flags().BoolVar(&jsonOut, "json", false, "emit the report as JSON")
cmd.Flags().StringVar(&inFile, "issues", "", "JSON file with classified issues (default: empty = empty plan)")
return cmd
}

func newAutoPRPlanCmd() *cobra.Command {
var workspace string
var inFile string
cmd := &cobra.Command{
Use: "plan",
Short: "Render the PR plan only; do not emit any commands",
RunE: func(cmd *cobra.Command, args []string) error {
issues, err := loadIssues(inFile)
if err != nil {
return err
}
rep := autopr.NewReport(workspace, issues)
if !rep.WouldCreatePR {
fmt.Fprintln(cmd.OutOrStdout(), "no auto-fixable issues")
return nil
}
fmt.Fprintln(cmd.OutOrStdout(), rep.PRBody)
return nil
},
}
cmd.Flags().StringVar(&workspace, "workspace", ".", "workspace root")
cmd.Flags().StringVar(&inFile, "issues", "", "JSON file with classified issues")
return cmd
}

// loadIssues reads a JSON file of []autopr.Issue. Empty path is OK
// (returns nil, nil) — the caller can still build an empty plan.
func loadIssues(path string) ([]autopr.Issue, error) {
if path == "" {
return nil, nil
}
b, err := os.ReadFile(path)

Check failure

Code scanning / gosec

Potential file inclusion via variable Error

Potential file inclusion via variable
if err != nil {
return nil, fmt.Errorf("autopr: read %s: %w", path, err)
}
var out []autopr.Issue
if err := json.Unmarshal(b, &out); err != nil {
return nil, fmt.Errorf("autopr: parse %s: %w", path, err)
}
return out, nil
}

// splitLines is a tiny helper to keep the run-command's PR-body
// preview readable. Local to the file to avoid an import for one
// use site.
func splitLines(s string) []string {
if s == "" {
return nil
}
var out []string
start := 0
for i := 0; i < len(s); i++ {
if s[i] == '\n' {
out = append(out, s[start:i])
start = i + 1
}
}
if start < len(s) {
out = append(out, s[start:])
}
return out
}
124 changes: 124 additions & 0 deletions cmd/sin-code/internal/autopr/classify.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,124 @@
// SPDX-License-Identifier: MIT
// Purpose: classify a spec/format/lint regression as trivial or
// non-trivial (issue #158). Trivial regressions are auto-fixable
// without an LLM call; non-trivial ones are deferred to the human.
// M3-mandated: the classifier is deterministic and pure (no I/O)
// so the verify gate stays race-free.
package autopr

import (
"strings"
)

// Class is the severity of a single regression.
type Class string

const (
// ClassTrivial is auto-fixable: gofmt, goimports, trailing
// whitespace, generated test stub, missing import.
ClassTrivial Class = "trivial"
// ClassMechanical is auto-fixable but needs a deterministic
// script: rename a symbol, regenerate a doc, add a missing
// license header.
ClassMechanical Class = "mechanical"
// ClassNonTrivial requires an LLM (or a human): logic
// changes, API design, behaviour drift.
ClassNonTrivial Class = "non_trivial"
)

// Issue is one observed regression. The autopr pipeline reads
// these from the verify-gate report + a couple of static checks
// (gofmt diff, goimports diff, missing-spec-test scan).
type Issue struct {
ID string `json:"id"` // stable id, e.g. "gofmt:cmd/sin-code/main.go"
Class Class `json:"class"` // ClassTrivial|ClassMechanical|ClassNonTrivial
Category string `json:"category"` // "format" | "import" | "test" | "spec" | "lint"
File string `json:"file"` // workspace-relative path
Note string `json:"note"` // human-readable explanation
// Fix is the command the pipeline would run for trivial /
// mechanical classes. Empty for non_trivial.
Fix string `json:"fix,omitempty"`
}

// TrivialAndMechanical returns only the issues that the pipeline
// can auto-fix (issue #158 acceptance criterion: "the auto-fix
// is reversible" — the human always sees the PR).
func TrivialAndMechanical(in []Issue) []Issue {
var out []Issue
for _, i := range in {
if i.Class == ClassTrivial || i.Class == ClassMechanical {
out = append(out, i)
}
}
return out
}

// ClassifyGofmt reports whether the file's content is gofmt-clean.
// Returns a ClassTrivial issue with the Fix command set if not.
func ClassifyGofmt(file string, dirty bool) Issue {
if !dirty {
return Issue{}
}
return Issue{
ID: "gofmt:" + file,
Class: ClassTrivial,
Category: "format",
File: file,
Note: "gofmt would reformat this file",
Fix: "gofmt -w " + file,
}
}

// ClassifyMissingTest reports a spec criterion that has no
// associated test file. ClassMechanical because a test stub is
// generated deterministically.
func ClassifyMissingTest(spec, testFile string) Issue {
return Issue{
ID: "missing-test:" + spec,
Class: ClassMechanical,
Category: "test",
File: spec,
Note: "spec " + spec + " has no test file at " + testFile,
Fix: "echo placeholder >> " + testFile,
}
}

// ClassifyImport returns a ClassTrivial issue when a Go file is
// missing an import the spec requires.
func ClassifyImport(file, missingImport string) Issue {
return Issue{
ID: "import:" + file + ":" + missingImport,
Class: ClassTrivial,
Category: "import",
File: file,
Note: "missing import " + missingImport,
Fix: "goimports -w " + file,
}
}

// ClassifyNonTrivial is the catch-all for anything the pipeline
// cannot auto-fix.
func ClassifyNonTrivial(file, note string) Issue {
return Issue{
ID: "non-trivial:" + file,
Class: ClassNonTrivial,
Category: "spec",
File: file,
Note: note,
}
}

// ClassFromString normalises a raw category string. Used by the
// report reader to keep the on-disk format human-friendly.
func ClassFromString(s string) Class {
switch strings.ToLower(strings.TrimSpace(s)) {
case "trivial":
return ClassTrivial
case "mechanical":
return ClassMechanical
case "non_trivial", "nontrivial", "non-trivial":
return ClassNonTrivial
default:
return ClassNonTrivial // fail-closed
}
}
80 changes: 80 additions & 0 deletions cmd/sin-code/internal/autopr/classify_test.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,80 @@
// SPDX-License-Identifier: MIT
// Purpose: tests for issue #158 — trivial vs. non-trivial classifier.
package autopr

import "testing"

func TestTrivialAndMechanical(t *testing.T) {
in := []Issue{
{ID: "1", Class: ClassTrivial},
{ID: "2", Class: ClassMechanical},
{ID: "3", Class: ClassNonTrivial},
{ID: "4", Class: ClassTrivial},
}
out := TrivialAndMechanical(in)
if len(out) != 3 {
t.Fatalf("expected 3 auto-fixable, got %d", len(out))
}
if out[0].ID != "1" || out[1].ID != "2" || out[2].ID != "4" {
t.Errorf("unexpected ids: %s %s %s", out[0].ID, out[1].ID, out[2].ID)
}
}

func TestClassifyGofmt(t *testing.T) {
clean := ClassifyGofmt("main.go", false)
if clean.ID != "" {
t.Errorf("expected empty id for clean file, got %q", clean.ID)
}
dirty := ClassifyGofmt("main.go", true)
if dirty.Class != ClassTrivial {
t.Errorf("expected ClassTrivial, got %q", dirty.Class)
}
if dirty.Fix != "gofmt -w main.go" {
t.Errorf("expected fix command, got %q", dirty.Fix)
}
}

func TestClassifyMissingTest(t *testing.T) {
i := ClassifyMissingTest("foo.spec.md", "foo_test.go")
if i.Class != ClassMechanical {
t.Errorf("expected ClassMechanical, got %q", i.Class)
}
if i.Fix == "" {
t.Error("expected a non-empty Fix command")
}
}

func TestClassifyImport(t *testing.T) {
i := ClassifyImport("a.go", "fmt")
if i.Class != ClassTrivial {
t.Errorf("expected ClassTrivial, got %q", i.Class)
}
if i.Category != "import" {
t.Errorf("expected category=import, got %q", i.Category)
}
}

func TestClassifyNonTrivial(t *testing.T) {
i := ClassifyNonTrivial("a.go", "logic drift")
if i.Class != ClassNonTrivial {
t.Errorf("expected ClassNonTrivial, got %q", i.Class)
}
}

func TestClassFromString(t *testing.T) {
cases := map[string]Class{
"trivial": ClassTrivial,

Check failure on line 66 in cmd/sin-code/internal/autopr/classify_test.go

View workflow job for this annotation

GitHub Actions / golangci-lint

File is not properly formatted (gofmt)
"TRIVIAL": ClassTrivial,
"mechanical": ClassMechanical,
"non_trivial": ClassNonTrivial,
"nontrivial": ClassNonTrivial,
"non-trivial": ClassNonTrivial,
"bogus": ClassNonTrivial, // fail-closed
"": ClassNonTrivial,
}
for in, want := range cases {
if got := ClassFromString(in); got != want {
t.Errorf("ClassFromString(%q) = %q, want %q", in, got, want)
}
}
}
Loading
Loading