Skip to content
Closed
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
7 changes: 7 additions & 0 deletions .github/workflows/compatibility-matrix.yml
Original file line number Diff line number Diff line change
Expand Up @@ -23,6 +23,10 @@ jobs:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
- uses: ./.github/actions/checkout-eyrie
with:
ref: ${{ github.head_ref || github.ref_name }}
allow_branch_fallback: "true"
- uses: actions/setup-go@4a3601121dd01d1626a1e23e37211e3254c1c06c # v6.4.0
with:
go-version: "1.26.5"
Expand All @@ -31,3 +35,6 @@ jobs:
run: make compat-check
- name: Report 'next' matrix
run: make compat-test
- name: Pin freshness vs external/ (advisory)
run: make compat-drift
continue-on-error: true
5 changes: 4 additions & 1 deletion Makefile
Original file line number Diff line number Diff line change
Expand Up @@ -235,14 +235,17 @@ help: ## Show this help.
# Validates compatibility-matrix.json and reports the resolved versions for
# a chosen matrix entry. Wired into the compatibility-test workflow.
# ---------------------------------------------------------------------------
.PHONY: compat-test compat-check
.PHONY: compat-test compat-check compat-drift

compat-test: ## Validate testdata/compatibility-matrix.json and report the 'next' matrix.
@go run ./cmd/compat-test -matrix=next -file=testdata/compatibility-matrix.json

compat-check: ## Strict validation — non-zero exit if any component lacks a version.
@go run ./cmd/compat-test -matrix=next -strict -file=testdata/compatibility-matrix.json

compat-drift: ## Advisory: report pin drift between hawk's go.mod and external/ submodules. Never fails.
@go run ./cmd/compat-test -check-external -file=testdata/compatibility-matrix.json

.PHONY: hooks sync-submodules sync-clone
hooks: ## Install git hooks via lefthook (formatting, linting, conventional commits).
@command -v lefthook >/dev/null 2>&1 || (echo "install: go install github.com/evilmartians/lefthook@latest" && exit 1)
Expand Down
79 changes: 79 additions & 0 deletions cmd/compat-test/drift.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,79 @@
package main

import (
"fmt"
"os"
"path/filepath"

"golang.org/x/mod/modfile"
)

// trackedPins are the shared leaf dependencies most likely to drift silently:
// a consumer (inspect, sight, ...) can pin an older version than what hawk's
// own go.mod requires, and Go's minimal version selection will silently pull
// in hawk's newer version at build time without the consumer's own CI ever
// having tested it. See docs/compatibility.md.
var trackedPins = []string{
"github.com/GrayCodeAI/hawk-core-contracts",
"github.com/GrayCodeAI/hawk-mcpkit",
}

// checkDrift compares hawk's own go.mod requirements for trackedPins against
// what each external/ submodule (a locally pinned clone of a hawk dependency)
// declares for the same modules in its own go.mod. It never fails — this is
// advisory, printed for humans/CI logs to notice, not a build gate.
func checkDrift(repoRoot string) error {
hawkRequires, err := readRequires(filepath.Join(repoRoot, "go.mod"))
if err != nil {
return fmt.Errorf("read hawk go.mod: %w", err)
}

externalDir := filepath.Join(repoRoot, "external")
entries, err := os.ReadDir(externalDir)
if err != nil {
return fmt.Errorf("read external/: %w", err)
}

fmt.Println("Pin freshness (advisory — see docs/compatibility.md):")
drifted := 0
for _, e := range entries {
if !e.IsDir() {
continue
}
modPath := filepath.Join(externalDir, e.Name(), "go.mod")
consumerRequires, err := readRequires(modPath)
if err != nil {
continue // submodule not checked out / no go.mod — skip silently
}
for _, pin := range trackedPins {
hawkVer, hawkHas := hawkRequires[pin]
consumerVer, consumerHas := consumerRequires[pin]
if !hawkHas || !consumerHas || hawkVer == consumerVer {
continue
}
drifted++
fmt.Printf(" %-22s requires %s@%s, hawk requires %s@%s\n",
e.Name(), pin, consumerVer, pin, hawkVer)
}
}
if drifted == 0 {
fmt.Println(" OK — no drift between hawk's pins and external/ consumers")
}
return nil
}

func readRequires(path string) (map[string]string, error) {
raw, err := os.ReadFile(path)
if err != nil {
return nil, err
}
mf, err := modfile.Parse(path, raw, nil)
if err != nil {
return nil, err
}
out := make(map[string]string, len(mf.Require))
for _, r := range mf.Require {
out[r.Mod.Path] = r.Mod.Version
}
return out, nil
}
15 changes: 15 additions & 0 deletions cmd/compat-test/main.go
Original file line number Diff line number Diff line change
Expand Up @@ -17,6 +17,11 @@
// go run ./cmd/compat-test -matrix=stable -strict
// # exit non-zero if any
// # component lacks a version
// go run ./cmd/compat-test -check-external # advisory: compare hawk's own
// # go.mod pins for shared leaf
// # deps against what each
// # external/ submodule declares.
// # Always exits 0; see drift.go.
package main

