Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
16 commits
Select commit Hold shift + click to select a range
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) // #nosec G304 -- path is constructed by caller from known filesystem entries
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 @@ -449,7 +452,7 @@ func (PatchTool) Execute(ctx context.Context, input json.RawMessage) (string, er
}
}

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/structured_edit.go
Original file line number Diff line number Diff line change
Expand Up @@ -82,6 +82,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