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
13 changes: 10 additions & 3 deletions internal/engine/integration.go
Original file line number Diff line number Diff line change
Expand Up @@ -2,11 +2,13 @@ package engine

import (
"fmt"
"path/filepath"
"strings"
"sync"
"time"

"github.com/GrayCodeAI/hawk/internal/engine/ctxmgr"
"github.com/GrayCodeAI/hawk/internal/home"
"github.com/GrayCodeAI/hawk/internal/types"
"github.com/GrayCodeAI/tok"
)
Expand Down Expand Up @@ -189,6 +191,11 @@ type SessionSummary struct {
// NewIntegrationPipeline initializes all subsystems and returns a ready-to-use
// pipeline orchestrator.
func NewIntegrationPipeline() *IntegrationPipeline {
// Resolve the user's home dir once so the learning-pipeline stores do not
// leak into <cwd>/.hawk/ when hawk is run from inside its own source tree.
// See L2 in docs/plans/fix-critical-and-high-review.md.
homeRoot := home.Dir()

return &IntegrationPipeline{
// Pre-query
IntentClassifier: NewIntentClassifier(),
Expand All @@ -214,9 +221,9 @@ func NewIntegrationPipeline() *IntegrationPipeline {
OutputRedactor: NewOutputRedactor(),

// Learning
ExperienceStore: NewExperienceStore(".hawk/experience"),
KnowledgeBase: NewKnowledgeBase(".hawk/knowledge"),
FeedbackCollector: NewFeedbackCollector(".hawk/feedback"),
ExperienceStore: NewExperienceStore(filepath.Join(homeRoot, ".hawk", "experience")),
KnowledgeBase: NewKnowledgeBase(filepath.Join(homeRoot, ".hawk", "knowledge")),
FeedbackCollector: NewFeedbackCollector(filepath.Join(homeRoot, ".hawk", "feedback")),
SelfAssessor: NewSelfAssessor(),

// Session management
Expand Down
51 changes: 51 additions & 0 deletions internal/engine/l2_home_paths_test.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,51 @@
package engine

import (
"os"
"path/filepath"
"strings"
"testing"
)

// TestL2PipelineStatePathsAreHomeRelative is a regression guard for L2 —
// the three learning-pipeline stores (ExperienceStore, KnowledgeBase,
// FeedbackCollector) created by NewIntegrationPipeline must write to
// ~/.hawk/{experience,knowledge,feedback}/, not to <cwd>/.hawk/...
//
// Pre-fix, NewIntegrationPipeline passed the literal strings
// ".hawk/experience", ".hawk/knowledge", ".hawk/feedback" to those
// constructors, which leaked into <cwd>/cmd/.hawk/ when hawk was run
// from its own source tree.
func TestL2PipelineStatePathsAreHomeRelative(t *testing.T) {
home, err := os.UserHomeDir()
if err != nil {
t.Fatalf("os.UserHomeDir: %v", err)
}
if home == "" {
t.Fatal("os.UserHomeDir returned empty string")
}
wantPrefix := filepath.Clean(home) + string(filepath.Separator)

check := func(name, got string) {
t.Helper()
if !filepath.IsAbs(got) {
t.Errorf("%s: path %q is not absolute", name, got)
return
}
if !strings.HasPrefix(got, wantPrefix) && !strings.HasPrefix(got, filepath.Clean(home)) {
t.Errorf("%s: path %q does not start with home dir %q", name, got, home)
}
}

p := NewIntegrationPipeline()
if p == nil {
t.Fatal("NewIntegrationPipeline returned nil")
}
if p.ExperienceStore == nil || p.KnowledgeBase == nil || p.FeedbackCollector == nil {
t.Fatal("NewIntegrationPipeline left a learning-pipeline store nil")
}

check("ExperienceStore.Dir", p.ExperienceStore.Dir)
check("KnowledgeBase.Dir", p.KnowledgeBase.Dir)
check("FeedbackCollector.Dir", p.FeedbackCollector.Dir)
}
5 changes: 4 additions & 1 deletion internal/engine/semantic_diff.go
Original file line number Diff line number Diff line change
Expand Up @@ -967,7 +967,10 @@ func formatNode(fset *token.FileSet, node ast.Node) string {
case *ast.Ident:
return t.Name
default:
return formatFieldType(node.(ast.Expr)) //nolint:errcheck
if expr, ok := node.(ast.Expr); ok {
return formatFieldType(expr)
}
return "unknown"
}
}

Expand Down
18 changes: 18 additions & 0 deletions internal/engine/semantic_diff_test.go
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
package engine

import (
"go/ast"
"strings"
"testing"
)
Expand Down Expand Up @@ -691,3 +692,20 @@ func TestGenerateSummaryNoAPIs(t *testing.T) {
t.Error("should not contain Affected APIs when there are none")
}
}

// TestFormatNodeNonExprRegression guards H10 — a non-ast.Expr node (e.g. *ast.Comment)
// must not panic; the comma-ok form should fall through to "unknown".
func TestFormatNodeNonExprRegression(t *testing.T) {
// *ast.Comment is ast.Node but not ast.Expr. Pre-fix this panicked
// with "interface conversion: *ast.Comment is not ast.Expr".
defer func() {
if r := recover(); r != nil {
t.Fatalf("formatNode panicked on non-Expr node: %v", r)
}
}()

got := formatNode(nil, &ast.Comment{Text: "x"})
if got != "unknown" {
t.Errorf("formatNode(*ast.Comment) = %q, want %q", got, "unknown")
}
}
6 changes: 3 additions & 3 deletions internal/permissions/canonicalize.go
Original file line number Diff line number Diff line change
Expand Up @@ -31,7 +31,7 @@ var BannedPrefixes = []string{
}

// cosmetic flags that don't affect safety
var cosmenticFlags = map[string]bool{
var cosmeticFlags = map[string]bool{
"--color": true,
"--no-color": true,
"-v": true,
Expand Down Expand Up @@ -142,7 +142,7 @@ func (c *Canonicalizer) canonicalizeSingle(cmd string) string {
// Strip cosmetic flags
var filtered []string
for _, tok := range tokens {
if !cosmenticFlags[tok] {
if !cosmeticFlags[tok] {
filtered = append(filtered, tok)
}
}
Expand Down Expand Up @@ -324,7 +324,7 @@ func (c *Canonicalizer) GeneratePattern(command string) string {
tok := tokens[argIdx]
if strings.HasPrefix(tok, "-") {
// Skip cosmetic flags from the pattern
if !cosmenticFlags[tok] {
if !cosmeticFlags[tok] {
prefix = append(prefix, tok)
}
argIdx++
Expand Down
50 changes: 50 additions & 0 deletions internal/snapshot/l2_home_paths_test.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,50 @@
package snapshot

import (
"os"
"path/filepath"
"strings"
"testing"
)

// TestL2DefaultPathsAreHomeRelative is a regression guard for L2 — when the
// state-store constructors are called with empty/zero args, their default
// paths must be absolute and live under the user's home dir
// (~/.hawk/...), not relative to <cwd>. Pre-fix, the defaults were strings
// like ".hawk/snapshots" and ".hawk/experience" which leaked into
// <cwd>/cmd/.hawk/ when hawk was run from its own source tree.
func TestL2DefaultPathsAreHomeRelative(t *testing.T) {
home, err := os.UserHomeDir()
if err != nil {
t.Fatalf("os.UserHomeDir: %v", err)
}
if home == "" {
t.Fatal("os.UserHomeDir returned empty string")
}

// Sanitize HOME so we can compare reliably (filepath.Clean strips
// trailing separators).
wantPrefix := filepath.Clean(home) + string(filepath.Separator)

check := func(name, got string) {
t.Helper()
if !filepath.IsAbs(got) {
t.Errorf("%s: default path %q is not absolute", name, got)
return
}
// On macOS temp dirs may live under /private/var/... while HOME
// resolves to /var/...; compare both forms.
if !strings.HasPrefix(got, wantPrefix) && !strings.HasPrefix(got, filepath.Clean(home)) {
t.Errorf("%s: default path %q does not start with home dir %q", name, got, home)
}
}

// NewSnapshotStore("") default
ss := NewSnapshotStore("")
check("NewSnapshotStore", ss.Dir)

// New(<projectDir>) default — shadowDir is now home-relative, not
// relative to projectDir.
tracker := New(t.TempDir())
check("New(tracker)", tracker.shadowDir)
}
7 changes: 6 additions & 1 deletion internal/snapshot/snapshot.go
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,8 @@ import (
"strings"
"sync"
"time"

"github.com/GrayCodeAI/hawk/internal/home"
)

// Tracker maintains a shadow git repository that records every file change
Expand Down Expand Up @@ -36,10 +38,13 @@ type FileDiff struct {
}

// New creates a Tracker for the given project directory.
// The shadow git repository lives under the user's home dir (~/.hawk/snapshots)
// rather than under projectDir, so that running hawk from inside a Go project
// root no longer creates a nested <cwd>/cmd/.hawk/ tree at runtime.
func New(projectDir string) *Tracker {
return &Tracker{
projectDir: projectDir,
shadowDir: filepath.Join(projectDir, ".hawk", "snapshots"),
shadowDir: filepath.Join(home.Dir(), ".hawk", "snapshots"),
}
}

Expand Down
4 changes: 2 additions & 2 deletions internal/snapshot/snapshot_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -51,8 +51,8 @@ func TestTracker_Init(t *testing.T) {
t.Fatalf("Second Init failed: %v", err)
}

if _, err := os.Stat(filepath.Join(dir, ".hawk", "snapshots", ".git")); err != nil {
t.Error("shadow git repo not initialized")
if _, err := os.Stat(filepath.Join(tracker.shadowDir, ".git")); err != nil {
t.Errorf("shadow git repo not initialized at %s: %v", tracker.shadowDir, err)
}
}

Expand Down
8 changes: 6 additions & 2 deletions internal/snapshot/workspace.go
Original file line number Diff line number Diff line change
Expand Up @@ -16,6 +16,8 @@ import (
"strings"
"sync"
"time"

"github.com/GrayCodeAI/hawk/internal/home"
)

// WorkspaceSnapshot captures the full state of a project at a point in time.
Expand Down Expand Up @@ -68,10 +70,12 @@ var ignoredDirs = map[string]bool{
}

// NewSnapshotStore creates a new SnapshotStore with the given directory.
// If dir is empty, defaults to ".hawk/snapshots/".
// If dir is empty, defaults to "~/.hawk/snapshots" (the user's home dir) so
// that state does not leak into <cwd>/.hawk/ when hawk is run from inside
// a Go project root.
func NewSnapshotStore(dir string) *SnapshotStore {
if dir == "" {
dir = filepath.Join(".hawk", "snapshots")
dir = filepath.Join(home.Dir(), ".hawk", "snapshots")
}
return &SnapshotStore{
Dir: dir,
Expand Down
9 changes: 8 additions & 1 deletion internal/snapshot/workspace_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -602,7 +602,14 @@ func TestRestore_PreservesGitDir(t *testing.T) {

func TestNewSnapshotStore_DefaultDir(t *testing.T) {
store := NewSnapshotStore("")
expected := filepath.Join(".hawk", "snapshots")
// L2: the default path is now home-relative (~/.hawk/snapshots) so
// state stops leaking into <cwd>/.hawk/ when hawk is run from
// inside a Go project root.
home, err := os.UserHomeDir()
if err != nil {
t.Fatalf("os.UserHomeDir: %v", err)
}
expected := filepath.Join(home, ".hawk", "snapshots")
if store.Dir != expected {
t.Errorf("expected default dir %q, got %q", expected, store.Dir)
}
Expand Down
Loading