From 6d46b8f6192006ee561366ddd0e242a51bdede2c Mon Sep 17 00:00:00 2001 From: Jordan Coin Jackson Date: Mon, 27 Jul 2026 15:45:08 -0400 Subject: [PATCH] fix: make codemap's model of agent sessions truthful Five accuracy fixes, each driven by a failing test captured from running codemap on its own repo during a real agent session: - Resolver: Go imports that aren't relative or under the module path are stdlib/third-party and no longer fall through to fuzzy suffix matching. Previously stdlib "context" resolved to cmd/context.go (and "embed" to skills/embed.go), fabricating hub status. Also slash-normalize Go package index keys so module lookups work on Windows. - Hubs: hub status now counts only non-test importers, and test files never rank as hubs (mcp/main_test.go was a "top-5 hub"). Centralized in scanner.HubThreshold/CountHubImporters/IsTestFile and applied across CLI, hooks, and MCP surfaces. - Intent: zero/low-confidence classifications no longer emit intent markers or "Next codemap:" advice (a PR-merge prompt used to get feature-work advice from a confidence-0 guess). New vcs category recognizes merge/review/release workflows and suggests --diff instead. - Provenance: the post-edit hook records agent edits to .codemap/agent_edits.jsonl; working-set and session-progress output now separate "this agent edited" from "changed on disk", so git merges and checkouts observed by the watch daemon stop being reported as 25 files the agent supposedly edited. - CLI: --importers explains empty results (same-package Go files never import each other) without leaking into token-budgeted blast-radius bundles; bare-word typos like 'codemap drift' get a friendly error; --help lists all subcommands; config init stops auto-including runtime residue extensions (log, pid, gitignore, ...). Cross-platform: verified with GOOS=windows and GOOS=linux builds; new code uses filepath/ToSlash normalization and O_APPEND-safe logging throughout. Co-Authored-By: Claude Fable 5 --- blast_radius.go | 17 +++- cmd/agent_edits.go | 140 +++++++++++++++++++++++++++ cmd/config.go | 5 + cmd/config_more_test.go | 38 ++++++++ cmd/hooks.go | 166 ++++++++++++++++++++++++-------- cmd/hooks_more_test.go | 10 +- cmd/hooks_provenance_test.go | 147 ++++++++++++++++++++++++++++ cmd/hooks_test.go | 49 ++++++---- cmd/intent.go | 18 ++++ cmd/intent_gate_test.go | 86 +++++++++++++++++ main.go | 17 +++- main_cli_polish_test.go | 61 ++++++++++++ mcp/main.go | 2 +- scanner/filegraph.go | 61 ++++++++++-- scanner/filegraph_truth_test.go | 146 ++++++++++++++++++++++++++++ 15 files changed, 891 insertions(+), 72 deletions(-) create mode 100644 cmd/agent_edits.go create mode 100644 cmd/hooks_provenance_test.go create mode 100644 cmd/intent_gate_test.go create mode 100644 main_cli_polish_test.go create mode 100644 scanner/filegraph_truth_test.go 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) + } +}