From 5cc3d17f094313169a126143590e764b7a4ddf8f Mon Sep 17 00:00:00 2001 From: Jordan Coin Jackson Date: Tue, 31 Mar 2026 09:06:40 -0400 Subject: [PATCH 1/4] test: improve render coverage --- .github/workflows/ci.yml | 2 +- render/depgraph_test.go | 72 ++++++++++++++++++++++++++++++++++++++++ 2 files changed, 73 insertions(+), 1 deletion(-) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 964784e..386ba0b 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -50,7 +50,7 @@ jobs: run: | total=$(go tool cover -func=coverage.out | awk '/^total:/ {gsub("%","",$3); print $3}') # Current enforced coverage floor. Codex PRs raise this incrementally toward 90%. - min=45.0 + min=50.0 awk -v t="$total" -v m="$min" 'BEGIN { if (t+0 < m+0) { printf "Coverage %.1f%% is below floor %.1f%%\n", t, m diff --git a/render/depgraph_test.go b/render/depgraph_test.go index 0cabb75..7ad6296 100644 --- a/render/depgraph_test.go +++ b/render/depgraph_test.go @@ -2,6 +2,8 @@ package render import ( "bytes" + "os" + "path/filepath" "strings" "testing" @@ -103,3 +105,73 @@ func TestDepgraphRendersExternalDepsAndSummarySection(t *testing.T) { } } } + +func writeDepgraphFixture(t *testing.T, root string) { + t.Helper() + + files := map[string]string{ + "go.mod": "module example.com/demo\n\ngo 1.24.0\n", + "app/main.go": "package app\n\nimport (\n\t\"example.com/demo/core/extra1\"\n\t\"example.com/demo/core/extra2\"\n\t\"example.com/demo/core/leaf\"\n\t\"example.com/demo/core/mid\"\n\t\"example.com/demo/core/root\"\n)\n\nfunc Main() {\n\textra1.Extra1()\n\textra2.Extra2()\n\tleaf.Leaf()\n\tmid.Mid()\n\troot.Root()\n}\n", + "core/root/root.go": "package root\n\nimport \"example.com/demo/core/mid\"\n\nfunc Root() {\n\tmid.Mid()\n}\n", + "core/mid/mid.go": "package mid\n\nimport \"example.com/demo/core/leaf\"\n\nfunc Mid() {\n\tleaf.Leaf()\n}\n", + "core/leaf/leaf.go": "package leaf\n\nfunc Leaf() {}\n", + "core/extra1/extra1.go": "package extra1\n\nimport \"example.com/demo/core/leaf\"\n\nfunc Extra1() {\n\tleaf.Leaf()\n}\n", + "core/extra2/extra2.go": "package extra2\n\nimport \"example.com/demo/core/leaf\"\n\nfunc Extra2() {\n\tleaf.Leaf()\n}\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) + } + } +} + +func TestDepgraphRendersChainsFanoutAndHubs(t *testing.T) { + if !scanner.NewAstGrepAnalyzer().Available() { + t.Skip("ast-grep not available") + } + + root := t.TempDir() + writeDepgraphFixture(t, root) + + project := scanner.DepsProject{ + Root: root, + Files: []scanner.FileAnalysis{ + {Path: "app/main.go", Functions: []string{"Main"}}, + {Path: "core/root/root.go", Functions: []string{"Root"}}, + {Path: "core/mid/mid.go", Functions: []string{"Mid"}}, + {Path: "core/leaf/leaf.go", Functions: []string{"Leaf"}}, + {Path: "core/extra1/extra1.go", Functions: []string{"Extra1"}}, + {Path: "core/extra2/extra2.go", Functions: []string{"Extra2"}}, + }, + ExternalDeps: map[string][]string{ + "go": {"example.com/very/long/module/name/v2"}, + }, + } + + var buf bytes.Buffer + Depgraph(&buf, project) + output := buf.String() + + expectedSnippets := []string{ + "Dependency Flow", + "Go: name", + "App", + "Core", + "main ──┬──▶ core/extra1/extra1", + "└──▶ core/root/root", + "root ───▶ core/mid/mid", + "HUBS: core/leaf/leaf (4←), core/mid/mid (2←)", + "6 files · 6 functions · 9 deps", + } + + for _, snippet := range expectedSnippets { + if !strings.Contains(output, snippet) { + t.Fatalf("expected output to contain %q, got:\n%s", snippet, output) + } + } +} From 3641764f1b5cf7dab9e9acb1fa8492b4fee21d76 Mon Sep 17 00:00:00 2001 From: Jordan Coin Jackson Date: Wed, 1 Apr 2026 22:40:55 -0400 Subject: [PATCH 2/4] prototype codemapd sidecar with sockd --- cmd/codemapd_socket.go | 339 +++++++++++++++++++++++++++++++++++++++++ cmd/context.go | 16 +- cmd/hooks.go | 49 ++++-- cmd/serve.go | 7 +- codemapd/Cargo.lock | 167 ++++++++++++++++++++ codemapd/Cargo.toml | 9 ++ codemapd/src/main.rs | 322 ++++++++++++++++++++++++++++++++++++++ 7 files changed, 880 insertions(+), 29 deletions(-) create mode 100644 cmd/codemapd_socket.go create mode 100644 codemapd/Cargo.lock create mode 100644 codemapd/Cargo.toml create mode 100644 codemapd/src/main.rs diff --git a/cmd/codemapd_socket.go b/cmd/codemapd_socket.go new file mode 100644 index 0000000..cd2b9be --- /dev/null +++ b/cmd/codemapd_socket.go @@ -0,0 +1,339 @@ +package cmd + +import ( + "encoding/binary" + "encoding/json" + "errors" + "fmt" + "io" + "net" + "os" + "path/filepath" + "runtime" + "strings" + "time" + + "codemap/watch" +) + +const ( + sockdMagic = 0x534f434b + sockdVersion = 1 + sockdHeaderLen = 12 + sockdMaxPayloadLen = 16 * 1024 * 1024 + frameKindRequest = 1 + frameKindResponse = 2 + frameKindError = 5 + frameKindShutdown = 6 + frameKindShutdownAck = 7 + + codemapdDialTimeout = 150 * time.Millisecond + codemapdQueryTimeout = 500 * time.Millisecond +) + +type codemapdQuery struct { + Kind string `json:"kind"` + Limit int `json:"limit,omitempty"` +} + +type codemapdHealth struct { + Status string `json:"status"` +} + +type codemapdHubInfoResponse struct { + UpdatedAt time.Time `json:"updated_at"` + FileCount int `json:"file_count"` + Hubs []string `json:"hubs"` + Importers map[string][]string `json:"importers"` + Imports map[string][]string `json:"imports"` +} + +type codemapdWorkingSetResponse struct { + UpdatedAt time.Time `json:"updated_at"` + WorkingSet *watch.WorkingSet `json:"working_set,omitempty"` +} + +type codemapdRecentEventsResponse struct { + UpdatedAt time.Time `json:"updated_at"` + RecentEvents []watch.Event `json:"recent_events"` +} + +type codemapdProjectStatsResponse struct { + UpdatedAt time.Time `json:"updated_at"` + FileCount int `json:"file_count"` + Hubs []string `json:"hubs"` +} + +func loadWorkingSet(root string) *watch.WorkingSet { + if workingSet, err := querySocketWorkingSet(root); err == nil && workingSet != nil { + return workingSet + } + + state := watch.ReadState(root) + if state == nil { + return nil + } + return state.WorkingSet +} + +func loadRecentEvents(root string, limit int) []watch.Event { + if events, err := querySocketRecentEvents(root, limit); err == nil { + return events + } + + state := watch.ReadState(root) + if state == nil { + return nil + } + if limit <= 0 || len(state.RecentEvents) <= limit { + return state.RecentEvents + } + return state.RecentEvents[len(state.RecentEvents)-limit:] +} + +func loadProjectStats(root string) (*codemapdProjectStatsResponse, bool) { + if stats, err := querySocketProjectStats(root); err == nil && stats != nil { + return stats, true + } + + state := watch.ReadState(root) + if state == nil { + return nil, false + } + return &codemapdProjectStatsResponse{ + UpdatedAt: state.UpdatedAt, + FileCount: state.FileCount, + Hubs: append([]string(nil), state.Hubs...), + }, true +} + +func querySocketHubInfo(root string) (*hubInfo, error) { + var response codemapdHubInfoResponse + if err := queryCodemapd(root, codemapdQuery{Kind: "hub_info"}, &response); err != nil { + return nil, err + } + if len(response.Importers) == 0 && len(response.Imports) == 0 && len(response.Hubs) == 0 { + return nil, nil + } + return &hubInfo{ + Hubs: response.Hubs, + Importers: response.Importers, + Imports: response.Imports, + }, nil +} + +func querySocketWorkingSet(root string) (*watch.WorkingSet, error) { + var response codemapdWorkingSetResponse + if err := queryCodemapd(root, codemapdQuery{Kind: "working_set"}, &response); err != nil { + return nil, err + } + return response.WorkingSet, nil +} + +func querySocketRecentEvents(root string, limit int) ([]watch.Event, error) { + var response codemapdRecentEventsResponse + if err := queryCodemapd(root, codemapdQuery{Kind: "recent_events", Limit: limit}, &response); err != nil { + return nil, err + } + return response.RecentEvents, nil +} + +func querySocketProjectStats(root string) (*codemapdProjectStatsResponse, error) { + var response codemapdProjectStatsResponse + if err := queryCodemapd(root, codemapdQuery{Kind: "project_stats"}, &response); err != nil { + return nil, err + } + return &response, nil +} + +func queryCodemapd(root string, request any, out any) error { + if runtime.GOOS == "windows" { + return errors.New("codemapd is not available on windows") + } + + payload, err := json.Marshal(request) + if err != nil { + return err + } + + conn, err := net.DialTimeout("unix", codemapdSocketPath(root), codemapdDialTimeout) + if err != nil { + return err + } + defer conn.Close() + + _ = conn.SetDeadline(time.Now().Add(codemapdQueryTimeout)) + + if err := writeSockdFrame(conn, frameKindRequest, payload); err != nil { + return err + } + + kind, responsePayload, err := readSockdFrame(conn) + if err != nil { + return err + } + + switch kind { + case frameKindResponse: + if out == nil { + return nil + } + return json.Unmarshal(responsePayload, out) + case frameKindError: + return errors.New(strings.TrimSpace(string(responsePayload))) + default: + return fmt.Errorf("unexpected codemapd frame kind: %d", kind) + } +} + +func startSocketDaemon(root string) { + if runtime.GOOS == "windows" || codemapdHealthy(root) { + return + } + + binaryPath, ok := resolveCodemapdBinary() + if !ok { + return + } + + cmd := hookExecCommand(binaryPath, "--root", root) + nullFile, err := os.OpenFile(os.DevNull, os.O_RDWR, 0) + if err != nil { + return + } + defer nullFile.Close() + + cmd.Stdout = nullFile + cmd.Stderr = nullFile + cmd.Stdin = nullFile + if err := cmd.Start(); err != nil { + return + } + + deadline := time.Now().Add(750 * time.Millisecond) + for time.Now().Before(deadline) { + if codemapdHealthy(root) { + return + } + time.Sleep(50 * time.Millisecond) + } +} + +func stopSocketDaemon(root string) { + if runtime.GOOS == "windows" { + return + } + + conn, err := net.DialTimeout("unix", codemapdSocketPath(root), codemapdDialTimeout) + if err != nil { + return + } + defer conn.Close() + + _ = conn.SetDeadline(time.Now().Add(codemapdQueryTimeout)) + if err := writeSockdFrame(conn, frameKindShutdown, nil); err != nil { + return + } + + kind, _, err := readSockdFrame(conn) + if err != nil { + return + } + if kind != frameKindShutdownAck { + return + } +} + +func codemapdHealthy(root string) bool { + var response codemapdHealth + if err := queryCodemapd(root, codemapdQuery{Kind: "health"}, &response); err != nil { + return false + } + return response.Status == "ok" +} + +func resolveCodemapdBinary() (string, bool) { + if runtime.GOOS == "windows" { + return "", false + } + + if envPath := strings.TrimSpace(os.Getenv("CODEMAPD_BIN")); envPath != "" { + if info, err := os.Stat(envPath); err == nil && !info.IsDir() { + return envPath, true + } + } + + exe, err := hookExecutablePath() + if err != nil { + return "", false + } + + candidate := filepath.Join(filepath.Dir(exe), "codemapd") + info, err := os.Stat(candidate) + if err != nil || info.IsDir() { + return "", false + } + return candidate, true +} + +func codemapdSocketPath(root string) string { + return filepath.Join(root, ".codemap", "codemapd.sock") +} + +func writeSockdFrame(w io.Writer, kind byte, payload []byte) error { + if len(payload) > sockdMaxPayloadLen { + return fmt.Errorf("codemapd payload too large: %d", len(payload)) + } + + var header [sockdHeaderLen]byte + binary.BigEndian.PutUint32(header[0:4], sockdMagic) + header[4] = sockdVersion + header[5] = kind + binary.BigEndian.PutUint16(header[6:8], 0) + binary.BigEndian.PutUint32(header[8:12], uint32(len(payload))) + + if err := writeAll(w, header[:]); err != nil { + return err + } + if len(payload) == 0 { + return nil + } + return writeAll(w, payload) +} + +func readSockdFrame(r io.Reader) (byte, []byte, error) { + var header [sockdHeaderLen]byte + if _, err := io.ReadFull(r, header[:]); err != nil { + return 0, nil, err + } + + if magic := binary.BigEndian.Uint32(header[0:4]); magic != sockdMagic { + return 0, nil, fmt.Errorf("invalid codemapd frame magic: 0x%x", magic) + } + if version := header[4]; version != sockdVersion { + return 0, nil, fmt.Errorf("unsupported codemapd frame version: %d", version) + } + + payloadLen := binary.BigEndian.Uint32(header[8:12]) + if payloadLen > sockdMaxPayloadLen { + return 0, nil, fmt.Errorf("codemapd frame payload too large: %d", payloadLen) + } + + payload := make([]byte, int(payloadLen)) + if _, err := io.ReadFull(r, payload); err != nil { + return 0, nil, err + } + + return header[5], payload, nil +} + +func writeAll(w io.Writer, payload []byte) error { + for len(payload) > 0 { + n, err := w.Write(payload) + if err != nil { + return err + } + payload = payload[n:] + } + return nil +} diff --git a/cmd/context.go b/cmd/context.go index 7d7594c..eed03ff 100644 --- a/cmd/context.go +++ b/cmd/context.go @@ -15,7 +15,6 @@ import ( "codemap/handoff" "codemap/scanner" "codemap/skills" - "codemap/watch" ) // ContextEnvelope is the standardized output format that any AI tool can consume. @@ -145,8 +144,7 @@ func buildContextEnvelope(root, prompt string, compact bool) ContextEnvelope { } // Working set from daemon - if state := watch.ReadState(root); state != nil && state.WorkingSet != nil { - ws := state.WorkingSet + if ws := loadWorkingSet(root); ws != nil { wsCtx := &WorkingSetContext{ FileCount: ws.Size(), HubCount: ws.HubCount(), @@ -197,13 +195,13 @@ func buildProjectContext(root string, info *hubInfo) ProjectContext { } // Count files and detect languages from daemon state - if state := watch.ReadState(root); state != nil { - ctx.FileCount = state.FileCount - ctx.HubCount = len(state.Hubs) - if len(state.Hubs) > 5 { - ctx.TopHubs = state.Hubs[:5] + if stats, ok := loadProjectStats(root); ok { + ctx.FileCount = stats.FileCount + ctx.HubCount = len(stats.Hubs) + if len(stats.Hubs) > 5 { + ctx.TopHubs = stats.Hubs[:5] } else { - ctx.TopHubs = state.Hubs + ctx.TopHubs = stats.Hubs } } diff --git a/cmd/hooks.go b/cmd/hooks.go index c834cd2..964544f 100644 --- a/cmd/hooks.go +++ b/cmd/hooks.go @@ -108,6 +108,15 @@ func getHubInfoNoFallback(root string) *hubInfo { } func getHubInfoWithFallback(root string, allowFallback bool) *hubInfo { + if info, err := querySocketHubInfo(root); err == nil { + if info != nil { + return info + } + if !allowFallback { + return nil + } + } + if state := watch.ReadState(root); state != nil { // State may contain file/event info only (no dependency graph) on very // large repos. Avoid expensive fallback scans in that case. @@ -273,6 +282,7 @@ func hookSessionStart(root string) error { if !watch.IsRunning(root) { startDaemon(root) } + startSocketDaemon(root) fmt.Println("📍 Project Context:") fmt.Println() @@ -288,13 +298,18 @@ func hookSessionStart(root string) error { // Future: Consider structured output that Claude Code can format/truncate intelligently. fileCount := 0 fileCountKnown := false - state := watch.ReadState(root) - if state == nil && watch.IsRunning(root) { - state = waitForDaemonState(root, 2*time.Second) - } - if state != nil { - fileCount = state.FileCount + if stats, ok := loadProjectStats(root); ok { + fileCount = stats.FileCount fileCountKnown = true + } else { + state := watch.ReadState(root) + if state == nil && watch.IsRunning(root) { + state = waitForDaemonState(root, 2*time.Second) + } + if state != nil { + fileCount = state.FileCount + fileCountKnown = true + } } projCfg := config.Load(root) structureBudget := projCfg.SessionStartOutputBytes() @@ -885,12 +900,11 @@ func showDriftWarnings(root string, cfg config.DriftConfig, routing config.Routi // showWorkingSetSummary displays the current working set from daemon state. func showWorkingSetSummary(root string) { - state := watch.ReadState(root) - if state == nil || state.WorkingSet == nil || state.WorkingSet.Size() == 0 { + ws := loadWorkingSet(root) + if ws == nil || ws.Size() == 0 { return } - ws := state.WorkingSet hot := ws.HotFiles(5) if len(hot) == 0 { return @@ -1048,15 +1062,15 @@ func emitRouteMarker(matches []subsystemRouteMatch) { // showSessionProgress shows files edited so far in this session func showSessionProgress(root string) { - state := watch.ReadState(root) - if state == nil || len(state.RecentEvents) == 0 { + events := loadRecentEvents(root, 0) + if len(events) == 0 { return } // Count unique files and unique hub files edited filesEdited := make(map[string]bool) hubFiles := make(map[string]bool) - for _, e := range state.RecentEvents { + for _, e := range events { filesEdited[e.Path] = true if e.IsHub { hubFiles[e.Path] = true @@ -1113,6 +1127,7 @@ func hookPreCompact(root string) error { func hookSessionStop(root string) error { // Read state BEFORE stopping daemon (includes timeline) state := watch.ReadState(root) + recentEvents := loadRecentEvents(root, 0) // Stop the watch daemon stopDaemon(root) @@ -1122,7 +1137,7 @@ func hookSessionStop(root string) error { fmt.Println("==================") // Show timeline from daemon events (if available) - if state != nil && len(state.RecentEvents) > 0 { + if len(recentEvents) > 0 { fmt.Println() fmt.Println("Edit Timeline:") @@ -1131,7 +1146,7 @@ func hookSessionStop(root string) error { fileEdits := make(map[string]int) // file -> edit count hubEdits := 0 - for _, e := range state.RecentEvents { + for _, e := range recentEvents { totalDelta += e.Delta fileEdits[e.Path]++ if e.IsHub { @@ -1140,7 +1155,7 @@ func hookSessionStop(root string) error { } // Show last 10 events - events := state.RecentEvents + events := recentEvents start := 0 if len(events) > 10 { start = len(events) - 10 @@ -1172,7 +1187,7 @@ func hookSessionStop(root string) error { // Show stats fmt.Println() fmt.Printf("Stats: %d events, %d files touched, %+d lines", - len(state.RecentEvents), len(fileEdits), totalDelta) + len(recentEvents), len(fileEdits), totalDelta) if hubEdits > 0 { fmt.Printf(", %d hub edits", hubEdits) } @@ -1334,6 +1349,8 @@ func gitSymbolicRef(root, ref string) (string, bool) { // stopDaemon stops the watch daemon func stopDaemon(root string) { + stopSocketDaemon(root) + if !hookWatchIsRunning(root) { return } diff --git a/cmd/serve.go b/cmd/serve.go index 33e2f1d..4ad5cb5 100644 --- a/cmd/serve.go +++ b/cmd/serve.go @@ -11,7 +11,6 @@ import ( "time" "codemap/skills" - "codemap/watch" ) // RunServe starts a lightweight HTTP server exposing codemap's intelligence. @@ -112,12 +111,12 @@ func RunServe(args []string, root string) { // GET /api/working-set — current session working set mux.HandleFunc("/api/working-set", func(w http.ResponseWriter, r *http.Request) { - state := watch.ReadState(absRoot) - if state == nil || state.WorkingSet == nil { + workingSet := loadWorkingSet(absRoot) + if workingSet == nil { writeJSON(w, map[string]string{"status": "no working set available"}) return } - writeJSON(w, state.WorkingSet) + writeJSON(w, workingSet) }) // GET /api/health — simple health check diff --git a/codemapd/Cargo.lock b/codemapd/Cargo.lock new file mode 100644 index 0000000..4ce8f9a --- /dev/null +++ b/codemapd/Cargo.lock @@ -0,0 +1,167 @@ +# This file is automatically @generated by Cargo. +# It is not intended for manual editing. +version = 4 + +[[package]] +name = "codemapd" +version = "0.1.0" +dependencies = [ + "serde", + "serde_json", + "sockd", +] + +[[package]] +name = "errno" +version = "0.3.14" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "39cab71617ae0d63f51a36d69f866391735b51691dbda63cf6f96d042b63efeb" +dependencies = [ + "libc", + "windows-sys", +] + +[[package]] +name = "itoa" +version = "1.0.18" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8f42a60cbdf9a97f5d2305f08a87dc4e09308d1276d28c869c684d7777685682" + +[[package]] +name = "libc" +version = "0.2.184" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "48f5d2a454e16a5ea0f4ced81bd44e4cfc7bd3a507b61887c99fd3538b28e4af" + +[[package]] +name = "memchr" +version = "2.8.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f8ca58f447f06ed17d5fc4043ce1b10dd205e060fb3ce5b979b8ed8e59ff3f79" + +[[package]] +name = "proc-macro2" +version = "1.0.106" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8fd00f0bb2e90d81d1044c2b32617f68fcb9fa3bb7640c23e9c748e53fb30934" +dependencies = [ + "unicode-ident", +] + +[[package]] +name = "quote" +version = "1.0.45" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "41f2619966050689382d2b44f664f4bc593e129785a36d6ee376ddf37259b924" +dependencies = [ + "proc-macro2", +] + +[[package]] +name = "serde" +version = "1.0.228" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9a8e94ea7f378bd32cbbd37198a4a91436180c5bb472411e48b5ec2e2124ae9e" +dependencies = [ + "serde_core", + "serde_derive", +] + +[[package]] +name = "serde_core" +version = "1.0.228" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "41d385c7d4ca58e59fc732af25c3983b67ac852c1a25000afe1175de458b67ad" +dependencies = [ + "serde_derive", +] + +[[package]] +name = "serde_derive" +version = "1.0.228" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d540f220d3187173da220f885ab66608367b6574e925011a9353e4badda91d79" +dependencies = [ + "proc-macro2", + "quote", + "syn", +] + +[[package]] +name = "serde_json" +version = "1.0.149" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "83fc039473c5595ace860d8c4fafa220ff474b3fc6bfdb4293327f1a37e94d86" +dependencies = [ + "itoa", + "memchr", + "serde", + "serde_core", + "zmij", +] + +[[package]] +name = "signal-hook" +version = "0.3.18" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d881a16cf4426aa584979d30bd82cb33429027e42122b169753d6ef1085ed6e2" +dependencies = [ + "libc", + "signal-hook-registry", +] + +[[package]] +name = "signal-hook-registry" +version = "1.4.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c4db69cba1110affc0e9f7bcd48bbf87b3f4fc7c61fc9155afd4c469eb3d6c1b" +dependencies = [ + "errno", + "libc", +] + +[[package]] +name = "sockd" +version = "0.0.1" +dependencies = [ + "libc", + "signal-hook", +] + +[[package]] +name = "syn" +version = "2.0.117" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e665b8803e7b1d2a727f4023456bbbbe74da67099c585258af0ad9c5013b9b99" +dependencies = [ + "proc-macro2", + "quote", + "unicode-ident", +] + +[[package]] +name = "unicode-ident" +version = "1.0.24" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e6e4313cd5fcd3dad5cafa179702e2b244f760991f45397d14d4ebf38247da75" + +[[package]] +name = "windows-link" +version = "0.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f0805222e57f7521d6a62e36fa9163bc891acd422f971defe97d64e70d0a4fe5" + +[[package]] +name = "windows-sys" +version = "0.61.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ae137229bcbd6cdf0f7b80a31df61766145077ddf49416a728b02cb3921ff3fc" +dependencies = [ + "windows-link", +] + +[[package]] +name = "zmij" +version = "1.0.21" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b8848ee67ecc8aedbaf3e4122217aff892639231befc6a1b58d29fff4c2cabaa" diff --git a/codemapd/Cargo.toml b/codemapd/Cargo.toml new file mode 100644 index 0000000..80196e4 --- /dev/null +++ b/codemapd/Cargo.toml @@ -0,0 +1,9 @@ +[package] +name = "codemapd" +version = "0.1.0" +edition = "2024" + +[dependencies] +serde = { version = "1", features = ["derive"] } +serde_json = "1" +sockd = { path = "../../sockd" } diff --git a/codemapd/src/main.rs b/codemapd/src/main.rs new file mode 100644 index 0000000..9d763ad --- /dev/null +++ b/codemapd/src/main.rs @@ -0,0 +1,322 @@ +use serde::{Deserialize, Serialize}; +use sockd::Daemon; +use std::collections::HashMap; +use std::env; +use std::error::Error; +use std::fs; +use std::path::PathBuf; +use std::time::{Duration, SystemTime}; + +type BoxError = Box; + +#[derive(Debug)] +struct Config { + socket_path: PathBuf, + pid_file: PathBuf, + state_file: PathBuf, + idle_timeout: Duration, +} + +#[derive(Debug, Deserialize)] +#[serde(tag = "kind", rename_all = "snake_case")] +enum Query { + Health, + HubInfo, + WorkingSet, + RecentEvents { limit: Option }, + ProjectStats, +} + +/// Deserialize a field that may be null or missing as an empty Vec. +/// Go's json.Marshal writes `null` for nil slices, but serde's #[default] +/// only handles missing fields, not explicit nulls. +fn null_as_empty_vec<'de, D, T>(deserializer: D) -> Result, D::Error> +where + D: serde::Deserializer<'de>, + T: Deserialize<'de>, +{ + Ok(Option::>::deserialize(deserializer)?.unwrap_or_default()) +} + +fn null_as_empty_map<'de, D, V>(deserializer: D) -> Result, D::Error> +where + D: serde::Deserializer<'de>, + V: Deserialize<'de>, +{ + Ok(Option::>::deserialize(deserializer)?.unwrap_or_default()) +} + +#[derive(Debug, Clone, Default, Deserialize)] +struct CodemapState { + #[serde(default)] + updated_at: String, + #[serde(default)] + file_count: usize, + #[serde(default, deserialize_with = "null_as_empty_vec")] + hubs: Vec, + #[serde(default, deserialize_with = "null_as_empty_map")] + importers: HashMap>, + #[serde(default, deserialize_with = "null_as_empty_map")] + imports: HashMap>, + #[serde(default, deserialize_with = "null_as_empty_vec")] + recent_events: Vec, + #[serde(default)] + working_set: Option, +} + +#[derive(Debug, Clone, Default, Serialize, Deserialize)] +struct RecentEvent { + #[serde(default)] + time: String, + #[serde(default)] + op: String, + #[serde(default)] + path: String, + #[serde(default)] + lang: String, + #[serde(default)] + lines: i64, + #[serde(default)] + delta: i64, + #[serde(default)] + size_delta: i64, + #[serde(default)] + dirty: bool, + #[serde(default)] + importers: usize, + #[serde(default)] + imports: usize, + #[serde(default)] + is_hub: bool, + #[serde(default)] + related_hot: Vec, +} + +#[derive(Debug, Clone, Default, Serialize, Deserialize)] +struct WorkingSet { + #[serde(default)] + files: HashMap, + #[serde(default)] + started_at: String, +} + +#[derive(Debug, Clone, Default, Serialize, Deserialize)] +struct WorkingFile { + #[serde(default)] + path: String, + #[serde(default)] + first_touch: String, + #[serde(default)] + last_touch: String, + #[serde(default)] + edit_count: usize, + #[serde(default)] + net_delta: i64, + #[serde(default)] + is_hub: bool, + #[serde(default)] + importers: usize, +} + +#[derive(Debug, Serialize)] +struct HubInfoResponse { + updated_at: String, + file_count: usize, + hubs: Vec, + importers: HashMap>, + imports: HashMap>, +} + +#[derive(Debug, Serialize)] +struct WorkingSetResponse { + updated_at: String, + working_set: Option, +} + +#[derive(Debug, Serialize)] +struct RecentEventsResponse { + updated_at: String, + recent_events: Vec, +} + +#[derive(Debug, Serialize)] +struct ProjectStatsResponse { + updated_at: String, + file_count: usize, + hubs: Vec, +} + +#[derive(Debug, Serialize)] +struct HealthResponse { + status: &'static str, +} + +#[derive(Debug)] +struct StateCache { + state_file: PathBuf, + modified_at: Option, + cached: Option, +} + +impl StateCache { + fn new(state_file: PathBuf) -> Self { + Self { + state_file, + modified_at: None, + cached: None, + } + } + + fn handle(&mut self, payload: &[u8]) -> Result, BoxError> { + let query: Query = serde_json::from_slice(payload)?; + match query { + Query::Health => { + serde_json::to_vec(&HealthResponse { status: "ok" }).map_err(Into::into) + } + Query::HubInfo => { + let state = self.load_state()?; + serde_json::to_vec(&HubInfoResponse { + updated_at: state.updated_at.clone(), + file_count: state.file_count, + hubs: state.hubs.clone(), + importers: state.importers.clone(), + imports: state.imports.clone(), + }) + .map_err(Into::into) + } + Query::WorkingSet => { + let state = self.load_state()?; + serde_json::to_vec(&WorkingSetResponse { + updated_at: state.updated_at.clone(), + working_set: state.working_set.clone(), + }) + .map_err(Into::into) + } + Query::RecentEvents { limit } => { + let state = self.load_state()?; + let recent_events = trim_recent_events(&state.recent_events, limit); + serde_json::to_vec(&RecentEventsResponse { + updated_at: state.updated_at.clone(), + recent_events, + }) + .map_err(Into::into) + } + Query::ProjectStats => { + let state = self.load_state()?; + serde_json::to_vec(&ProjectStatsResponse { + updated_at: state.updated_at.clone(), + file_count: state.file_count, + hubs: state.hubs.clone(), + }) + .map_err(Into::into) + } + } + } + + fn load_state(&mut self) -> Result<&CodemapState, BoxError> { + let metadata = fs::metadata(&self.state_file)?; + let modified_at = metadata.modified()?; + + let needs_reload = self.cached.is_none() || self.modified_at != Some(modified_at); + if needs_reload { + let bytes = fs::read(&self.state_file)?; + let state: CodemapState = serde_json::from_slice(&bytes)?; + self.cached = Some(state); + self.modified_at = Some(modified_at); + } + + self.cached + .as_ref() + .ok_or_else(|| "codemap state cache unavailable".into()) + } +} + +fn trim_recent_events(events: &[RecentEvent], limit: Option) -> Vec { + let Some(limit) = limit else { + return events.to_vec(); + }; + if limit == 0 || events.len() <= limit { + return events.to_vec(); + } + events[events.len() - limit..].to_vec() +} + +fn main() -> Result<(), BoxError> { + let config = parse_args(env::args().skip(1))?; + if let Some(parent) = config.socket_path.parent() { + fs::create_dir_all(parent)?; + } + + let state_file = config.state_file.clone(); + let daemon = Daemon::builder() + .socket(&config.socket_path) + .pid_file(&config.pid_file) + .idle_timeout(config.idle_timeout) + .on_start(move || Ok(StateCache::new(state_file.clone()))) + .on_request(|cache, payload| cache.handle(payload)) + .build()?; + + daemon.run()?; + Ok(()) +} + +fn parse_args(args: I) -> Result +where + I: IntoIterator, +{ + let mut root: Option = None; + let mut socket_path: Option = None; + let mut pid_file: Option = None; + let mut state_file: Option = None; + let mut idle_secs = 300u64; + + let mut iter = args.into_iter(); + while let Some(arg) = iter.next() { + match arg.as_str() { + "--root" => root = Some(PathBuf::from(next_arg(&mut iter, "--root")?)), + "--socket" => { + socket_path = Some(PathBuf::from(next_arg(&mut iter, "--socket")?)); + } + "--pid-file" => { + pid_file = Some(PathBuf::from(next_arg(&mut iter, "--pid-file")?)); + } + "--state-file" => { + state_file = Some(PathBuf::from(next_arg(&mut iter, "--state-file")?)); + } + "--idle-secs" => { + idle_secs = next_arg(&mut iter, "--idle-secs")?.parse()?; + } + "--help" | "-h" => { + print_usage(); + std::process::exit(0); + } + other => { + return Err(format!("unexpected argument: {other}").into()); + } + } + } + + let root = root.ok_or_else(|| "--root is required".to_string())?; + let codemap_dir = root.join(".codemap"); + + Ok(Config { + socket_path: socket_path.unwrap_or_else(|| codemap_dir.join("codemapd.sock")), + pid_file: pid_file.unwrap_or_else(|| codemap_dir.join("codemapd.pid")), + state_file: state_file.unwrap_or_else(|| codemap_dir.join("state.json")), + idle_timeout: Duration::from_secs(idle_secs), + }) +} + +fn next_arg(iter: &mut I, flag: &str) -> Result +where + I: Iterator, +{ + iter.next() + .ok_or_else(|| format!("{flag} requires a value").into()) +} + +fn print_usage() { + eprintln!( + "Usage: codemapd --root [--socket ] [--pid-file ] [--state-file ] [--idle-secs ]" + ); +} From 2dccae43f44f1684371a3ffb324923a9f5fbcafa Mon Sep 17 00:00:00 2001 From: Jordan Coin Jackson Date: Thu, 2 Apr 2026 14:46:48 -0400 Subject: [PATCH 3/4] Add --stdin flag for sandboxed app integration Accepts JSON file manifest from stdin instead of reading filesystem. Used by Lens Mac App Store app which can't grant subprocess file access. Co-Authored-By: Claude Opus 4.6 (1M context) --- main.go | 105 ++++++++++++++++++++++++++++++++++++++++------ main_more_test.go | 4 +- 2 files changed, 94 insertions(+), 15 deletions(-) diff --git a/main.go b/main.go index c1087e0..78fcc54 100644 --- a/main.go +++ b/main.go @@ -159,6 +159,7 @@ func main() { jsonMode := flag.Bool("json", false, "Output JSON (for Python renderer compatibility)") debugMode := flag.Bool("debug", false, "Show debug info (gitignore loading, paths, etc.)") watchMode := flag.Bool("watch", false, "Live file watcher daemon (experimental)") + stdinMode := flag.Bool("stdin", false, "Read file manifest from stdin (use with --deps)") importersMode := flag.String("importers", "", "Check file impact: who imports it, is it a hub?") helpMode := flag.Bool("help", false, "Show help") // Short flag aliases @@ -180,6 +181,7 @@ func main() { fmt.Println(" --depth, -d Limit tree depth (0 = unlimited)") fmt.Println(" --only Only show files with these extensions (e.g., 'swift,go')") fmt.Println(" --exclude Exclude paths matching patterns (e.g., '.xcassets,Fonts')") + fmt.Println(" --stdin Read JSON file manifest from stdin (use with --deps)") fmt.Println(" --importers Check file impact (who imports it, hub status)") fmt.Println() fmt.Println("Examples:") @@ -193,6 +195,7 @@ func main() { fmt.Println(" codemap --only swift . # Just Swift files") fmt.Println(" codemap --exclude .xcassets,Fonts,.png # Hide assets") fmt.Println(" codemap --importers scanner/types.go # Check file impact") + fmt.Println(" echo '{...}' | codemap --deps --stdin # Deps from file manifest") fmt.Println() fmt.Println("Remote repos (clones temporarily):") fmt.Println(" codemap github.com/user/repo # GitHub repo") @@ -328,7 +331,7 @@ func main() { if diffInfo != nil { changedFiles = diffInfo.Changed } - runDepsMode(absRoot, root, *jsonMode, *diffRef, changedFiles) + runDepsMode(absRoot, root, *jsonMode, *diffRef, changedFiles, *stdinMode) return } @@ -377,17 +380,43 @@ func main() { } } -func runDepsMode(absRoot, root string, jsonMode bool, diffRef string, changedFiles map[string]bool) { - analyses, err := scanner.ScanForDeps(root) - if err != nil { - fmt.Fprintf(os.Stderr, "Error: %v\n", err) - fmt.Fprintln(os.Stderr, "") - fmt.Fprintln(os.Stderr, "The --deps feature requires ast-grep. Install it with:") - fmt.Fprintln(os.Stderr, " brew install ast-grep # macOS/Linux (installs as 'sg')") - fmt.Fprintln(os.Stderr, " cargo install ast-grep # via Rust (installs as 'ast-grep')") - fmt.Fprintln(os.Stderr, " pipx install ast-grep # via Python (installs as 'ast-grep')") - fmt.Fprintln(os.Stderr, "") - os.Exit(1) +// stdinManifest is the JSON format accepted by --stdin. +type stdinManifest struct { + Root string `json:"root"` + Files []struct { + Path string `json:"path"` + Content string `json:"content"` + } `json:"files"` +} + +func runDepsMode(absRoot, root string, jsonMode bool, diffRef string, changedFiles map[string]bool, stdinMode bool) { + var analyses []FileAnalysis + var externalDeps map[string][]string + var err error + + if stdinMode { + analyses, externalDeps, err = runDepsFromStdin() + if err != nil { + fmt.Fprintf(os.Stderr, "Error reading stdin manifest: %v\n", err) + os.Exit(1) + } + // Use the manifest root as absRoot if provided + if externalDeps == nil { + externalDeps = make(map[string][]string) + } + } else { + analyses, err = scanForDepsWithHint(root) + if err != nil { + fmt.Fprintf(os.Stderr, "Error: %v\n", err) + fmt.Fprintln(os.Stderr, "") + fmt.Fprintln(os.Stderr, "The --deps feature requires ast-grep. Install it with:") + fmt.Fprintln(os.Stderr, " brew install ast-grep # macOS/Linux (installs as 'sg')") + fmt.Fprintln(os.Stderr, " cargo install ast-grep # via Rust (installs as 'ast-grep')") + fmt.Fprintln(os.Stderr, " pipx install ast-grep # via Python (installs as 'ast-grep')") + fmt.Fprintln(os.Stderr, "") + os.Exit(1) + } + externalDeps = scanner.ReadExternalDeps(absRoot) } // Filter to changed files if --diff specified @@ -399,7 +428,7 @@ func runDepsMode(absRoot, root string, jsonMode bool, diffRef string, changedFil Root: absRoot, Mode: "deps", Files: analyses, - ExternalDeps: scanner.ReadExternalDeps(absRoot), + ExternalDeps: externalDeps, DiffRef: diffRef, } @@ -411,6 +440,56 @@ func runDepsMode(absRoot, root string, jsonMode bool, diffRef string, changedFil } } +// scanForDepsWithHint wraps scanner.ScanForDeps (extracted for testability). +func scanForDepsWithHint(root string) ([]FileAnalysis, error) { + return scanner.ScanForDeps(root) +} + +// runDepsFromStdin reads a JSON manifest from stdin, writes files to a temp +// directory, runs ast-grep on it, and returns the results with paths matching +// the original manifest. +func runDepsFromStdin() ([]FileAnalysis, map[string][]string, error) { + var manifest stdinManifest + if err := json.NewDecoder(os.Stdin).Decode(&manifest); err != nil { + return nil, nil, fmt.Errorf("invalid JSON: %w", err) + } + + if len(manifest.Files) == 0 { + return nil, nil, nil + } + + // Create temp directory and write manifest files + tempDir, err := os.MkdirTemp("", "codemap-stdin-*") + if err != nil { + return nil, nil, fmt.Errorf("failed to create temp dir: %w", err) + } + defer os.RemoveAll(tempDir) + + for _, f := range manifest.Files { + dest := filepath.Join(tempDir, f.Path) + if err := os.MkdirAll(filepath.Dir(dest), 0755); err != nil { + return nil, nil, fmt.Errorf("mkdir %s: %w", filepath.Dir(dest), err) + } + if err := os.WriteFile(dest, []byte(f.Content), 0644); err != nil { + return nil, nil, fmt.Errorf("write %s: %w", f.Path, err) + } + } + + // Run ast-grep on temp directory + analyses, err := scanner.ScanForDeps(tempDir) + if err != nil { + return nil, nil, err + } + + // Read external deps from temp directory (manifest may include go.mod etc.) + externalDeps := scanner.ReadExternalDeps(tempDir) + + return analyses, externalDeps, nil +} + +// FileAnalysis is a type alias for use in main package. +type FileAnalysis = scanner.FileAnalysis + func runWatchMode(root string, verbose bool) { fmt.Println("codemap watch - Live code graph daemon") fmt.Println() diff --git a/main_more_test.go b/main_more_test.go index ba640e2..87fdb81 100644 --- a/main_more_test.go +++ b/main_more_test.go @@ -309,7 +309,7 @@ func TestRunDepsModeJSONAndMainDispatchesDepsAndImporters(t *testing.T) { writeImportersFixture(t, root) stdout, _ := captureMainStreams(t, func() { - runDepsMode(root, root, true, "main", map[string]bool{"a/a.go": true}) + runDepsMode(root, root, true, "main", map[string]bool{"a/a.go": true}, false) }) var depsProject scanner.DepsProject @@ -661,7 +661,7 @@ func TestRunDepsModeRenderedOutputAndMainTreeModes(t *testing.T) { writeImportersFixture(t, root) stdout, _ := captureMainStreams(t, func() { - runDepsMode(root, root, false, "main", nil) + runDepsMode(root, root, false, "main", nil, false) }) if !strings.Contains(stdout, "Dependency Flow") { t.Fatalf("expected rendered dependency graph output, got:\n%s", stdout) From f22f038b9aa54f48111d8402ea24ed73547cb26f Mon Sep 17 00:00:00 2001 From: Jordan Coin Jackson Date: Thu, 2 Apr 2026 14:48:30 -0400 Subject: [PATCH 4/4] Revert "Add --stdin flag for sandboxed app integration" This reverts commit 2dccae43f44f1684371a3ffb324923a9f5fbcafa. --- main.go | 105 ++++++---------------------------------------- main_more_test.go | 4 +- 2 files changed, 15 insertions(+), 94 deletions(-) diff --git a/main.go b/main.go index 78fcc54..c1087e0 100644 --- a/main.go +++ b/main.go @@ -159,7 +159,6 @@ func main() { jsonMode := flag.Bool("json", false, "Output JSON (for Python renderer compatibility)") debugMode := flag.Bool("debug", false, "Show debug info (gitignore loading, paths, etc.)") watchMode := flag.Bool("watch", false, "Live file watcher daemon (experimental)") - stdinMode := flag.Bool("stdin", false, "Read file manifest from stdin (use with --deps)") importersMode := flag.String("importers", "", "Check file impact: who imports it, is it a hub?") helpMode := flag.Bool("help", false, "Show help") // Short flag aliases @@ -181,7 +180,6 @@ func main() { fmt.Println(" --depth, -d Limit tree depth (0 = unlimited)") fmt.Println(" --only Only show files with these extensions (e.g., 'swift,go')") fmt.Println(" --exclude Exclude paths matching patterns (e.g., '.xcassets,Fonts')") - fmt.Println(" --stdin Read JSON file manifest from stdin (use with --deps)") fmt.Println(" --importers Check file impact (who imports it, hub status)") fmt.Println() fmt.Println("Examples:") @@ -195,7 +193,6 @@ func main() { fmt.Println(" codemap --only swift . # Just Swift files") fmt.Println(" codemap --exclude .xcassets,Fonts,.png # Hide assets") fmt.Println(" codemap --importers scanner/types.go # Check file impact") - fmt.Println(" echo '{...}' | codemap --deps --stdin # Deps from file manifest") fmt.Println() fmt.Println("Remote repos (clones temporarily):") fmt.Println(" codemap github.com/user/repo # GitHub repo") @@ -331,7 +328,7 @@ func main() { if diffInfo != nil { changedFiles = diffInfo.Changed } - runDepsMode(absRoot, root, *jsonMode, *diffRef, changedFiles, *stdinMode) + runDepsMode(absRoot, root, *jsonMode, *diffRef, changedFiles) return } @@ -380,43 +377,17 @@ func main() { } } -// stdinManifest is the JSON format accepted by --stdin. -type stdinManifest struct { - Root string `json:"root"` - Files []struct { - Path string `json:"path"` - Content string `json:"content"` - } `json:"files"` -} - -func runDepsMode(absRoot, root string, jsonMode bool, diffRef string, changedFiles map[string]bool, stdinMode bool) { - var analyses []FileAnalysis - var externalDeps map[string][]string - var err error - - if stdinMode { - analyses, externalDeps, err = runDepsFromStdin() - if err != nil { - fmt.Fprintf(os.Stderr, "Error reading stdin manifest: %v\n", err) - os.Exit(1) - } - // Use the manifest root as absRoot if provided - if externalDeps == nil { - externalDeps = make(map[string][]string) - } - } else { - analyses, err = scanForDepsWithHint(root) - if err != nil { - fmt.Fprintf(os.Stderr, "Error: %v\n", err) - fmt.Fprintln(os.Stderr, "") - fmt.Fprintln(os.Stderr, "The --deps feature requires ast-grep. Install it with:") - fmt.Fprintln(os.Stderr, " brew install ast-grep # macOS/Linux (installs as 'sg')") - fmt.Fprintln(os.Stderr, " cargo install ast-grep # via Rust (installs as 'ast-grep')") - fmt.Fprintln(os.Stderr, " pipx install ast-grep # via Python (installs as 'ast-grep')") - fmt.Fprintln(os.Stderr, "") - os.Exit(1) - } - externalDeps = scanner.ReadExternalDeps(absRoot) +func runDepsMode(absRoot, root string, jsonMode bool, diffRef string, changedFiles map[string]bool) { + analyses, err := scanner.ScanForDeps(root) + if err != nil { + fmt.Fprintf(os.Stderr, "Error: %v\n", err) + fmt.Fprintln(os.Stderr, "") + fmt.Fprintln(os.Stderr, "The --deps feature requires ast-grep. Install it with:") + fmt.Fprintln(os.Stderr, " brew install ast-grep # macOS/Linux (installs as 'sg')") + fmt.Fprintln(os.Stderr, " cargo install ast-grep # via Rust (installs as 'ast-grep')") + fmt.Fprintln(os.Stderr, " pipx install ast-grep # via Python (installs as 'ast-grep')") + fmt.Fprintln(os.Stderr, "") + os.Exit(1) } // Filter to changed files if --diff specified @@ -428,7 +399,7 @@ func runDepsMode(absRoot, root string, jsonMode bool, diffRef string, changedFil Root: absRoot, Mode: "deps", Files: analyses, - ExternalDeps: externalDeps, + ExternalDeps: scanner.ReadExternalDeps(absRoot), DiffRef: diffRef, } @@ -440,56 +411,6 @@ func runDepsMode(absRoot, root string, jsonMode bool, diffRef string, changedFil } } -// scanForDepsWithHint wraps scanner.ScanForDeps (extracted for testability). -func scanForDepsWithHint(root string) ([]FileAnalysis, error) { - return scanner.ScanForDeps(root) -} - -// runDepsFromStdin reads a JSON manifest from stdin, writes files to a temp -// directory, runs ast-grep on it, and returns the results with paths matching -// the original manifest. -func runDepsFromStdin() ([]FileAnalysis, map[string][]string, error) { - var manifest stdinManifest - if err := json.NewDecoder(os.Stdin).Decode(&manifest); err != nil { - return nil, nil, fmt.Errorf("invalid JSON: %w", err) - } - - if len(manifest.Files) == 0 { - return nil, nil, nil - } - - // Create temp directory and write manifest files - tempDir, err := os.MkdirTemp("", "codemap-stdin-*") - if err != nil { - return nil, nil, fmt.Errorf("failed to create temp dir: %w", err) - } - defer os.RemoveAll(tempDir) - - for _, f := range manifest.Files { - dest := filepath.Join(tempDir, f.Path) - if err := os.MkdirAll(filepath.Dir(dest), 0755); err != nil { - return nil, nil, fmt.Errorf("mkdir %s: %w", filepath.Dir(dest), err) - } - if err := os.WriteFile(dest, []byte(f.Content), 0644); err != nil { - return nil, nil, fmt.Errorf("write %s: %w", f.Path, err) - } - } - - // Run ast-grep on temp directory - analyses, err := scanner.ScanForDeps(tempDir) - if err != nil { - return nil, nil, err - } - - // Read external deps from temp directory (manifest may include go.mod etc.) - externalDeps := scanner.ReadExternalDeps(tempDir) - - return analyses, externalDeps, nil -} - -// FileAnalysis is a type alias for use in main package. -type FileAnalysis = scanner.FileAnalysis - func runWatchMode(root string, verbose bool) { fmt.Println("codemap watch - Live code graph daemon") fmt.Println() diff --git a/main_more_test.go b/main_more_test.go index 87fdb81..ba640e2 100644 --- a/main_more_test.go +++ b/main_more_test.go @@ -309,7 +309,7 @@ func TestRunDepsModeJSONAndMainDispatchesDepsAndImporters(t *testing.T) { writeImportersFixture(t, root) stdout, _ := captureMainStreams(t, func() { - runDepsMode(root, root, true, "main", map[string]bool{"a/a.go": true}, false) + runDepsMode(root, root, true, "main", map[string]bool{"a/a.go": true}) }) var depsProject scanner.DepsProject @@ -661,7 +661,7 @@ func TestRunDepsModeRenderedOutputAndMainTreeModes(t *testing.T) { writeImportersFixture(t, root) stdout, _ := captureMainStreams(t, func() { - runDepsMode(root, root, false, "main", nil, false) + runDepsMode(root, root, false, "main", nil) }) if !strings.Contains(stdout, "Dependency Flow") { t.Fatalf("expected rendered dependency graph output, got:\n%s", stdout)