diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 7284c67eb..02efc8517 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -47,6 +47,8 @@ jobs: with: go-version: ${{ env.GO_VERSION }} cache: true + - name: Boundary guard + run: bash ./scripts/check-ecosystem-boundaries.sh - name: gofumpt diff run: | go install mvdan.cc/gofumpt@v0.10.0 @@ -71,6 +73,8 @@ jobs: with: go-version: ${{ env.GO_VERSION }} cache: true + - name: Boundary guard + run: bash ./scripts/check-ecosystem-boundaries.sh - uses: golangci/golangci-lint-action@4afd733a84b1f43292c63897423277bb7f4313a9 # v7.0.0 with: version: v2.1.0 @@ -90,6 +94,8 @@ jobs: with: go-version: ${{ env.GO_VERSION }} cache: true + - name: Boundary guard + run: bash ./scripts/check-ecosystem-boundaries.sh - name: Tidy check run: | go mod tidy diff --git a/ARCHITECTURE.md b/ARCHITECTURE.md index ca2cfac40..91d1b7cba 100644 --- a/ARCHITECTURE.md +++ b/ARCHITECTURE.md @@ -246,7 +246,6 @@ type PipelineContext struct { | `skills/` | 5 Claude-style agent skills (`tok`, `tok-commit`, `tok-compress`, `tok-help`, `tok-review`) | `SKILL.md` per skill | | `benchmarks/` | Benchmark harness (run.sh + results.md template) | `run.sh` | | `evals/` | Prompt-compression eval | `pipeline-bench.sh`, `prompts/en.txt` | -| `types/` | Cross-eco exported types (mirrors hawk's `shared/types/`) | `finding.go`, `severity.go` | --- diff --git a/Makefile b/Makefile index 1292bd73f..203dcddbd 100644 --- a/Makefile +++ b/Makefile @@ -27,9 +27,12 @@ GOVULNCHECK := $(GOBIN_DIR)/govulncheck # --------------------------------------------------------------------------- # Phony declarations (alphabetical). # --------------------------------------------------------------------------- -.PHONY: all bench build ci clean cover cover-new fmt help lint lint-fix \ +.PHONY: all bench boundaries build ci clean cover cover-new fmt help lint lint-fix \ security test test-10x test-new test-race tidy version vet +boundaries: ## Enforce support-repo import boundaries. + bash ./scripts/check-ecosystem-boundaries.sh + # --------------------------------------------------------------------------- # Default target. # --------------------------------------------------------------------------- @@ -99,7 +102,7 @@ tidy: ## Tidy go.mod / go.sum. # --------------------------------------------------------------------------- # Composite gate used by CI and pre-push. # --------------------------------------------------------------------------- -ci: tidy fmt vet lint test-race security ## Run everything CI runs. +ci: tidy fmt vet lint boundaries test-race security ## Run everything CI runs. @echo "All CI checks passed." # --------------------------------------------------------------------------- diff --git a/README.md b/README.md index 5dc549d58..f565e2e9c 100644 --- a/README.md +++ b/README.md @@ -22,6 +22,14 @@ tok is a **library**, not a CLI. It exposes token-efficiency primitives as a cle It is consumed directly as a Go module, and it powers the `tok` commands inside [Hawk](https://github.com/GrayCodeAI/hawk) (`hawk tok ...`), which imports it as a library. +## Ecosystem Boundaries + +tok is a Hawk support engine. Keep the dependency edge one-way: + +- depend on `hawk-core-contracts` when a stable cross-repo contract is needed +- do not import `hawk/internal/*` +- do not import removed legacy path `hawk/shared/types`; use `hawk-core-contracts/types` + --- ## Install diff --git a/lefthook.yml b/lefthook.yml index ba5700d84..7d5bdaf09 100644 --- a/lefthook.yml +++ b/lefthook.yml @@ -110,3 +110,18 @@ commit-msg: echo " full guide: https://www.conventionalcommits.org/" exit 1 fi + + strip-co-authored-by: + run: | + # Strip Co-authored-by: trailers that AI tools (Claude, Cursor, etc.) add. + # This enforces the rule that commits list only the human author. + sed '/^[Cc]o-[Aa]uthored-[Bb]y:/d' "{1}" > "{1}.tmp" && mv "{1}.tmp" "{1}" + +# --------------------------------------------------------------------------- +# prepare-commit-msg — strip AI co-author trailers after tools inject them. +# --------------------------------------------------------------------------- +prepare-commit-msg: + commands: + strip-co-authored-by: + run: | + sed '/^[Cc]o-[Aa]uthored-[Bb]y:/d' "{1}" > "{1}.tmp" && mv "{1}.tmp" "{1}" diff --git a/scripts/check-ecosystem-boundaries.sh b/scripts/check-ecosystem-boundaries.sh new file mode 100644 index 000000000..57f81ba4d --- /dev/null +++ b/scripts/check-ecosystem-boundaries.sh @@ -0,0 +1,21 @@ +#!/usr/bin/env bash +set -euo pipefail + +ROOT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)" +cd "$ROOT_DIR" + +if command -v rg >/dev/null 2>&1; then + violations="$(rg -n 'github\.com/GrayCodeAI/hawk/(internal/|shared/types)' --glob '*.go' . || true)" +else + violations="$(grep -rn --include='*.go' -E 'github\.com/GrayCodeAI/hawk/(internal/|shared/types)' . || true)" +fi + +if [[ -n "${violations}" ]]; then + echo "forbidden Hawk imports found:" + echo "${violations}" + echo + echo "support repos must use hawk-core-contracts or local contracts, not hawk/internal or removed hawk/shared/types" + exit 1 +fi + +echo "ecosystem boundary guard passed" diff --git a/types/finding.go b/types/finding.go deleted file mode 100644 index 3589f1ca7..000000000 --- a/types/finding.go +++ /dev/null @@ -1,156 +0,0 @@ -package types - -import ( - "fmt" - "time" -) - -// Finding represents a unified code-analysis concern sourced from sight, inspect, or manual review. -type Finding struct { - ID string `json:"id"` - Source string `json:"source"` // sight, inspect, manual - Concern string `json:"concern"` // e.g. "sql-injection", "broken-auth" - Severity Severity `json:"severity"` - File string `json:"file,omitempty"` - URL string `json:"url,omitempty"` - Line int `json:"line,omitempty"` - EndLine int `json:"end_line,omitempty"` - Message string `json:"message"` - CWE string `json:"cwe,omitempty"` - Confidence float64 `json:"confidence"` - Fix string `json:"fix,omitempty"` - Reasoning string `json:"reasoning,omitempty"` - Tags []string `json:"tags,omitempty"` - Metadata map[string]string `json:"metadata,omitempty"` - CreatedAt time.Time `json:"created_at"` -} - -// FindingSlice is a sortable slice of Findings. -// Sort order: severity descending, then confidence descending. -type FindingSlice []Finding - -func (s FindingSlice) Len() int { return len(s) } - -func (s FindingSlice) Less(i, j int) bool { - if s[i].Severity != s[j].Severity { - return s[i].Severity > s[j].Severity // higher severity first - } - return s[i].Confidence > s[j].Confidence // higher confidence first -} - -func (s FindingSlice) Swap(i, j int) { s[i], s[j] = s[j], s[i] } - -// FilterBySource returns findings whose Source matches the given value. -func (s FindingSlice) FilterBySource(source string) FindingSlice { - out := make(FindingSlice, 0, len(s)) - for _, f := range s { - if f.Source == source { - out = append(out, f) - } - } - return out -} - -// FilterBySeverity returns findings whose Severity is at least min. -func (s FindingSlice) FilterBySeverity(min Severity) FindingSlice { - out := make(FindingSlice, 0, len(s)) - for _, f := range s { - if f.Severity.AtLeast(min) { - out = append(out, f) - } - } - return out -} - -// FilterByConfidence returns findings whose Confidence is >= min. -func (s FindingSlice) FilterByConfidence(min float64) FindingSlice { - out := make(FindingSlice, 0, len(s)) - for _, f := range s { - if f.Confidence >= min { - out = append(out, f) - } - } - return out -} - -// ByFile groups findings by their File field. -func (s FindingSlice) ByFile() map[string]FindingSlice { - m := make(map[string]FindingSlice, len(s)) - for _, f := range s { - m[f.File] = append(m[f.File], f) - } - return m -} - -// FindingSummary provides aggregate counts over a set of findings. -type FindingSummary struct { - Total int `json:"total"` - BySource map[string]int `json:"by_source"` - BySeverity map[string]int `json:"by_severity"` - AvgConfidence float64 `json:"avg_confidence"` -} - -// Summary returns a FindingSummary for the slice. -func (s FindingSlice) Summary() FindingSummary { - bySrc := make(map[string]int) - bySev := make(map[string]int) - var confSum float64 - - for _, f := range s { - bySrc[f.Source]++ - bySev[f.Severity.String()]++ - confSum += f.Confidence - } - - avg := 0.0 - if len(s) > 0 { - avg = confSum / float64(len(s)) - } - - return FindingSummary{ - Total: len(s), - BySource: bySrc, - BySeverity: bySev, - AvgConfidence: avg, - } -} - -// FindingFromSight constructs a Finding from a sight (AST/static-analysis) result. -func FindingFromSight( - concern, file string, - line int, - message, cwe string, - sev Severity, - confidence float64, -) Finding { - return Finding{ - ID: fmt.Sprintf("sight:%s:%s:%d", concern, file, line), - Source: "sight", - Concern: concern, - Severity: sev, - File: file, - Line: line, - Message: message, - CWE: cwe, - Confidence: confidence, - CreatedAt: time.Now(), - } -} - -// FindingFromInspect constructs a Finding from an inspect (linting/analysis) result. -func FindingFromInspect( - concern, url, message string, - sev Severity, - tags []string, -) Finding { - return Finding{ - ID: fmt.Sprintf("inspect:%s:%s", concern, url), - Source: "inspect", - Concern: concern, - Severity: sev, - URL: url, - Message: message, - Tags: tags, - CreatedAt: time.Now(), - } -} diff --git a/types/finding_test.go b/types/finding_test.go deleted file mode 100644 index 1cf05fa6e..000000000 --- a/types/finding_test.go +++ /dev/null @@ -1,225 +0,0 @@ -package types_test - -import ( - "sort" - "testing" - - "github.com/GrayCodeAI/tok/types" -) - -func TestFindingSlice_SortBySeverityThenConfidence(t *testing.T) { - t.Parallel() - findings := types.FindingSlice{ - {ID: "a", Severity: types.SeverityLow, Confidence: 0.9}, - {ID: "b", Severity: types.SeverityCritical, Confidence: 0.7}, - {ID: "c", Severity: types.SeverityCritical, Confidence: 0.95}, - {ID: "d", Severity: types.SeverityMedium, Confidence: 0.8}, - } - sort.Sort(findings) - - if findings[0].ID != "c" { - t.Fatalf("expected first finding to be c (critical, 0.95), got %s", findings[0].ID) - } - if findings[1].ID != "b" { - t.Fatalf("expected second finding to be b (critical, 0.7), got %s", findings[1].ID) - } - if findings[2].ID != "d" { - t.Fatalf("expected third finding to be d (medium), got %s", findings[2].ID) - } - if findings[3].ID != "a" { - t.Fatalf("expected fourth finding to be a (low), got %s", findings[3].ID) - } -} - -func TestFilterBySource(t *testing.T) { - t.Parallel() - findings := types.FindingSlice{ - {ID: "1", Source: "sight"}, - {ID: "2", Source: "inspect"}, - {ID: "3", Source: "sight"}, - {ID: "4", Source: "manual"}, - } - - got := findings.FilterBySource("sight") - if len(got) != 2 { - t.Fatalf("expected 2 sight findings, got %d", len(got)) - } - for _, f := range got { - if f.Source != "sight" { - t.Fatalf("expected source sight, got %s", f.Source) - } - } -} - -func TestFilterBySeverity(t *testing.T) { - t.Parallel() - findings := types.FindingSlice{ - {ID: "1", Severity: types.SeverityInfo}, - {ID: "2", Severity: types.SeverityHigh}, - {ID: "3", Severity: types.SeverityCritical}, - {ID: "4", Severity: types.SeverityLow}, - {ID: "5", Severity: types.SeverityMedium}, - } - - got := findings.FilterBySeverity(types.SeverityHigh) - if len(got) != 2 { - t.Fatalf("expected 2 findings >= high, got %d", len(got)) - } -} - -func TestFilterByConfidence(t *testing.T) { - t.Parallel() - findings := types.FindingSlice{ - {ID: "1", Confidence: 0.9}, - {ID: "2", Confidence: 0.5}, - {ID: "3", Confidence: 0.7}, - {ID: "4", Confidence: 0.3}, - } - - got := findings.FilterByConfidence(0.7) - if len(got) != 2 { - t.Fatalf("expected 2 findings with confidence >= 0.7, got %d", len(got)) - } - for _, f := range got { - if f.Confidence < 0.7 { - t.Fatalf("expected confidence >= 0.7, got %f", f.Confidence) - } - } -} - -func TestByFile(t *testing.T) { - t.Parallel() - findings := types.FindingSlice{ - {ID: "1", File: "a.go"}, - {ID: "2", File: "b.go"}, - {ID: "3", File: "a.go"}, - {ID: "4", File: ""}, - } - - grouped := findings.ByFile() - if len(grouped["a.go"]) != 2 { - t.Fatalf("expected 2 findings in a.go, got %d", len(grouped["a.go"])) - } - if len(grouped["b.go"]) != 1 { - t.Fatalf("expected 1 finding in b.go, got %d", len(grouped["b.go"])) - } - if len(grouped[""]) != 1 { - t.Fatalf("expected 1 finding with empty file, got %d", len(grouped[""])) - } -} - -func TestSummary(t *testing.T) { - t.Parallel() - findings := types.FindingSlice{ - {ID: "1", Source: "sight", Severity: types.SeverityHigh, Confidence: 0.8}, - {ID: "2", Source: "sight", Severity: types.SeverityLow, Confidence: 0.6}, - {ID: "3", Source: "inspect", Severity: types.SeverityHigh, Confidence: 1.0}, - } - - summary := findings.Summary() - if summary.Total != 3 { - t.Fatalf("expected total 3, got %d", summary.Total) - } - if summary.BySource["sight"] != 2 { - t.Fatalf("expected 2 sight findings, got %d", summary.BySource["sight"]) - } - if summary.BySource["inspect"] != 1 { - t.Fatalf("expected 1 inspect finding, got %d", summary.BySource["inspect"]) - } - if summary.BySeverity["high"] != 2 { - t.Fatalf("expected 2 high severity, got %d", summary.BySeverity["high"]) - } - if summary.BySeverity["low"] != 1 { - t.Fatalf("expected 1 low severity, got %d", summary.BySeverity["low"]) - } - // avg confidence = (0.8 + 0.6 + 1.0) / 3 = 0.8 - if summary.AvgConfidence < 0.79 || summary.AvgConfidence > 0.81 { - t.Fatalf("expected avg confidence ~0.8, got %f", summary.AvgConfidence) - } -} - -func TestFindingFromSight(t *testing.T) { - t.Parallel() - f := types.FindingFromSight("sql-injection", "db.go", 42, "unparameterized query", "CWE-89", types.SeverityCritical, 0.95) - - if f.ID != "sight:sql-injection:db.go:42" { - t.Fatalf("unexpected ID: %s", f.ID) - } - if f.Source != "sight" { - t.Fatalf("expected source sight, got %s", f.Source) - } - if f.Concern != "sql-injection" { - t.Fatalf("expected concern sql-injection, got %s", f.Concern) - } - if f.Severity != types.SeverityCritical { - t.Fatalf("expected severity critical, got %v", f.Severity) - } - if f.File != "db.go" { - t.Fatalf("expected file db.go, got %s", f.File) - } - if f.Line != 42 { - t.Fatalf("expected line 42, got %d", f.Line) - } - if f.CWE != "CWE-89" { - t.Fatalf("expected CWE-89, got %s", f.CWE) - } - if f.Confidence != 0.95 { - t.Fatalf("expected confidence 0.95, got %f", f.Confidence) - } - if f.CreatedAt.IsZero() { - t.Fatal("expected CreatedAt to be set") - } -} - -func TestFindingFromInspect(t *testing.T) { - t.Parallel() - tags := []string{"security", "injection"} - f := types.FindingFromInspect("broken-auth", "https://example.com/api", "missing auth header", types.SeverityHigh, tags) - - if f.ID != "inspect:broken-auth:https://example.com/api" { - t.Fatalf("unexpected ID: %s", f.ID) - } - if f.Source != "inspect" { - t.Fatalf("expected source inspect, got %s", f.Source) - } - if f.URL != "https://example.com/api" { - t.Fatalf("expected url, got %s", f.URL) - } - if f.Severity != types.SeverityHigh { - t.Fatalf("expected severity high, got %v", f.Severity) - } - if len(f.Tags) != 2 || f.Tags[0] != "security" { - t.Fatalf("unexpected tags: %v", f.Tags) - } - if f.CreatedAt.IsZero() { - t.Fatal("expected CreatedAt to be set") - } -} - -func TestEmptySliceHandling(t *testing.T) { - t.Parallel() - var empty types.FindingSlice - - if empty.Len() != 0 { - t.Fatalf("expected len 0, got %d", empty.Len()) - } - if len(empty.FilterBySource("x")) != 0 { - t.Fatal("expected empty filter result") - } - if len(empty.FilterBySeverity(types.SeverityCritical)) != 0 { - t.Fatal("expected empty filter result") - } - if len(empty.FilterByConfidence(0.5)) != 0 { - t.Fatal("expected empty filter result") - } - if len(empty.ByFile()) != 0 { - t.Fatal("expected empty ByFile map") - } - summary := empty.Summary() - if summary.Total != 0 { - t.Fatalf("expected total 0, got %d", summary.Total) - } - if summary.AvgConfidence != 0.0 { - t.Fatalf("expected avg confidence 0, got %f", summary.AvgConfidence) - } -} diff --git a/types/severity.go b/types/severity.go deleted file mode 100644 index 4bb6c6e37..000000000 --- a/types/severity.go +++ /dev/null @@ -1,67 +0,0 @@ -// Package types defines stable, dependency-free shared types for the GrayCodeAI -// ecosystem (sight, inspect, hawk, …). It lives in tok — a pure leaf library that -// depends on nothing — so any module can import these types without depending on -// the hawk umbrella product, which would otherwise create an import cycle. -package types - -import "strings" - -// Severity represents the impact level of a finding. -type Severity int - -const ( - SeverityInfo Severity = iota - SeverityLow - SeverityMedium - SeverityHigh - SeverityCritical -) - -var severityNames = [...]string{"info", "low", "medium", "high", "critical"} - -func (s Severity) String() string { - if int(s) < len(severityNames) { - return severityNames[s] - } - return "unknown" -} - -// ParseSeverity converts a string to a Severity. -func ParseSeverity(s string) Severity { - switch strings.ToLower(strings.TrimSpace(s)) { - case "critical": - return SeverityCritical - case "high": - return SeverityHigh - case "medium": - return SeverityMedium - case "low": - return SeverityLow - default: - return SeverityInfo - } -} - -// AtLeast returns true if s >= threshold. -func (s Severity) AtLeast(threshold Severity) bool { - return s >= threshold -} - -// TokenSeverity defines rule severity for compression error patterns. -type TokenSeverity string - -const ( - TokenSeverityCritical TokenSeverity = "critical" - TokenSeverityHigh TokenSeverity = "high" - TokenSeverityMedium TokenSeverity = "medium" - TokenSeverityLow TokenSeverity = "low" -) - -// AuditSeverity indicates how dangerous a security audit finding is. -type AuditSeverity string - -const ( - AuditSeverityCritical AuditSeverity = "CRITICAL" - AuditSeverityWarning AuditSeverity = "WARNING" - AuditSeverityInfo AuditSeverity = "INFO" -) diff --git a/types/severity_test.go b/types/severity_test.go deleted file mode 100644 index f9bac0674..000000000 --- a/types/severity_test.go +++ /dev/null @@ -1,27 +0,0 @@ -package types_test - -import ( - "testing" - - "github.com/GrayCodeAI/tok/types" -) - -func TestParseSeverity_forwarded(t *testing.T) { - t.Parallel() - cases := []struct { - in string - want types.Severity - }{ - {"critical", types.SeverityCritical}, - {"high", types.SeverityHigh}, - {"medium", types.SeverityMedium}, - {"low", types.SeverityLow}, - {"info", types.SeverityInfo}, - {"unknown", types.SeverityInfo}, - } - for _, tc := range cases { - if got := types.ParseSeverity(tc.in); got != tc.want { - t.Fatalf("ParseSeverity(%q) = %v, want %v", tc.in, got, tc.want) - } - } -}