diff --git a/blast_radius.go b/blast_radius.go index 3f8013f..4f9e580 100644 --- a/blast_radius.go +++ b/blast_radius.go @@ -1278,8 +1278,21 @@ func renderImportersReportString(report scanner.ImportersReport) string { return buf.String() } +// renderImportersReportCLI is the interactive `--importers` output: unlike the +// token-budgeted blast-radius bundle (which omits empty sections), a human ran +// this command and deserves an explanation when the answer is "none". +func renderImportersReportCLI(w io.Writer, report scanner.ImportersReport) { + if len(report.Importers) == 0 && len(report.HubImports) == 0 { + fmt.Fprintf(w, "No files import %s.\n", report.File) + fmt.Fprintln(w, " Note: files in the same package never import each other (Go resolves") + fmt.Fprintln(w, " imports at package level), so only cross-package importers appear here.") + return + } + renderImportersReport(w, report) +} + func renderImportersReport(w io.Writer, report scanner.ImportersReport) { - if len(report.Importers) >= 3 { + if scanner.CountHubImporters(report.Importers) >= scanner.HubThreshold { fmt.Fprintf(w, "⚠️ HUB FILE: %s\n", report.File) fmt.Fprintf(w, " Imported by %d files - changes have wide impact!\n", len(report.Importers)) fmt.Fprintln(w) @@ -1327,7 +1340,7 @@ func buildImportersReportFromGraph(root, file string, fg *scanner.FileGraph) sca Importers: importers, Imports: imports, ImporterCount: len(importers), - IsHub: len(importers) >= 3, + IsHub: fg.IsHub(file), } for _, imp := range imports { diff --git a/cmd/agent_edits.go b/cmd/agent_edits.go new file mode 100644 index 0000000..ee313b9 --- /dev/null +++ b/cmd/agent_edits.go @@ -0,0 +1,140 @@ +package cmd + +import ( + "bufio" + "bytes" + "encoding/json" + "os" + "path/filepath" + "strings" + "time" +) + +// The watch daemon sees every write on disk — including git merges, checkouts, +// and generated files — so daemon state alone cannot say what an agent +// actually edited. The post-edit hook is the only place that knows a write +// came from an agent tool call, so it appends a record here and the display +// layer partitions "this agent edited" from "changed on disk". +const ( + agentEditsFile = "agent_edits.jsonl" + agentEditsMaxBytes = 256 * 1024 + agentEditRecency = 48 * time.Hour +) + +type agentEditRecord struct { + Path string `json:"path"` + Session string `json:"session,omitempty"` + At time.Time `json:"at"` +} + +type agentEdits struct { + paths map[string]bool // repo-relative slash paths, any session + bySession map[string]map[string]bool // session id -> paths +} + +func agentEditsPath(root string) string { + return filepath.Join(root, ".codemap", agentEditsFile) +} + +// normalizeAgentEditPath converts hook-provided paths (often absolute, with +// OS-native separators) to the repo-relative slash form used across codemap. +func normalizeAgentEditPath(root, path string) string { + if filepath.IsAbs(path) { + if rel, err := filepath.Rel(root, path); err == nil && !strings.HasPrefix(rel, "..") { + path = rel + } + } + return filepath.ToSlash(path) +} + +// recordAgentEdit appends one edit record. Appends are line-buffered and +// O_APPEND so concurrent short-lived hook processes interleave safely on +// every supported platform. +func recordAgentEdit(root, sessionID, path string, now time.Time) error { + if strings.TrimSpace(path) == "" { + return nil + } + if err := os.MkdirAll(filepath.Join(root, ".codemap"), 0o755); err != nil { + return err + } + record := agentEditRecord{ + Path: normalizeAgentEditPath(root, path), + Session: strings.TrimSpace(sessionID), + At: now.UTC(), + } + data, err := json.Marshal(record) + if err != nil { + return err + } + logPath := agentEditsPath(root) + compactAgentEdits(logPath, now) + f, err := os.OpenFile(logPath, os.O_APPEND|os.O_CREATE|os.O_WRONLY, 0o644) + if err != nil { + return err + } + if _, err := f.Write(append(data, '\n')); err != nil { + f.Close() + return err + } + return f.Close() +} + +// compactAgentEdits bounds the log by rewriting it with only recent records +// once it grows past the cap. Best-effort: a concurrently appended record can +// be lost during the rewrite, which is acceptable for display hints. +func compactAgentEdits(logPath string, now time.Time) { + info, err := os.Stat(logPath) + if err != nil || info.Size() <= agentEditsMaxBytes { + return + } + data, err := os.ReadFile(logPath) + if err != nil { + return + } + cutoff := now.Add(-agentEditRecency) + var kept bytes.Buffer + scanner := bufio.NewScanner(bytes.NewReader(data)) + for scanner.Scan() { + var record agentEditRecord + if json.Unmarshal(scanner.Bytes(), &record) != nil { + continue + } + if record.At.Before(cutoff) { + continue + } + kept.Write(scanner.Bytes()) + kept.WriteByte('\n') + } + _ = writeFileAtomic(logPath, kept.Bytes(), 0o644) +} + +// loadAgentEdits reads records at or after `since`. Missing or malformed logs +// yield an empty (never nil) result. +func loadAgentEdits(root string, since time.Time) agentEdits { + edits := agentEdits{ + paths: make(map[string]bool), + bySession: make(map[string]map[string]bool), + } + data, err := os.ReadFile(agentEditsPath(root)) + if err != nil { + return edits + } + scanner := bufio.NewScanner(bytes.NewReader(data)) + for scanner.Scan() { + var record agentEditRecord + if json.Unmarshal(scanner.Bytes(), &record) != nil { + continue + } + if record.At.Before(since) { + continue + } + edits.paths[record.Path] = true + if record.Session != "" { + if edits.bySession[record.Session] == nil { + edits.bySession[record.Session] = make(map[string]bool) + } + edits.bySession[record.Session][record.Path] = true + } + } + return edits +} diff --git a/cmd/config.go b/cmd/config.go index 114c87e..fbfb56b 100644 --- a/cmd/config.go +++ b/cmd/config.go @@ -31,6 +31,11 @@ var nonCodeExtensions = map[string]bool{ "svg": true, "png": true, "jpg": true, "jpeg": true, "gif": true, "ico": true, "woff": true, "woff2": true, "ttf": true, "eot": true, "map": true, "license": true, + // Runtime and build residue: never worth an `only` slot even when a repo + // has many of them (codemap's own repo once auto-detected "log"). + "log": true, "jsonl": true, "pid": true, "tmp": true, + "bak": true, "out": true, "cache": true, "swp": true, + "gitignore": true, "gitattributes": true, "editorconfig": true, } // RunConfig dispatches the "config" subcommand. diff --git a/cmd/config_more_test.go b/cmd/config_more_test.go index 144681b..d3df180 100644 --- a/cmd/config_more_test.go +++ b/cmd/config_more_test.go @@ -139,3 +139,41 @@ func mustWriteConfigFixture(t *testing.T, path string, content string) { t.Fatalf("write %s: %v", path, err) } } + +func TestInitProjectConfigSkipsNoiseExtensions(t *testing.T) { + root := t.TempDir() + files := map[string]string{ + "main.go": "package main\n", + "util.go": "package main\n", + "a/one.log": "log line\n", + "a/two.log": "log line\n", + "a/three.log": "log line\n", + "b/daemon.pid": "123\n", + "b/backup.bak": "old\n", + "b/scratch.tmp": "x\n", + "b/build.out": "x\n", + } + for path, content := range files { + full := filepath.Join(root, path) + if err := os.MkdirAll(filepath.Dir(full), 0o755); err != nil { + t.Fatal(err) + } + if err := os.WriteFile(full, []byte(content), 0o644); err != nil { + t.Fatal(err) + } + } + + result, err := initProjectConfig(root) + if err != nil { + t.Fatalf("initProjectConfig: %v", err) + } + for _, ext := range result.TopExts { + switch ext { + case "log", "pid", "bak", "tmp", "out": + t.Fatalf("noise extension %q auto-included in config: %v", ext, result.TopExts) + } + } + if len(result.TopExts) != 1 || result.TopExts[0] != "go" { + t.Fatalf("TopExts = %v, want [go]", result.TopExts) + } +} diff --git a/cmd/hooks.go b/cmd/hooks.go index c0f7ec5..497be6f 100644 --- a/cmd/hooks.go +++ b/cmd/hooks.go @@ -698,10 +698,22 @@ func hookPreEdit(root string) error { // hookPostEdit shows impact after editing (reads JSON from stdin) func hookPostEdit(root string) error { - filePaths, err := extractFilePathsFromStdin() - if err != nil || len(filePaths) == 0 { + input, err := io.ReadAll(os.Stdin) + if err != nil { return nil } + filePaths := parseHookFilePaths(input) + if len(filePaths) == 0 { + return nil + } + + // Record provenance: these writes came from an agent tool call, which is + // the signal the watch daemon can't observe on its own. + sessionID := sessionIDFromHookInput(input) + now := time.Now() + for _, filePath := range filePaths { + _ = recordAgentEdit(root, sessionID, filePath, now) + } for _, filePath := range filePaths { if err := checkFileImportersWithPhase(root, filePath, "after"); err != nil { @@ -778,15 +790,19 @@ func hookPromptSubmit(root string) error { filesMentioned := extractMentionedFiles(prompt, topK) intent := classifyIntent(prompt, filesMentioned, info, projCfg) - // Emit structured intent marker (machine-readable for tools) - emitIntentMarker(intent) + // Emit structured intent marker (machine-readable for tools) only when the + // classification is trustworthy — a zero/low-confidence category is a + // guess, and downstream advice derived from it misleads the agent. + if intent.Confidence >= intentConfidenceFloor { + emitIntentMarker(intent) + } // Human-readable file context (backwards compatible) var output []string if info != nil { for _, file := range filesMentioned { if importers := info.Importers[file]; len(importers) > 0 { - if len(importers) >= 3 { + if scanner.CountHubImporters(importers) >= scanner.HubThreshold { output = append(output, fmt.Sprintf(" ⚠️ %s is a HUB (imported by %d files)", file, len(importers))) } else { output = append(output, fmt.Sprintf(" 📍 %s (imported by %d files)", file, len(importers))) @@ -825,8 +841,9 @@ func hookPromptSubmit(root string) error { } // Show working set and session progress - showWorkingSetSummary(root) - showSessionProgress(root) + promptSessionID := sessionIDFromHookInput(input) + showWorkingSetSummary(root, promptSessionID) + showSessionProgress(root, promptSessionID) // Write statusline state for terminal UI writeStatuslineState(root, intent) @@ -920,6 +937,10 @@ func showNextCodemapSteps(intent TaskIntent, info *hubInfo) { } func planCodemapNextSteps(intent TaskIntent, info *hubInfo) []codemapNextStep { + if intent.Confidence < intentConfidenceFloor { + return nil + } + var steps []codemapNextStep seen := make(map[string]bool) add := func(command, reason string) { @@ -933,24 +954,28 @@ func planCodemapNextSteps(intent TaskIntent, info *hubInfo) []codemapNextStep { primaryFile := pickPrimaryCodemapFile(intent.Files, info) if primaryFile != "" { importerCount := 0 + hubImporterCount := 0 if info != nil { importerCount = len(info.Importers[primaryFile]) + hubImporterCount = scanner.CountHubImporters(info.Importers[primaryFile]) } reason := "check callers before editing" switch { - case importerCount >= 3: + case hubImporterCount >= scanner.HubThreshold: reason = fmt.Sprintf("check blast radius before editing this hub (%d importers)", importerCount) case importerCount > 0: reason = fmt.Sprintf("check callers before editing (%d importers)", importerCount) } add("codemap --importers "+shellQuoteIfNeeded(primaryFile), reason) - if importerCount >= 3 { + if hubImporterCount >= scanner.HubThreshold { add("codemap --deps", "trace dependency flow around this hub before changing it") } } switch intent.Category { + case "vcs": + add("codemap --diff", "review what changed before merging, reviewing, or releasing") case "explore": if len(intent.Files) == 0 { add("codemap .", "refresh project structure before diving in") @@ -969,7 +994,7 @@ func planCodemapNextSteps(intent TaskIntent, info *hubInfo) []codemapNextStep { case "bugfix": if len(intent.Files) == 0 { add("codemap --diff", "check recent branch changes before debugging") - } else if primaryFile != "" && info != nil && len(info.Importers[primaryFile]) >= 3 { + } else if primaryFile != "" && info != nil && scanner.CountHubImporters(info.Importers[primaryFile]) >= scanner.HubThreshold { add("codemap --deps", "hub fixes can ripple through dependents") } } @@ -1079,8 +1104,13 @@ func showDriftWarnings(root string, cfg config.DriftConfig, routing config.Routi } } -// showWorkingSetSummary displays the current working set from daemon state. -func showWorkingSetSummary(root string) { +// showWorkingSetSummary displays session activity from daemon state, split by +// provenance: files this agent actually edited (recorded by the post-edit +// hook) versus files that merely changed on disk (git merges, checkouts, +// generated output). The daemon alone can't tell those apart, so without +// agent-edit records the output says "changed on disk" instead of claiming an +// agent working set that may be merge churn. +func showWorkingSetSummary(root, sessionID string) { state := watch.ReadState(root) if state == nil || state.WorkingSet == nil || state.WorkingSet.Size() == 0 { return @@ -1092,18 +1122,51 @@ func showWorkingSetSummary(root string) { return } + agentPaths := loadAgentEdits(root, time.Now().Add(-agentEditRecency)).paths + if len(agentPaths) == 0 { + fmt.Println() + fmt.Printf("💾 Files changed on disk: %d (no agent edits recorded this session)\n", ws.Size()) + for _, wf := range hot { + fmt.Printf(" • %s (%+d lines)\n", filepath.ToSlash(wf.Path), wf.NetDelta) + } + return + } + + var agentFiles []*watch.WorkingFile + for _, wf := range ws.Files { + if agentPaths[filepath.ToSlash(wf.Path)] { + agentFiles = append(agentFiles, wf) + } + } + sort.Slice(agentFiles, func(i, j int) bool { + return agentFiles[i].LastTouch.After(agentFiles[j].LastTouch) + }) + + hubCount := 0 + for _, wf := range agentFiles { + if wf.IsHub { + hubCount++ + } + } + fmt.Println() - fmt.Printf("🔧 Working set: %d files", ws.Size()) - if ws.HubCount() > 0 { - fmt.Printf(" (%d hubs)", ws.HubCount()) + fmt.Printf("🔧 Working set: %d files", len(agentFiles)) + if hubCount > 0 { + fmt.Printf(" (%d hubs)", hubCount) } fmt.Println() - for _, wf := range hot { + for i, wf := range agentFiles { + if i >= 5 { + break + } hubStr := "" if wf.IsHub { hubStr = " ⚠️HUB" } - fmt.Printf(" • %s (%d edits, %+d lines%s)\n", wf.Path, wf.EditCount, wf.NetDelta, hubStr) + fmt.Printf(" • %s (%d edits, %+d lines%s)\n", filepath.ToSlash(wf.Path), wf.EditCount, wf.NetDelta, hubStr) + } + if others := ws.Size() - len(agentFiles); others > 0 { + fmt.Printf(" … %d other files changed on disk (not this agent's edits)\n", others) } } @@ -1242,34 +1305,42 @@ func emitRouteMarker(matches []subsystemRouteMatch) { fmt.Printf("\n", string(data)) } -// showSessionProgress shows files edited so far in this session -func showSessionProgress(root string) { +// showSessionProgress reports what this agent session actually edited (from +// post-edit hook records). Daemon events cover everything that changed on disk +// — merges, checkouts, generated files — so they only back an honest +// "disk activity" fallback, never an "edited" claim. +func showSessionProgress(root, sessionID string) { + edits := loadAgentEdits(root, time.Now().Add(-agentEditRecency)) + if sessionPaths := edits.bySession[sessionID]; len(sessionPaths) > 0 { + info := getHubInfoNoFallback(root) + hubEdits := 0 + for path := range sessionPaths { + if info != nil && info.isHub(path) { + hubEdits++ + } + } + fmt.Println() + fmt.Printf("📊 Session so far: %d files edited", len(sessionPaths)) + if hubEdits > 0 { + fmt.Printf(", %d hub edits", hubEdits) + } + fmt.Println() + return + } + state := watch.ReadState(root) if state == nil || len(state.RecentEvents) == 0 { return } - - // Count unique files and unique hub files edited - filesEdited := make(map[string]bool) - hubFiles := make(map[string]bool) + changed := make(map[string]bool) for _, e := range state.RecentEvents { - filesEdited[e.Path] = true - if e.IsHub { - hubFiles[e.Path] = true - } + changed[e.Path] = true } - hubEdits := len(hubFiles) - - if len(filesEdited) == 0 { + if len(changed) == 0 { return } - - fmt.Println() - fmt.Printf("📊 Session so far: %d files edited", len(filesEdited)) - if hubEdits > 0 { - fmt.Printf(", %d hub edits", hubEdits) - } fmt.Println() + fmt.Printf("📊 Recent disk activity: %d files changed\n", len(changed)) } // hookPreCompact saves hub state before context compaction @@ -1537,6 +1608,11 @@ func hookSessionIDFromStdin() string { if err != nil || len(strings.TrimSpace(string(input))) == 0 { return "" } + return sessionIDFromHookInput(input) +} + +// sessionIDFromHookInput extracts the agent session id from a raw hook payload. +func sessionIDFromHookInput(input []byte) string { var payload map[string]any if json.Unmarshal(input, &payload) != nil { return "" @@ -1687,7 +1763,16 @@ func extractFilePathsFromStdin() ([]string, error) { if err != nil { return nil, err } + return parseHookFilePaths(input), nil +} +// parseHookFilePaths extracts edited file paths from a raw hook payload. +func parseHookFilePaths(input []byte) []string { + paths, _ := parseHookFilePathsErr(input) + return paths +} + +func parseHookFilePathsErr(input []byte) ([]string, error) { var data map[string]interface{} if err := json.Unmarshal(input, &data); err != nil { // Try regex fallback for non-JSON or partial JSON @@ -1766,7 +1851,7 @@ func checkFileImportersWithPhase(root, filePath, phase string) error { } importers := info.Importers[filePath] - if len(importers) >= 3 { + if scanner.CountHubImporters(importers) >= scanner.HubThreshold { fmt.Println() switch phase { case "before": @@ -1853,9 +1938,12 @@ func showFileCodemapActions(filePath string, importerCount int, importsHub bool, fmt.Println() } -// isHub checks if a file is a hub (has 3+ importers) +// isHub checks if a file is a hub (has HubThreshold+ non-test importers). func (h *hubInfo) isHub(path string) bool { - return len(h.Importers[path]) >= 3 + if scanner.IsTestFile(path) { + return false + } + return scanner.CountHubImporters(h.Importers[path]) >= scanner.HubThreshold } // findChildRepos returns subdirectories that are git repositories, diff --git a/cmd/hooks_more_test.go b/cmd/hooks_more_test.go index d63fe8e..866345f 100644 --- a/cmd/hooks_more_test.go +++ b/cmd/hooks_more_test.go @@ -449,8 +449,16 @@ func TestHookPromptSubmitShowsContextAndProgress(t *testing.T) { }, }) + if err := recordAgentEdit(root, "prompt-session", "pkg/types.go", time.Now()); err != nil { + t.Fatal(err) + } + if err := recordAgentEdit(root, "prompt-session", "cmd/run.go", time.Now()); err != nil { + t.Fatal(err) + } + withStdinInput(t, mustJSONInput(t, map[string]string{ - "prompt": "please inspect pkg/types.go because hook daemon events are noisy", + "prompt": "please inspect pkg/types.go because hook daemon events are noisy", + "session_id": "prompt-session", }), func() { var hookErr error out := captureOutput(func() { hookErr = hookPromptSubmit(root) }) diff --git a/cmd/hooks_provenance_test.go b/cmd/hooks_provenance_test.go new file mode 100644 index 0000000..a13da97 --- /dev/null +++ b/cmd/hooks_provenance_test.go @@ -0,0 +1,147 @@ +package cmd + +import ( + "encoding/json" + "os" + "path/filepath" + "strings" + "testing" + "time" + + "codemap/watch" +) + +func writeProvenanceState(t *testing.T, root string, paths []string) { + t.Helper() + ws := &watch.WorkingSet{Files: map[string]*watch.WorkingFile{}, StartedAt: time.Now()} + var events []watch.Event + for _, p := range paths { + ws.Files[p] = &watch.WorkingFile{Path: p, EditCount: 1, NetDelta: 10, LastTouch: time.Now()} + events = append(events, watch.Event{Path: p, Op: "WRITE"}) + } + state := watch.State{ + UpdatedAt: time.Now(), + FileCount: len(paths), + RecentEvents: events, + WorkingSet: ws, + } + data, err := json.Marshal(state) + if err != nil { + t.Fatal(err) + } + if err := os.MkdirAll(filepath.Join(root, ".codemap"), 0o755); err != nil { + t.Fatal(err) + } + if err := os.WriteFile(filepath.Join(root, ".codemap", "state.json"), data, 0o644); err != nil { + t.Fatal(err) + } +} + +func TestRecordAgentEditRoundTrip(t *testing.T) { + root := t.TempDir() + + if err := recordAgentEdit(root, "session-a", filepath.Join(root, "watch", "events.go"), time.Now()); err != nil { + t.Fatalf("recordAgentEdit: %v", err) + } + if err := recordAgentEdit(root, "session-b", "cmd/hooks.go", time.Now()); err != nil { + t.Fatalf("recordAgentEdit: %v", err) + } + + edits := loadAgentEdits(root, time.Now().Add(-time.Hour)) + if !edits.paths["watch/events.go"] { + t.Fatalf("absolute path not normalized to repo-relative slash form: %+v", edits.paths) + } + if !edits.paths["cmd/hooks.go"] { + t.Fatalf("missing recorded edit: %+v", edits.paths) + } + if !edits.bySession["session-a"]["watch/events.go"] || edits.bySession["session-a"]["cmd/hooks.go"] { + t.Fatalf("session attribution wrong: %+v", edits.bySession) + } + + stale := loadAgentEdits(root, time.Now().Add(time.Hour)) + if len(stale.paths) != 0 { + t.Fatalf("cutoff not applied: %+v", stale.paths) + } +} + +func TestHookPostEditRecordsAgentEdit(t *testing.T) { + root := t.TempDir() + target := filepath.Join(root, "pkg", "types.go") + + input := `{"file_path":` + jsonQuote(target) + `,"session_id":"post-edit-session"}` + withStdinInput(t, input, func() { + _ = captureOutput(func() { + if err := hookPostEdit(root); err != nil { + t.Fatalf("hookPostEdit: %v", err) + } + }) + }) + + edits := loadAgentEdits(root, time.Now().Add(-time.Minute)) + if !edits.bySession["post-edit-session"]["pkg/types.go"] { + t.Fatalf("post-edit hook did not record agent edit: %+v", edits.bySession) + } +} + +func jsonQuote(path string) string { + data, _ := json.Marshal(path) + return string(data) +} + +func TestWorkingSetSummarySeparatesAgentEditsFromDiskChurn(t *testing.T) { + root := t.TempDir() + writeProvenanceState(t, root, []string{"a.go", "b.go", "c.go"}) + if err := recordAgentEdit(root, "session-a", "a.go", time.Now()); err != nil { + t.Fatal(err) + } + + out := captureOutput(func() { showWorkingSetSummary(root, "session-a") }) + + if !strings.Contains(out, "a.go") { + t.Fatalf("agent-edited file missing from working set:\n%s", out) + } + if strings.Contains(out, "b.go") || strings.Contains(out, "c.go") { + t.Fatalf("disk churn listed as if agent-edited:\n%s", out) + } + if !strings.Contains(out, "2 other files changed on disk") { + t.Fatalf("disk churn should be summarized separately:\n%s", out) + } +} + +func TestWorkingSetSummaryFallsBackToHonestDiskLabel(t *testing.T) { + root := t.TempDir() + writeProvenanceState(t, root, []string{"a.go", "b.go"}) + + out := captureOutput(func() { showWorkingSetSummary(root, "session-a") }) + + if strings.Contains(out, "Working set") { + t.Fatalf("without recorded agent edits the output must not claim a working set:\n%s", out) + } + if !strings.Contains(out, "changed on disk") { + t.Fatalf("expected honest disk-activity label:\n%s", out) + } +} + +func TestSessionProgressCountsOnlyThisSessionsAgentEdits(t *testing.T) { + root := t.TempDir() + writeProvenanceState(t, root, []string{"a.go", "b.go", "c.go", "d.go"}) + if err := recordAgentEdit(root, "session-a", "a.go", time.Now()); err != nil { + t.Fatal(err) + } + if err := recordAgentEdit(root, "session-a", "b.go", time.Now()); err != nil { + t.Fatal(err) + } + if err := recordAgentEdit(root, "session-b", "c.go", time.Now()); err != nil { + t.Fatal(err) + } + + out := captureOutput(func() { showSessionProgress(root, "session-a") }) + if !strings.Contains(out, "2 files edited") { + t.Fatalf("session progress should count only this session's agent edits:\n%s", out) + } + + fallback := captureOutput(func() { showSessionProgress(root, "session-without-edits") }) + if strings.Contains(fallback, "files edited") { + t.Fatalf("without agent edits, progress must not claim edits:\n%s", fallback) + } +} diff --git a/cmd/hooks_test.go b/cmd/hooks_test.go index adb9672..cde8d3f 100644 --- a/cmd/hooks_test.go +++ b/cmd/hooks_test.go @@ -977,11 +977,11 @@ func TestShowLastSessionContext(t *testing.T) { }) } -// TestShowSessionProgress verifies that in-session hub-edit statistics are -// reported accurately, exposing hub churn as a context-bloat signal. +// TestShowSessionProgress verifies session statistics separate what the agent +// actually edited (post-edit hook records) from what merely changed on disk. func TestShowSessionProgress(t *testing.T) { - t.Run("no daemon state produces no output", func(t *testing.T) { - out := captureOutput(func() { showSessionProgress(t.TempDir()) }) + t.Run("no daemon state and no edits produces no output", func(t *testing.T) { + out := captureOutput(func() { showSessionProgress(t.TempDir(), "s") }) if out != "" { t.Errorf("expected no output with no state, got %q", out) } @@ -993,13 +993,13 @@ func TestShowSessionProgress(t *testing.T) { UpdatedAt: time.Now(), FileCount: 5, }) - out := captureOutput(func() { showSessionProgress(root) }) + out := captureOutput(func() { showSessionProgress(root, "s") }) if out != "" { t.Errorf("expected no output for state with no events, got %q", out) } }) - t.Run("shows files-edited count", func(t *testing.T) { + t.Run("daemon events alone report disk activity, never edits", func(t *testing.T) { root := t.TempDir() writeWatchState(t, root, watch.State{ UpdatedAt: time.Now(), @@ -1010,30 +1010,37 @@ func TestShowSessionProgress(t *testing.T) { {Path: "main.go", Op: "WRITE"}, // duplicate — same file }, }) - out := captureOutput(func() { showSessionProgress(root) }) - if !strings.Contains(out, "2 files edited") { - t.Errorf("expected '2 files edited', got %q", out) + out := captureOutput(func() { showSessionProgress(root, "s") }) + if strings.Contains(out, "files edited") { + t.Errorf("disk churn must not be reported as edits, got %q", out) + } + if !strings.Contains(out, "2 files changed") { + t.Errorf("expected '2 files changed' disk summary, got %q", out) } }) - t.Run("reports hub-edit count to surface risky churn", func(t *testing.T) { + t.Run("agent edits drive files-edited and hub counts", func(t *testing.T) { root := t.TempDir() writeWatchState(t, root, watch.State{ UpdatedAt: time.Now(), FileCount: 10, - RecentEvents: []watch.Event{ - {Path: "types.go", Op: "WRITE", IsHub: true}, - {Path: "utils.go", Op: "WRITE", IsHub: false}, - {Path: "types.go", Op: "WRITE", IsHub: true}, + Importers: map[string][]string{ + "types.go": {"a.go", "b.go", "c.go"}, }, }) - out := captureOutput(func() { showSessionProgress(root) }) - if !strings.Contains(out, "1 hub edits") { - t.Errorf("expected '1 hub edits' (unique hub files, not events), got %q", out) + if err := recordAgentEdit(root, "s", "types.go", time.Now()); err != nil { + t.Fatal(err) } + if err := recordAgentEdit(root, "s", "utils.go", time.Now()); err != nil { + t.Fatal(err) + } + out := captureOutput(func() { showSessionProgress(root, "s") }) if !strings.Contains(out, "2 files edited") { t.Errorf("expected '2 files edited', got %q", out) } + if !strings.Contains(out, "1 hub edits") { + t.Errorf("expected '1 hub edits', got %q", out) + } }) t.Run("omits hub-edit label when there are none", func(t *testing.T) { @@ -1041,11 +1048,11 @@ func TestShowSessionProgress(t *testing.T) { writeWatchState(t, root, watch.State{ UpdatedAt: time.Now(), FileCount: 5, - RecentEvents: []watch.Event{ - {Path: "main.go", Op: "WRITE", IsHub: false}, - }, }) - out := captureOutput(func() { showSessionProgress(root) }) + if err := recordAgentEdit(root, "s", "main.go", time.Now()); err != nil { + t.Fatal(err) + } + out := captureOutput(func() { showSessionProgress(root, "s") }) if strings.Contains(out, "hub edits") { t.Errorf("should not mention hub edits when count is 0, got %q", out) } diff --git a/cmd/intent.go b/cmd/intent.go index 8dbb025..ab871a0 100644 --- a/cmd/intent.go +++ b/cmd/intent.go @@ -38,7 +38,25 @@ type categoryDef struct { Signals []intentSignal } +// intentConfidenceFloor is the minimum classifier confidence required before +// hook output acts on a category. Below it, the category is a guess (often the +// zero-signal "feature" fallback) and emitting advice derived from it actively +// misleads the agent. +const intentConfidenceFloor = 0.5 + var categoryDefs = []categoryDef{ + { + // VCS workflow tasks (merging, reviewing, releasing) come before the + // code categories so agent sessions that never edit code stop being + // misread as feature work. + Category: "vcs", + Signals: []intentSignal{ + {"pull request", 5}, {"pull requests", 5}, {"prs", 5}, {"open pr", 5}, + {"merge", 4}, {"rebase", 5}, {"cherry-pick", 5}, {"cherry pick", 5}, + {"release", 4}, {"changelog", 3}, {"tag it", 4}, + {"land", 3}, {"review", 3}, {"commit", 3}, {"push", 3}, {"branch", 2}, + }, + }, { Category: "explore", Signals: []intentSignal{ diff --git a/cmd/intent_gate_test.go b/cmd/intent_gate_test.go new file mode 100644 index 0000000..a9a6b9a --- /dev/null +++ b/cmd/intent_gate_test.go @@ -0,0 +1,86 @@ +package cmd + +import ( + "strings" + "testing" + + "codemap/config" +) + +func TestClassifyIntentDetectsVCSWorkflow(t *testing.T) { + prompts := []string{ + "help me finish all open prs. start with the other people ones and merge those in", + "review the open pull request and land it", + "rebase this branch and push it", + "cut a release and tag it", + } + for _, prompt := range prompts { + intent := classifyIntent(prompt, nil, nil, config.ProjectConfig{}) + if intent.Category != "vcs" { + t.Fatalf("classifyIntent(%q).Category = %q, want vcs", prompt, intent.Category) + } + if intent.Confidence < intentConfidenceFloor { + t.Fatalf("classifyIntent(%q).Confidence = %v, want >= %v", prompt, intent.Confidence, intentConfidenceFloor) + } + } +} + +func TestClassifyIntentVCSDoesNotHijackCodePrompts(t *testing.T) { + intent := classifyIntent("fix the broken debounce bug in the watch daemon", nil, nil, config.ProjectConfig{}) + if intent.Category != "bugfix" { + t.Fatalf("Category = %q, want bugfix", intent.Category) + } +} + +func TestPlanCodemapNextStepsForVCSSuggestsDiffNotEditAdvice(t *testing.T) { + intent := TaskIntent{Category: "vcs", Confidence: 1} + steps := planCodemapNextSteps(intent, nil) + + var commands []string + for _, step := range steps { + commands = append(commands, step.Command) + } + joined := strings.Join(commands, "\n") + if strings.Contains(joined, "codemap --deps") || joined == "codemap ." { + t.Fatalf("VCS workflow got edit-flavored advice: %v", commands) + } + if !strings.Contains(joined, "codemap --diff") { + t.Fatalf("VCS workflow should suggest reviewing the diff, got: %v", commands) + } +} + +func TestPromptSubmitSuppressesZeroConfidenceIntent(t *testing.T) { + root := t.TempDir() + + // No category signal words at all: classifier falls back with confidence 0. + input := `{"prompt":"hello there, whats up my friend", "session_id":"gate-test"}` + withStdinInput(t, input, func() { + out := captureOutput(func() { + if err := hookPromptSubmit(root); err != nil { + t.Fatalf("hookPromptSubmit: %v", err) + } + }) + if strings.Contains(out, "codemap:intent") { + t.Fatalf("zero-confidence intent marker should be suppressed, got:\n%s", out) + } + if strings.Contains(out, "Next codemap:") { + t.Fatalf("zero-confidence next steps should be suppressed, got:\n%s", out) + } + }) +} + +func TestPromptSubmitEmitsConfidentIntent(t *testing.T) { + root := t.TempDir() + + input := `{"prompt":"fix the broken parser bug", "session_id":"gate-test"}` + withStdinInput(t, input, func() { + out := captureOutput(func() { + if err := hookPromptSubmit(root); err != nil { + t.Fatalf("hookPromptSubmit: %v", err) + } + }) + if !strings.Contains(out, "codemap:intent") { + t.Fatalf("confident intent should emit a marker, got:\n%s", out) + } + }) +} diff --git a/main.go b/main.go index 14b0963..ed38bda 100644 --- a/main.go +++ b/main.go @@ -266,6 +266,13 @@ func main() { fmt.Println("MCP server:") fmt.Println(" codemap mcp # Run Codemap MCP server on stdio") fmt.Println() + fmt.Println("More subcommands:") + fmt.Println(" codemap watch status|start|stop # Manage the background watch daemon") + fmt.Println(" codemap skill list|show # List or show bundled agent skills") + fmt.Println(" codemap context # Print machine-readable project context JSON") + fmt.Println(" codemap serve # Serve project intelligence over HTTP") + fmt.Println(" codemap version # Show build version") + fmt.Println() fmt.Println("Recommended onboarding:") fmt.Println(" codemap setup # Configure project config + Claude hooks") fmt.Println(" codemap setup --global # Write hooks to ~/.claude/settings.json") @@ -384,6 +391,14 @@ func main() { mode = "skyline" } + // A bare word that isn't a directory is almost always a typo'd subcommand + // ("codemap drift"), so fail with directions instead of a walk error. + if _, statErr := os.Stat(root); os.IsNotExist(statErr) { + fmt.Fprintf(os.Stderr, "Error: path %q does not exist.\n", root) + fmt.Fprintln(os.Stderr, "If you meant a subcommand, run 'codemap --help' for the full list.") + os.Exit(1) + } + // Scan files files, err := scanner.ScanFiles(root, gitCache, only, exclude) if err != nil { @@ -590,7 +605,7 @@ func runImportersMode(root, file string, jsonMode bool, filters scanner.Filters) _ = json.NewEncoder(os.Stdout).Encode(report) return } - renderImportersReport(os.Stdout, report) + renderImportersReportCLI(os.Stdout, report) } func runWatchSubcommand(subCmd, root string) { diff --git a/main_cli_polish_test.go b/main_cli_polish_test.go new file mode 100644 index 0000000..78e35c4 --- /dev/null +++ b/main_cli_polish_test.go @@ -0,0 +1,61 @@ +package main + +import ( + "strings" + "testing" + + "codemap/scanner" +) + +func TestRenderImportersReportExplainsEmptyResult(t *testing.T) { + report := scanner.ImportersReport{ + Root: "/repo", + Mode: "importers", + File: "watch/events.go", + } + + var buf strings.Builder + renderImportersReportCLI(&buf, report) + out := buf.String() + if !strings.Contains(out, "No files import watch/events.go") { + t.Fatalf("empty importers result must say so instead of printing nothing:\n%q", out) + } + if !strings.Contains(out, "same package") { + t.Fatalf("empty result should explain the same-package limitation for Go:\n%q", out) + } + + // The token-budgeted blast-radius renderer must keep omitting empty + // sections — the explanation is CLI-only. + if bundleOut := renderImportersReportString(report); strings.Contains(bundleOut, "No files import") { + t.Fatalf("bundle renderer should omit empty sections, got:\n%q", bundleOut) + } +} + +func TestNonexistentPathGetsFriendlyError(t *testing.T) { + _, stderr, err := runCodemapWithInput("", "drift") + if err == nil { + t.Fatal("expected non-zero exit for nonexistent path") + } + if !strings.Contains(stderr, `"drift" does not exist`) { + t.Fatalf("expected friendly missing-path error, got:\n%s", stderr) + } + if !strings.Contains(stderr, "--help") { + t.Fatalf("error should point at --help for available subcommands, got:\n%s", stderr) + } +} + +func TestHelpListsAllSubcommands(t *testing.T) { + out, _, err := runCodemapWithInput("", "--help") + if err != nil { + t.Fatalf("--help failed: %v", err) + } + for _, sub := range []string{ + "codemap watch", "codemap hook", "codemap config", "codemap setup", + "codemap doctor", "codemap mcp", "codemap skill", "codemap plugin", + "codemap context", "codemap serve", "codemap handoff", "codemap blast-radius", + } { + if !strings.Contains(out, sub) { + t.Fatalf("--help does not mention %q:\n%s", sub, out) + } + } +} diff --git a/mcp/main.go b/mcp/main.go index 0e0eb25..c83eac2 100644 --- a/mcp/main.go +++ b/mcp/main.go @@ -610,7 +610,7 @@ func handleGetImporters(ctx context.Context, req *mcp.CallToolRequest, input Imp return textResult("No files import '" + input.File + "'"), nil, nil } - isHub := len(importers) >= 3 + isHub := scanner.CountHubImporters(importers) >= scanner.HubThreshold hubNote := "" if isHub { hubNote = " ⚠️ HUB FILE" diff --git a/scanner/filegraph.go b/scanner/filegraph.go index 99c4326..01439ed 100644 --- a/scanner/filegraph.go +++ b/scanner/filegraph.go @@ -153,11 +153,12 @@ func buildFileIndex(files []FileInfo, goModule string) *fileIndex { idx.bySuffix[noExt] = append(idx.bySuffix[noExt], path) } - // Go package index + // Go package index. Import paths always use forward slashes, so the + // key must be slash-normalized or lookups fail on Windows. if strings.HasSuffix(path, ".go") && goModule != "" { pkgPath := goModule if dir != "" { - pkgPath = goModule + "/" + dir + pkgPath = goModule + "/" + filepath.ToSlash(dir) } idx.goPkgs[pkgPath] = append(idx.goPkgs[pkgPath], path) } @@ -189,6 +190,16 @@ func fuzzyResolve(imp, fromFile string, idx *fileIndex, goModule string, pathAli return resolveRelative(imp, fromDir, idx) } + // Go imports are always full package paths, so a non-relative import from + // a .go file that isn't under the module path is the standard library or a + // third-party module. Fuzzy matching those creates false edges — e.g. the + // stdlib "context" import resolving to a local cmd/context.go — which + // inflate hub counts everywhere downstream. Without a go.mod we can't + // resolve Go imports at all, so we return none rather than guess wrong. + if strings.HasSuffix(fromFile, ".go") { + return nil + } + // Strategy 3: TypeScript/JavaScript path alias resolution (@modules/auth, @shared/utils) if len(pathAliases) > 0 { if files := resolvePathAlias(imp, pathAliases, baseURL, idx); len(files) > 0 { @@ -355,16 +366,52 @@ func detectModule(root string) string { return "" } -// IsHub returns true if a file has 3+ importers +// HubThreshold is the number of non-test importers at which a file counts as +// a hub. +const HubThreshold = 3 + +// IsTestFile reports whether a path names a test file across the supported +// languages (Go _test files, JS/TS .test/.spec files, Python test_ modules). +// Test importers are real graph edges but shouldn't confer hub status: a file +// imported only by its own tests doesn't have blast radius. +func IsTestFile(path string) bool { + base := strings.ToLower(filepath.Base(filepath.FromSlash(path))) + if strings.HasSuffix(base, "_test.go") { + return true + } + if strings.HasPrefix(base, "test_") && strings.HasSuffix(base, ".py") { + return true + } + noExt := strings.TrimSuffix(base, filepath.Ext(base)) + return strings.HasSuffix(noExt, ".test") || strings.HasSuffix(noExt, ".spec") +} + +// CountHubImporters returns how many of the given importers count toward hub +// status (i.e. excluding test files). +func CountHubImporters(importers []string) int { + count := 0 + for _, imp := range importers { + if !IsTestFile(imp) { + count++ + } + } + return count +} + +// IsHub returns true if a file has HubThreshold+ non-test importers. +// Test files themselves never rank as hubs. func (fg *FileGraph) IsHub(path string) bool { - return len(fg.Importers[path]) >= 3 + if IsTestFile(path) { + return false + } + return CountHubImporters(fg.Importers[path]) >= HubThreshold } -// HubFiles returns all files that are imported by 3+ other files +// HubFiles returns all files that qualify as hubs under IsHub. func (fg *FileGraph) HubFiles() []string { var hubs []string - for path, importers := range fg.Importers { - if len(importers) >= 3 { + for path := range fg.Importers { + if fg.IsHub(path) { hubs = append(hubs, path) } } diff --git a/scanner/filegraph_truth_test.go b/scanner/filegraph_truth_test.go new file mode 100644 index 0000000..681567f --- /dev/null +++ b/scanner/filegraph_truth_test.go @@ -0,0 +1,146 @@ +package scanner + +import ( + "os" + "path/filepath" + "testing" +) + +// writeTruthFixture creates a repo shaped like the real-world failure: local +// files whose basenames collide with Go standard library packages. +func writeTruthFixture(t *testing.T) string { + t.Helper() + root := t.TempDir() + files := map[string]string{ + "go.mod": "module example.com/m\n\ngo 1.22\n", + "cmd/context.go": "package cmd\n", + "skills/embed.go": "package skills\n", + "internal/buildinfo/version.go": "package buildinfo\n", + "scanner/astgrep.go": "package scanner\n", + "app/main.py": "from app.core import config\n", + "app/core/config.py": "VALUE = 1\n", + } + for path, content := range files { + full := filepath.Join(root, path) + if err := os.MkdirAll(filepath.Dir(full), 0o755); err != nil { + t.Fatal(err) + } + if err := os.WriteFile(full, []byte(content), 0o644); err != nil { + t.Fatal(err) + } + } + return root +} + +func TestGoStdlibImportsDoNotResolveToLocalFiles(t *testing.T) { + root := writeTruthFixture(t) + + analyses := []FileAnalysis{ + {Path: "scanner/astgrep.go", Language: "go", Imports: []string{ + "context", "embed", "fmt", "github.com/fsnotify/fsnotify", + "example.com/m/internal/buildinfo", + }}, + } + + fg, err := BuildFileGraphFromFilteredAnalyses(root, analyses, Filters{}) + if err != nil { + t.Fatalf("BuildFileGraphFromFilteredAnalyses: %v", err) + } + + if got := fg.Importers["cmd/context.go"]; len(got) != 0 { + t.Fatalf("stdlib \"context\" import resolved to local file, importers = %v", got) + } + if got := fg.Importers["skills/embed.go"]; len(got) != 0 { + t.Fatalf("stdlib \"embed\" import resolved to local file, importers = %v", got) + } + if got := fg.Importers["internal/buildinfo/version.go"]; len(got) != 1 || got[0] != "scanner/astgrep.go" { + t.Fatalf("module-internal import lost, importers = %v", got) + } +} + +func TestGoFilesWithoutModuleGetNoFuzzyEdges(t *testing.T) { + root := t.TempDir() + files := map[string]string{ + "cmd/context.go": "package cmd\n", + "main.go": "package main\n", + } + for path, content := range files { + full := filepath.Join(root, path) + if err := os.MkdirAll(filepath.Dir(full), 0o755); err != nil { + t.Fatal(err) + } + if err := os.WriteFile(full, []byte(content), 0o644); err != nil { + t.Fatal(err) + } + } + + analyses := []FileAnalysis{ + {Path: "main.go", Language: "go", Imports: []string{"context"}}, + } + fg, err := BuildFileGraphFromFilteredAnalyses(root, analyses, Filters{}) + if err != nil { + t.Fatalf("BuildFileGraphFromFilteredAnalyses: %v", err) + } + if got := fg.Importers["cmd/context.go"]; len(got) != 0 { + t.Fatalf("Go import fuzzy-matched a local file without module context, importers = %v", got) + } +} + +func TestNonGoResolutionUnaffectedByGoGate(t *testing.T) { + root := writeTruthFixture(t) + + analyses := []FileAnalysis{ + {Path: "app/main.py", Language: "python", Imports: []string{"app.core.config"}}, + } + fg, err := BuildFileGraphFromFilteredAnalyses(root, analyses, Filters{}) + if err != nil { + t.Fatalf("BuildFileGraphFromFilteredAnalyses: %v", err) + } + if got := fg.Importers["app/core/config.py"]; len(got) != 1 || got[0] != "app/main.py" { + t.Fatalf("python suffix resolution regressed, importers = %v", got) + } +} + +func TestIsTestFile(t *testing.T) { + tests := []struct { + path string + want bool + }{ + {"watch/events_test.go", true}, + {"watch/events.go", false}, + {"src/app.test.ts", true}, + {"src/app.spec.tsx", true}, + {"src/app.ts", false}, + {"tests/test_config.py", true}, + {"pkg/attest.go", false}, + {filepath.Join("watch", "events_test.go"), true}, + } + for _, tt := range tests { + if got := IsTestFile(tt.path); got != tt.want { + t.Fatalf("IsTestFile(%q) = %v, want %v", tt.path, got, tt.want) + } + } +} + +func TestHubStatusIgnoresTestFileImporters(t *testing.T) { + fg := &FileGraph{Importers: map[string][]string{ + "pkg/types.go": {"a.go", "b_test.go", "c_test.go"}, + "pkg/hub.go": {"a.go", "b.go", "c.go"}, + "pkg/pop_test.go": {"a.go", "b.go", "c.go", "d.go"}, + }} + + if fg.IsHub("pkg/types.go") { + t.Fatal("file with only 1 non-test importer must not be a hub") + } + if !fg.IsHub("pkg/hub.go") { + t.Fatal("file with 3 non-test importers must be a hub") + } + if fg.IsHub("pkg/pop_test.go") { + t.Fatal("test files must never rank as hubs") + } + + hubs := fg.HubFiles() + if len(hubs) != 1 || hubs[0] != "pkg/hub.go" { + t.Fatalf("HubFiles() = %v, want only pkg/hub.go", hubs) + } +}