import (
Expand Down Expand Up @@ -47,12 +52,22 @@ func main() {
matrixName := flag.String("matrix", "next", "matrix entry to inspect (next, stable, ...)")
strict := flag.Bool("strict", false, "exit non-zero if any component lacks a pinned version")
path := flag.String("file", findMatrixFile(), "path to compatibility-matrix.json")
checkExternal := flag.Bool("check-external", false, "advisory: report pin drift against external/ submodules and exit (see drift.go)")
flag.Parse()

if *path == "" {
die("compatibility-matrix.json not found in current dir or repo root")
}

if *checkExternal {
repoRoot := filepath.Dir(filepath.Dir(*path)) // testdata/compatibility-matrix.json -> repo root
if err := checkDrift(repoRoot); err != nil {
// Advisory tool: report the problem but still exit 0.
fmt.Fprintf(os.Stderr, "compat-test: check-external: %v\n", err)
}
return
}

raw, err := os.ReadFile(*path)
if err != nil {
die("read %s: %v", *path, err)
Expand Down
25 changes: 25 additions & 0 deletions docs/compatibility.md
Original file line number Diff line number Diff line change
Expand Up @@ -68,6 +68,31 @@ It runs on:
- **Drift is visible.** A component that's never updated in `next` but is
fine in `stable` shows up as a drift candidate in CI reports.

## Pin freshness (advisory)

Separate from the matrix above: `hawk`'s own `go.mod` directly pins a couple
of shared leaf dependencies (currently `hawk-core-contracts`), and several
`external/` submodule consumers (`inspect`, `sight`, ...) pin the *same*
dependencies independently in their own `go.mod`. Go's minimal version
selection means whatever `hawk` pins wins in `hawk`'s own build — but if a
consumer's own pin is older, that consumer's CI has never actually tested the
version that ships. This is exactly the kind of drift that let a real bug
through in July 2026 (a stale goreleaser pin sat unnoticed until the first
tag push exercised it).

`make compat-drift` (wired into this workflow as an advisory, non-blocking
step) reports any such mismatch by comparing `hawk/go.mod` against each
`external/<repo>/go.mod`:

```bash
make compat-drift
```

This **never fails the build** — it's a signal for humans to notice and
re-pin the consumer, not a gate. It depends on `external/` submodules being
checked out (the workflow does this via `checkout-eyrie`); running it without
that will just report nothing to check.

## Validating the file

The file is validated against [`testdata/compatibility-matrix.schema.json`](./testdata/compatibility-matrix.schema.json)
Expand Down
2 changes: 1 addition & 1 deletion go.mod

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

7 changes: 5 additions & 2 deletions internal/tool/patch.go
Original file line number Diff line number Diff line change
Expand Up @@ -243,9 +243,12 @@ func Apply(patch *FilePatch) error {
}

// ApplyAll applies all patches and returns the list of modified file paths.
func (p *PatchParser) ApplyAll() ([]string, error) {
func (p *PatchParser) ApplyAll(ctx context.Context) ([]string, error) {
var modified []string
for i := range p.patches {
if err := validatePathAllowed(ctx, p.patches[i].Path); err != nil {
return modified, err
}
if err := Apply(&p.patches[i]); err != nil {
return modified, fmt.Errorf("failed to apply patch for %s: %w", p.patches[i].Path, err)
}
Expand Down Expand Up @@ -440,7 +443,7 @@ func (PatchTool) Execute(ctx context.Context, input json.RawMessage) (string, er
return "", fmt.Errorf("failed to parse patch: %w", err)
}

modified, err := parser.ApplyAll()
modified, err := parser.ApplyAll(ctx)
if err != nil {
return "", err
}
Expand Down
30 changes: 29 additions & 1 deletion internal/tool/patch_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -473,7 +473,7 @@ func TestApplyAll(t *testing.T) {
t.Fatalf("parse error: %v", err)
}

modified, err := parser.ApplyAll()
modified, err := parser.ApplyAll(context.Background())
if err != nil {
t.Fatalf("ApplyAll error: %v", err)
}
Expand Down Expand Up @@ -567,6 +567,34 @@ func main() {
}
}

func TestPatchTool_Execute_BlocksPathOutsideAllowedDirectories(t *testing.T) {
root := t.TempDir()
outside := t.TempDir()
outsidePath := filepath.Join(outside, "escape.go")

orig, _ := os.Getwd()
if err := os.Chdir(root); err != nil {
t.Fatal(err)
}
defer func() { _ = os.Chdir(orig) }()

patchContent := "*** Begin Patch\n" +
"*** Create File: " + outsidePath + "\n" +
"+ package main\n" +
"*** End Patch"

input, _ := json.Marshal(map[string]string{"patch": patchContent})

guarded := WithToolContext(context.Background(), &ToolContext{})
tool := PatchTool{}
if _, err := tool.Execute(guarded, input); err == nil {
t.Fatal("expected error for path outside allowed directories")
}
if _, statErr := os.Stat(outsidePath); !os.IsNotExist(statErr) {
t.Fatal("expected file outside allowed directories to not be created")
}
}

func TestPatchTool_Interface(t *testing.T) {
var _ Tool = PatchTool{}

Expand Down
3 changes: 3 additions & 0 deletions internal/tool/smart_create.go
Original file line number Diff line number Diff line change
Expand Up @@ -680,6 +680,9 @@ func (t *SmartCreateTool) Execute(ctx context.Context, input json.RawMessage) (s
if params.Path == "" {
return "", fmt.Errorf("path is required")
}
if err := validatePathAllowed(ctx, params.Path); err != nil {
return "", err
}

// Generate boilerplate.
content := t.Creator.GenerateBoilerplate(params.Path)
Expand Down
3 changes: 3 additions & 0 deletions internal/tool/structured_edit.go
Original file line number Diff line number Diff line change
Expand Up @@ -79,6 +79,9 @@ func (s StructuredEditTool) Execute(ctx context.Context, input json.RawMessage)
if len(p.Blocks) == 0 {
return "", fmt.Errorf("at least one SEARCH/REPLACE block is required")
}
if err := validatePathAllowed(ctx, p.Path); err != nil {
return "", err
}

// Read the file.
data, err := os.ReadFile(p.Path)
Expand Down
Loading