diff --git a/CHANGELOG.md b/CHANGELOG.md index 6f035b6..da9a675 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -2,10 +2,31 @@ All notable changes to TagLock will be documented here. +## 0.3.0 - 2026-08-11 + +### Added + +- `triage.Compare(previous, current Summary) Delta` with score, rule, and severity deltas. +- `Delta.IntroducesAt` and `Delta.Clean` for CI policy gates. +- Durable triage snapshots via `triage.WriteSnapshot` and `triage.ReadSnapshot`. +- `taglock triage` command with optional `--baseline` comparison and `--output` snapshot capture. + +### Compatibility + +- Existing analyzer, CLI, snapshot, schema, verification, and evolution APIs are unchanged. +- The release is additive and requires no configuration migration. + ## Unreleased No unreleased changes yet. +## 0.2.0 - 2026-08-04 + +### Added + +- `github.com/theworker02/taglock/triage` package for deterministic diagnostic summaries. +- Weighted risk scores, grouped counts, and `Summary.FailsAt` threshold checks. + ## 0.1.0 - 2026-08-02 ### Added diff --git a/docs/releases/v0.3.0.md b/docs/releases/v0.3.0.md new file mode 100644 index 0000000..43e9636 --- /dev/null +++ b/docs/releases/v0.3.0.md @@ -0,0 +1,30 @@ +# TagLock v0.3.0 — Triage Delta Comparison + +TagLock v0.3.0 extends the triage layer with deterministic deltas and a first-class CLI workflow for CI trend gates. + +## Added + +- `triage.Compare(previous, current Summary) Delta` with score, rule, and severity deltas. +- `Delta.Clean` for exact snapshot-equivalence checks. +- `Delta.IntroducesAt` for typed CI threshold decisions. +- Durable triage snapshots via `triage.WriteSnapshot` and `triage.ReadSnapshot`. +- `taglock triage` command with optional `--baseline` comparison and `--output` snapshot capture. + +## Compatibility + +- Existing analyzer, CLI, snapshot, schema, verification, and evolution APIs are unchanged. +- The feature is additive and requires no configuration migration. +- Go 1.24 remains the minimum supported toolchain. + +## Upgrade + +```bash +go get github.com/theworker02/taglock@v0.3.0 +``` + +## Example + +```bash +taglock triage --output .taglock/triage.json ./... +taglock triage --baseline .taglock/triage.json --fail-on error ./... +``` diff --git a/internal/cli/run.go b/internal/cli/run.go index bfcdfa6..8eaeebf 100644 --- a/internal/cli/run.go +++ b/internal/cli/run.go @@ -31,6 +31,7 @@ import ( "github.com/theworker02/taglock/schema" "github.com/theworker02/taglock/semantics" "github.com/theworker02/taglock/snapshot" + "github.com/theworker02/taglock/triage" "github.com/theworker02/taglock/verify" ) @@ -77,6 +78,8 @@ func Run(arguments []string, stdout, stderr io.Writer) int { return runVerify(rest, stdout, stderr) case "changes": return runChanges(rest, stdout, stderr) + case "triage": + return runTriage(rest, stdout, stderr) case "version": if len(rest) != 0 { fmt.Fprintln(stderr, "taglock: version does not accept arguments") @@ -835,6 +838,142 @@ func runChanges(arguments []string, stdout, stderr io.Writer) int { return ExitOK } +func runTriage(arguments []string, stdout, stderr io.Writer) int { + set := newFlagSet("triage", stderr) + formatValue := set.String("format", "text", "output format: text or json") + failOn := set.String("fail-on", "warning", "minimum severity introduced by a delta that fails") + baselinePath := set.String("baseline", "", "triage snapshot to compare against") + outputPath := set.String("output", "", "write current summary snapshot to path") + configPath := set.String("config", "", "configuration path") + if err := set.Parse(arguments); err != nil { + return ExitUsage + } + threshold, err := rule.ParseSeverity(*failOn) + if err != nil { + fmt.Fprintln(stderr, "taglock:", err) + return ExitUsage + } + if *formatValue != "text" && *formatValue != "json" { + fmt.Fprintf(stderr, "taglock: invalid format %q\n", *formatValue) + return ExitUsage + } + cfg, code := loadConfig(*configPath, stderr) + if code != ExitOK { + return code + } + result, err := engine.Analyze(context.Background(), set.Args(), cfg) + if err != nil { + fmt.Fprintln(stderr, "taglock:", err) + return ExitAnalysis + } + summary := triage.Summarize(result.Diagnostics) + if *outputPath != "" { + file, err := os.Create(*outputPath) + if err != nil { + fmt.Fprintln(stderr, "taglock:", err) + return ExitAnalysis + } + writeErr := triage.WriteSnapshot(file, summary) + closeErr := file.Close() + if writeErr != nil { + fmt.Fprintln(stderr, "taglock:", writeErr) + return ExitAnalysis + } + if closeErr != nil { + fmt.Fprintln(stderr, "taglock:", closeErr) + return ExitAnalysis + } + fmt.Fprintf(stdout, "wrote triage snapshot with %d findings to %s\n", summary.Total, *outputPath) + } + var delta triage.Delta + if *baselinePath != "" { + file, err := os.Open(*baselinePath) + if err != nil { + fmt.Fprintln(stderr, "taglock: open baseline:", err) + return ExitUsage + } + stored, err := triage.ReadSnapshot(file) + file.Close() + if err != nil { + fmt.Fprintln(stderr, "taglock:", err) + return ExitUsage + } + delta = triage.Compare(stored.Summary, summary) + } + if err := writeTriage(stdout, summary, delta, *baselinePath != "", threshold, *formatValue); err != nil { + fmt.Fprintln(stderr, "taglock: write output:", err) + return ExitAnalysis + } + if *baselinePath != "" && delta.IntroducesAt(threshold) { + return ExitViolations + } + return ExitOK +} + +func writeTriage(writer io.Writer, summary triage.Summary, delta triage.Delta, compared bool, threshold rule.Severity, format string) error { + if format == "json" { + payload := struct { + Summary triage.Summary `json:"summary"` + Delta *triage.Delta `json:"delta,omitempty"` + }{Summary: summary} + if compared { + payload.Delta = &delta + } + encoder := json.NewEncoder(writer) + encoder.SetIndent("", " ") + return encoder.Encode(payload) + } + + fmt.Fprintf(writer, "TagLock triage — %d findings, score %d\n", summary.Total, summary.Score) + if summary.Total > 0 { + fmt.Fprintf(writer, "By severity: info=%d warning=%d error=%d\n", + summary.BySeverity["info"], summary.BySeverity["warning"], summary.BySeverity["error"]) + top := summary.TopRules(5) + if len(top) > 0 { + fmt.Fprintln(writer, "Top rules:") + for _, item := range top { + fmt.Fprintf(writer, " %s (%d)\n", item.Rule, item.Count) + } + } + } + if !compared { + return nil + } + status := "PASS" + if delta.IntroducesAt(threshold) { + status = "BLOCK" + } + fmt.Fprintf(writer, "\nDelta %s — score %+d\n", status, delta.ScoreDelta) + if delta.Clean() { + fmt.Fprintln(writer, "No triage changes since the stored snapshot.") + return nil + } + if len(delta.AddedRules) > 0 { + fmt.Fprintf(writer, "Added rules (%d):\n", len(delta.AddedRules)) + for _, item := range delta.AddedRules { + fmt.Fprintf(writer, " %s (%d)\n", item.Rule, item.Count) + } + } + if len(delta.ResolvedRules) > 0 { + fmt.Fprintf(writer, "Resolved rules (%d):\n", len(delta.ResolvedRules)) + for _, item := range delta.ResolvedRules { + fmt.Fprintf(writer, " %s (%d)\n", item.Rule, item.Count) + } + } + if len(delta.SeverityDelta) > 0 { + fmt.Fprintln(writer, "Severity delta:") + keys := make([]string, 0, len(delta.SeverityDelta)) + for key := range delta.SeverityDelta { + keys = append(keys, key) + } + sort.Strings(keys) + for _, key := range keys { + fmt.Fprintf(writer, " %s %+d\n", key, delta.SeverityDelta[key]) + } + } + return nil +} + func readSnapshot(filename string) (snapshot.Snapshot, error) { file, err := os.Open(filename) if err != nil { @@ -911,7 +1050,7 @@ func newFlagSet(name string, stderr io.Writer) *flag.FlagSet { } func writeUsage(writer io.Writer) { fmt.Fprintln(writer, "TagLock — compile-time confidence for Go's runtime metadata") - fmt.Fprintln(writer, "\nUsage:\n taglock check [flags] [packages]\n taglock fix [flags] [packages]\n taglock snapshot [flags] [packages]\n taglock compare \n taglock compare --base REV --head REV [packages]\n taglock migrate json-v2 [packages]\n taglock manifest [packages]\n taglock schema [check] [packages]\n taglock verify [packages]\n taglock changes validate\n taglock rules\n taglock explain \n taglock init\n taglock config \n taglock baseline [packages]\n taglock version") + fmt.Fprintln(writer, "\nUsage:\n taglock check [flags] [packages]\n taglock fix [flags] [packages]\n taglock snapshot [flags] [packages]\n taglock compare \n taglock compare --base REV --head REV [packages]\n taglock migrate json-v2 [packages]\n taglock manifest [packages]\n taglock schema [check] [packages]\n taglock verify [packages]\n taglock changes validate\n taglock triage [flags] [packages]\n taglock rules\n taglock explain \n taglock init\n taglock config \n taglock baseline [packages]\n taglock version") } // MarshalDiagnostics is retained for small CLI integration helpers. diff --git a/triage/delta.go b/triage/delta.go new file mode 100644 index 0000000..231ec2c --- /dev/null +++ b/triage/delta.go @@ -0,0 +1,89 @@ +package triage + +import ( + "sort" + + "github.com/theworker02/taglock/rule" +) + +// Delta describes how a triage summary changed between two scans. +type Delta struct { + ScoreDelta int `json:"score_delta"` + AddedRules []RuleCount `json:"added_rules"` + ResolvedRules []RuleCount `json:"resolved_rules"` + SeverityDelta map[string]int `json:"severity_delta"` +} + +// Compare returns a deterministic delta between previous and current summaries. +func Compare(previous, current Summary) Delta { + delta := Delta{ + ScoreDelta: current.Score - previous.Score, + SeverityDelta: map[string]int{}, + } + + severities := unionSeverityKeys(previous.BySeverity, current.BySeverity) + for _, severity := range severities { + change := current.BySeverity[severity] - previous.BySeverity[severity] + if change != 0 { + delta.SeverityDelta[severity] = change + } + } + + for ruleID, count := range current.ByRule { + if previous.ByRule[ruleID] == 0 && count > 0 { + delta.AddedRules = append(delta.AddedRules, RuleCount{Rule: ruleID, Count: count}) + } + } + for ruleID, count := range previous.ByRule { + if current.ByRule[ruleID] == 0 && count > 0 { + delta.ResolvedRules = append(delta.ResolvedRules, RuleCount{Rule: ruleID, Count: count}) + } + } + + sortRuleCounts(delta.AddedRules) + sortRuleCounts(delta.ResolvedRules) + return delta +} + +// Clean reports whether the current summary matches the previous summary. +func (d Delta) Clean() bool { + return d.ScoreDelta == 0 && len(d.AddedRules) == 0 && len(d.ResolvedRules) == 0 && len(d.SeverityDelta) == 0 +} + +// IntroducesAt reports whether the delta adds diagnostics at or above threshold. +func (d Delta) IntroducesAt(threshold rule.Severity) bool { + if threshold == rule.SeverityOff { + return false + } + for severity := threshold; severity <= rule.SeverityError; severity++ { + if d.SeverityDelta[severity.String()] > 0 { + return true + } + } + return false +} + +func unionSeverityKeys(left, right map[string]int) []string { + keys := map[string]bool{} + for key := range left { + keys[key] = true + } + for key := range right { + keys[key] = true + } + result := make([]string, 0, len(keys)) + for key := range keys { + result = append(result, key) + } + sort.Strings(result) + return result +} + +func sortRuleCounts(counts []RuleCount) { + sort.Slice(counts, func(i, j int) bool { + if counts[i].Count != counts[j].Count { + return counts[i].Count > counts[j].Count + } + return counts[i].Rule < counts[j].Rule + }) +} diff --git a/triage/delta_test.go b/triage/delta_test.go new file mode 100644 index 0000000..4cfe170 --- /dev/null +++ b/triage/delta_test.go @@ -0,0 +1,67 @@ +package triage + +import ( + "reflect" + "testing" + + "github.com/theworker02/taglock/rule" +) + +func TestCompare(t *testing.T) { + previous := Summary{ + Score: 5, + BySeverity: map[string]int{"warning": 1, "error": 0}, + ByRule: map[string]int{"TAG003": 1}, + } + current := Summary{ + Score: 17, + BySeverity: map[string]int{"warning": 1, "error": 1}, + ByRule: map[string]int{"TAG003": 1, "TAG104": 1}, + } + + delta := Compare(previous, current) + if delta.ScoreDelta != 12 { + t.Fatalf("score delta = %d, want 12", delta.ScoreDelta) + } + if delta.SeverityDelta["error"] != 1 { + t.Fatalf("severity delta = %#v", delta.SeverityDelta) + } + if len(delta.AddedRules) != 1 || delta.AddedRules[0].Rule != "TAG104" { + t.Fatalf("added rules = %#v", delta.AddedRules) + } + if len(delta.ResolvedRules) != 0 { + t.Fatalf("resolved rules = %#v", delta.ResolvedRules) + } + if !delta.IntroducesAt(rule.SeverityError) { + t.Fatal("expected error introduction") + } + if delta.Clean() { + t.Fatal("non-empty delta reported clean") + } +} + +func TestCompareResolvedRules(t *testing.T) { + previous := Summary{ByRule: map[string]int{"TAG003": 2, "TAG104": 1}} + current := Summary{ByRule: map[string]int{"TAG003": 2}} + + delta := Compare(previous, current) + want := []RuleCount{{Rule: "TAG104", Count: 1}} + if got := delta.ResolvedRules; !reflect.DeepEqual(got, want) { + t.Fatalf("ResolvedRules = %#v, want %#v", got, want) + } +} + +func TestCompareClean(t *testing.T) { + summary := Summary{ + Score: 4, + BySeverity: map[string]int{"warning": 1}, + ByRule: map[string]int{"TAG003": 1}, + } + delta := Compare(summary, summary) + if !delta.Clean() { + t.Fatalf("identical summaries produced %#v", delta) + } + if delta.IntroducesAt(rule.SeverityWarning) { + t.Fatal("identical summaries introduced findings") + } +} diff --git a/triage/snapshot.go b/triage/snapshot.go new file mode 100644 index 0000000..10ff4a8 --- /dev/null +++ b/triage/snapshot.go @@ -0,0 +1,44 @@ +package triage + +import ( + "encoding/json" + "fmt" + "io" + "time" +) + +// SnapshotFormatVersion is the supported on-disk triage snapshot schema version. +const SnapshotFormatVersion = 1 + +// Snapshot is a durable triage summary used for CI delta comparisons. +type Snapshot struct { + FormatVersion int `json:"format_version"` + CreatedAt time.Time `json:"created_at"` + Summary Summary `json:"summary"` +} + +// WriteSnapshot persists summary as a versioned triage snapshot file. +func WriteSnapshot(writer io.Writer, summary Summary) error { + snapshot := Snapshot{ + FormatVersion: SnapshotFormatVersion, + CreatedAt: time.Now().UTC(), + Summary: summary, + } + encoder := json.NewEncoder(writer) + encoder.SetIndent("", " ") + return encoder.Encode(snapshot) +} + +// ReadSnapshot loads a snapshot file written by WriteSnapshot. +func ReadSnapshot(reader io.Reader) (Snapshot, error) { + var snapshot Snapshot + decoder := json.NewDecoder(reader) + decoder.DisallowUnknownFields() + if err := decoder.Decode(&snapshot); err != nil { + return Snapshot{}, fmt.Errorf("decode triage snapshot: %w", err) + } + if snapshot.FormatVersion != SnapshotFormatVersion { + return Snapshot{}, fmt.Errorf("unsupported triage snapshot version %d", snapshot.FormatVersion) + } + return snapshot, nil +}