Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
17 changes: 15 additions & 2 deletions blast_radius.go
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Expand Down Expand Up @@ -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 {
Expand Down
140 changes: 140 additions & 0 deletions cmd/agent_edits.go
Original file line number Diff line number Diff line change
@@ -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
}
5 changes: 5 additions & 0 deletions cmd/config.go
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down
38 changes: 38 additions & 0 deletions cmd/config_more_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -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)
}
}
Loading
Loading