Skip to content
5 changes: 5 additions & 0 deletions CONTRIBUTING.md
Original file line number Diff line number Diff line change
Expand Up @@ -92,6 +92,11 @@ make deps
- Run `go fmt` before committing
- Test your changes with `./codemap` on a real project


## Every line codemap prints

Output is reviewed against [docs/OUTPUT-STANDARD.md](docs/OUTPUT-STANDARD.md): sourced, precise, bounded, useful. A PR that adds or changes a printed line quotes it and says which tests it passes.

## Questions?

Open an issue or reach out. We're happy to help!
5 changes: 3 additions & 2 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -156,6 +156,7 @@ codemap --deps . # dependency flow
codemap --importers f # who imports a file
codemap blast-radius # review bundle: diff + deps + importers
codemap collide # rank open PRs by shared-file merge-order hazard
codemap find "<query>" # rank files by path and symbol match, with importer counts
codemap handoff . # save layered handoff for cross-agent continuation
codemap context # machine-readable project context JSON
codemap doctor # validate agent integrations
Expand Down Expand Up @@ -310,11 +311,11 @@ The prompt-submit hook classifies intent, surfaces hub-file risk, shows your wor

### MCP

`codemap mcp` serves 16 tools over stdio:
`codemap mcp` serves 19 tools over stdio:

| Category | Tools |
|----------|-------|
| Structure | `get_structure`, `find_file`, `get_hubs`, `get_file_context` |
| Structure | `get_structure`, `find`, `find_file`, `get_hubs`, `get_file_context` |
| Dependencies | `get_dependencies`, `get_importers`, `get_diff` |
| Session | `get_working_set`, `get_activity`, `get_handoff` |
| Daemon | `start_watch`, `stop_watch`, `status` |
Expand Down
1 change: 1 addition & 0 deletions blast_radius.go
Original file line number Diff line number Diff line change
Expand Up @@ -442,6 +442,7 @@ func buildBlastRadiusBundle(absRoot, ref string, limits blastRadiusLimits) (blas
return blastRadiusBundle{}, &blastRadiusDiffError{ref: ref, err: err}
}

cmd.EnsureProjectConfig(absRoot)
cfg := config.Load(absRoot)
filters := scanner.Filters{Only: cfg.Only, Exclude: cfg.Exclude}
gitCache := scanner.NewGitIgnoreCache(absRoot)
Expand Down
74 changes: 73 additions & 1 deletion cmd/config.go
Original file line number Diff line number Diff line change
Expand Up @@ -19,10 +19,74 @@ var errConfigExists = errors.New("config already exists")
type configInitResult struct {
Path string
TopExts []string
Exclude []string
TotalFiles int
MatchedFiles int
}

// noiseDirs are directories that are never project source and that the
// scanner does not already skip (Pods, build, dist, vendor, node_modules,
// target, DerivedData, testdata live in scanner.IgnoredDirs). Init excludes
// one only when it exists at the root or one level down, so a config never
// carries excludes for noise the repo does not have.
var noiseDirs = []string{"Carthage", "coverage", "fixtures", "__snapshots__", ".xcassets"}

// noiseExts are binary assets excluded when a repo carries them in volume.
var noiseExts = []string{"png", "jpg", "gif", "pdf", "otf", "ttf", "mp3", "mp4", "zip"}

const noiseExtThreshold = 10

// detectNoise returns exclude patterns for noise that is present in files.
func detectNoise(files []scanner.FileInfo) []string {
dirs := make(map[string]bool)
extCount := make(map[string]int)
for _, f := range files {
parts := strings.Split(filepath.ToSlash(f.Path), "/")
for i := 0; i < len(parts)-1 && i < 2; i++ {
for _, noise := range noiseDirs {
if parts[i] == noise || (strings.HasPrefix(noise, ".") && strings.HasSuffix(parts[i], noise)) {
dirs[noise] = true
}
}
}
extCount[strings.TrimPrefix(strings.ToLower(f.Ext), ".")]++
}
var out []string
for _, noise := range noiseDirs {
if dirs[noise] {
out = append(out, noise)
}
}
for _, ext := range noiseExts {
if extCount[ext] >= noiseExtThreshold {
out = append(out, "."+ext)
}
}
return out
}

// EnsureProjectConfig writes an auto-detected .codemap/config.json when none
// exists and root is a git checkout. Every analysis entry point calls this so
// a repo never stays in the "missing" state past its first codemap command.
// Write failures (read-only checkout) are silent: the caller still runs.
func EnsureProjectConfig(root string) bool {
if _, err := os.Stat(config.ConfigPath(root)); err == nil {
return false
}
if _, err := os.Stat(filepath.Join(root, ".git")); err != nil {
return false
}
if _, err := initProjectConfig(root); err != nil {
return false
}
// Only a person at a terminal needs the notice; piped consumers that
// merge stderr into a JSON stream must not see it.
if info, err := os.Stderr.Stat(); err == nil && info.Mode()&os.ModeCharDevice != 0 {
fmt.Fprintln(os.Stderr, "codemap: wrote .codemap/config.json (auto-detected); tune with: codemap skill show config-setup")
}
return true
}

