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
21 changes: 21 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
30 changes: 30 additions & 0 deletions docs/releases/v0.3.0.md
Original file line number Diff line number Diff line change
@@ -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 ./...
```
141 changes: 140 additions & 1 deletion internal/cli/run.go
Original file line number Diff line number Diff line change
Expand Up @@ -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"
)

Expand Down Expand Up @@ -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")
Expand Down Expand Up @@ -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 {
Expand Down Expand Up @@ -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 <base.json> <head.json>\n taglock compare --base REV --head REV [packages]\n taglock migrate json-v2 [packages]\n taglock manifest <package|module> [packages]\n taglock schema [check] [packages]\n taglock verify <generate|run> [packages]\n taglock changes validate\n taglock rules\n taglock explain <rule-id>\n taglock init\n taglock config <validate|print>\n taglock baseline <create|update> [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 <base.json> <head.json>\n taglock compare --base REV --head REV [packages]\n taglock migrate json-v2 [packages]\n taglock manifest <package|module> [packages]\n taglock schema [check] [packages]\n taglock verify <generate|run> [packages]\n taglock changes validate\n taglock triage [flags] [packages]\n taglock rules\n taglock explain <rule-id>\n taglock init\n taglock config <validate|print>\n taglock baseline <create|update> [packages]\n taglock version")
}

// MarshalDiagnostics is retained for small CLI integration helpers.
Expand Down
89 changes: 89 additions & 0 deletions triage/delta.go
Original file line number Diff line number Diff line change
@@ -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
})
}
67 changes: 67 additions & 0 deletions triage/delta_test.go
Original file line number Diff line number Diff line change
@@ -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")
}
}
Loading