// nonCodeExtensions are extensions excluded from "config init" auto-detection.
// These are documentation, data, or lock files that rarely represent the
// project's primary code.
Expand All @@ -37,6 +101,8 @@ var nonCodeExtensions = map[string]bool{
"log": true, "jsonl": true, "pid": true, "tmp": true,
"bak": true, "out": true, "cache": true, "swp": true,
"gitignore": true, "gitattributes": true, "editorconfig": true,
// Binary assets: an `only` slot for these contradicts the noise excludes.
"otf": true, "pdf": true, "mp3": true, "mp4": true, "zip": true,
}

// RunConfig dispatches the "config" subcommand.
Expand Down Expand Up @@ -86,6 +152,9 @@ func configInit(root string) {
fmt.Println("No code extensions detected — wrote empty config.")
} else {
fmt.Printf(" only: %s\n", strings.Join(result.TopExts, ", "))
if len(result.Exclude) > 0 {
fmt.Printf(" exclude: %s\n", strings.Join(result.Exclude, ", "))
}
if result.TotalFiles > 0 {
fmt.Printf(" (%d of %d files)\n", result.MatchedFiles, result.TotalFiles)
}
Expand Down Expand Up @@ -138,7 +207,10 @@ func initProjectConfig(root string) (configInitResult, error) {
result.TopExts = append(result.TopExts, e.Ext)
}

cfg := config.ProjectConfig{Only: result.TopExts}
// Mode is written explicitly: an init that looked for noise is a
// decision, not a bootstrap, so the config assesses as ready.
result.Exclude = detectNoise(files)
cfg := config.ProjectConfig{Only: result.TopExts, Exclude: result.Exclude, Mode: "auto"}

if err := os.MkdirAll(filepath.Dir(cfgPath), 0755); err != nil {
return result, fmt.Errorf("create .codemap directory: %w", err)
Expand Down
47 changes: 47 additions & 0 deletions cmd/config_more_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -177,3 +177,50 @@ func TestInitProjectConfigSkipsNoiseExtensions(t *testing.T) {
t.Fatalf("TopExts = %v, want [go]", result.TopExts)
}
}

func TestInitProjectConfigExcludesPresentNoise(t *testing.T) {
root := t.TempDir()
mustWriteConfigFixture(t, filepath.Join(root, "App", "main.swift"), "import UIKit\n")
mustWriteConfigFixture(t, filepath.Join(root, "App", "view.swift"), "import UIKit\n")
mustWriteConfigFixture(t, filepath.Join(root, "Carthage", "Build", "Alamofire.swift"), "import Foundation\n")
for i := 0; i < 20; i++ {
mustWriteConfigFixture(t, filepath.Join(root, "App", "Assets.xcassets", "icon"+strings.Repeat("x", i)+".png"), "png\n")
}

result, err := initProjectConfig(root)
if err != nil {
t.Fatalf("initProjectConfig: %v", err)
}
if got, want := strings.Join(result.Exclude, ","), "Carthage,.xcassets,.png"; got != want {
t.Fatalf("Exclude = %q, want %q", got, want)
}
if got := config.AssessSetup(root).State; got != config.SetupStateReady {
t.Fatalf("state after init = %q, want ready", got)
}
}

func TestEnsureProjectConfigWritesOnlyInsideRepos(t *testing.T) {
repo := t.TempDir()
if err := os.Mkdir(filepath.Join(repo, ".git"), 0o755); err != nil {
t.Fatal(err)
}
mustWriteConfigFixture(t, filepath.Join(repo, "main.go"), "package main\n")
if !EnsureProjectConfig(repo) {
t.Fatal("expected config to be written in a repo")
}
if _, err := os.Stat(config.ConfigPath(repo)); err != nil {
t.Fatalf("config not written: %v", err)
}
if EnsureProjectConfig(repo) {
t.Fatal("second call must not rewrite an existing config")
}

plain := t.TempDir()
mustWriteConfigFixture(t, filepath.Join(plain, "main.go"), "package main\n")
if EnsureProjectConfig(plain) {
t.Fatal("must not write config outside a repo")
}
if _, err := os.Stat(config.ConfigPath(plain)); !os.IsNotExist(err) {
t.Fatalf("config unexpectedly present in non-repo: %v", err)
}
}
12 changes: 10 additions & 2 deletions cmd/hooks.go
Original file line number Diff line number Diff line change
Expand Up @@ -412,6 +412,7 @@ func hookSessionStart(root string) error {
state = waitForDaemonState(root, 2*time.Second)
}
fileCount, fileCountKnown = configuredStateFileCount(root, state)
EnsureProjectConfig(root)
projCfg := config.Load(root)
structureBudget := projCfg.SessionStartOutputBytes()
maxHubs := projCfg.HubDisplayLimit()
Expand Down Expand Up @@ -887,6 +888,7 @@ func hookPromptSubmit(root string) error {
return nil
}

EnsureProjectConfig(root)
projCfg := config.Load(root)
topK := projCfg.RoutingTopKOrDefault()
info := getValidatedHubInfo(root)
Expand Down Expand Up @@ -1029,6 +1031,12 @@ func showMatchedSkills(root string, intent TaskIntent) {
names[i] = r.Name
}
fmt.Printf("Skills matched: %s — run `codemap skill show <name>` for guidance\n", strings.Join(names, ", "))
for _, r := range refs {
if r.Name == "config-setup" && strings.HasPrefix(r.Reason, "config:") {
fmt.Println("🛑 Codemap setup is not finished. Before continuing the user's task: run `codemap skill show config-setup`, tune .codemap/config.json, rerun `codemap .`, then proceed.")
break
}
}
}

type codemapNextStep struct {
Expand Down Expand Up @@ -1195,11 +1203,11 @@ func showConfigSetupHint(root string) {
fmt.Printf("<!-- codemap:config %s -->\n", string(data))
}

fmt.Println("⚙️ Codemap config setup recommended:")
fmt.Println("🛑 Codemap setup is not finished. Before continuing the user's task: run `codemap skill show config-setup`, tune .codemap/config.json, rerun `codemap .`, then proceed.")
for _, reason := range assessment.Reasons {
fmt.Printf(" • %s\n", reason)
}
fmt.Println("Run `codemap skill show config-setup` and tune `.codemap/config.json` before deeper analysis.")
fmt.Println("Run now: codemap skill show config-setup")
fmt.Println()
}

Expand Down
19 changes: 17 additions & 2 deletions cmd/hooks_more_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -573,8 +573,11 @@ func TestShowConfigSetupHint(t *testing.T) {
if !strings.Contains(out, "codemap:config") {
t.Fatalf("expected config marker, got:\n%s", out)
}
if !strings.Contains(out, "config setup recommended") {
t.Fatalf("expected setup heading, got:\n%s", out)
if !strings.Contains(out, "🛑 Codemap setup is not finished. Before continuing the user's task") {
t.Fatalf("expected escalated setup heading, got:\n%s", out)
}
if !strings.Contains(out, "Run now: codemap skill show config-setup") {
t.Fatalf("expected run-now line, got:\n%s", out)
}
if !strings.Contains(out, "config-setup") {
t.Fatalf("expected config-setup guidance, got:\n%s", out)
Expand All @@ -594,6 +597,18 @@ func TestShowConfigSetupHint(t *testing.T) {
t.Fatalf("expected no output for ready config, got:\n%s", out)
}
})

t.Run("boilerplate config escalates", func(t *testing.T) {
root := t.TempDir()
writeProjectConfig(t, root, config.ProjectConfig{Only: []string{"go"}})
out := captureOutput(func() { showConfigSetupHint(root) })
if !strings.Contains(out, "🛑 Codemap setup is not finished. Before continuing the user's task") {
t.Fatalf("expected escalated line for boilerplate config, got:\n%s", out)
}
if !strings.Contains(out, `"state":"boilerplate"`) {
t.Fatalf("expected boilerplate marker, got:\n%s", out)
}
})
}

func TestFindChildReposAndSessionStartVariants(t *testing.T) {
Expand Down
1 change: 1 addition & 0 deletions collide.go
Original file line number Diff line number Diff line change
Expand Up @@ -233,6 +233,7 @@ func runCollideSubcommand(args []string, launchDir string) int {
return 1
}

cmd.EnsureProjectConfig(resolvedRoot)
cfg := config.Load(resolvedRoot)
filters := scanner.Filters{Only: cfg.Only, Exclude: cfg.Exclude}
// The scan outcome is kept rather than discarded: a package-resolved
Expand Down
22 changes: 22 additions & 0 deletions docs/OUTPUT-STANDARD.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,22 @@
# Output standard

Every line codemap prints, posts, or renders is reviewed against four tests. A line that fails one does not ship. This applies to CLI output, hook output, MCP text, PR comments, the ledger, and any surface that renders codemap facts.

## The four tests

1. **Sourced.** The line names the path, line, PR, commit, or the command that reproduces it. If it cannot cite its source, it is an opinion, and codemap does not print opinions.
2. **Precise.** Numbers and names. Never "may", "might", "likely", "probably".
3. **Bounded.** The line states its own limit in the same breath: the coverage status, the cap that was hit, what was skipped.
4. **Useful.** The line names what to look at or what to do next. A fact with no next action is noise and is cut.

## Consequences

- A heuristic label does not ship until it is measured. A pair's "likelihood" is written to the calibration log and never printed. The printed line is the fact: `shared: src/email/emailService.ts (32 importers)`.
- An interpretation label comes after the number and states its threshold: `src/email/emailService.ts · 32 importers · blast high (high = 9+ importers or 2 hubs)`.
- No model writes a fact line. Agents consume codemap; they never produce its output.
- Third-party statistics do not appear on any codemap surface. Numbers from the user's own repositories do.
- Green says what it did not test. A passing result lists what was skipped.

## Review rule

A pull request that adds or changes a printed line quotes the new line in its description and says which of the four tests it passes and how. A reviewer who cannot find the source for a line asks for it or asks for the line to be removed.
Loading
Loading