Skip to content
Merged
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
2 changes: 2 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -251,6 +251,7 @@ subdirectory; relative setup paths resolve from the project root.
| `--depth, -d <n>` | Limit tree depth (0 = unlimited) |
| `--only <exts>` | Only include files with these extensions |
| `--exclude <patterns>` | Exclude files matching patterns |
| `--max-files <n>` | Scan bound: files kept by the tree, and the largest repo a dependency scan runs on (default 100000, or config `max_files`; `-1` = unbounded). Above it the output says `bounded: scanned N of M files` instead of presenting a prefix as the repo |
| `--diff` | Show files changed vs main branch |
| `--ref <branch>` | Branch to compare against (with `--diff`) |
| `--deps` | Dependency flow mode |
Expand Down Expand Up @@ -408,6 +409,7 @@ codemap config show # display current config
"only": ["rs", "sh", "sql", "toml", "yml"],
"exclude": ["docs/reference", "docs/research"],
"depth": 4,
"max_files": 100000,
"mode": "auto",
"guidance": {
"missing_extension_hints": true,
Expand Down
10 changes: 9 additions & 1 deletion blast_radius.go
Original file line number Diff line number Diff line change
Expand Up @@ -445,7 +445,7 @@ func buildBlastRadiusBundle(absRoot, ref string, limits blastRadiusLimits) (blas

cmd.EnsureProjectConfig(absRoot)
cfg := config.Load(absRoot)
filters := scanner.Filters{Only: cfg.Only, Exclude: cfg.Exclude}
filters := scanner.Filters{Only: cfg.Only, Exclude: cfg.Exclude, MaxFiles: cfg.MaxFiles}
gitCache := scanner.NewGitIgnoreCache(absRoot)
allFiles, err := scanner.ScanFiles(context.Background(), absRoot, gitCache, filters.Only, filters.Exclude)
if err != nil {
Expand Down Expand Up @@ -1422,6 +1422,14 @@ func renderImportersReportCLI(w io.Writer, report scanner.ImportersReport) {
renderCoverage(w, report.CoverageStatus, report.CoverageNotes)
return
}
if report.CoverageStatus == string(analysis.CoverageUnavailable) {
// No dependency scan ran (above the file bound, ast-grep missing),
// so there is no answer, and "No files import" would be a
// confident zero with nothing behind it (#186, #138).
fmt.Fprintf(w, "Importers of %s: unknown, no dependency scan ran.\n", report.File)
renderCoverage(w, report.CoverageStatus, report.CoverageNotes)
return
}
if report.Package != "" && report.PackageSiblings > 0 {
// A Go file's importers are the files that import its package, so
// a zero here says nothing about the package's own files: those
Expand Down
2 changes: 1 addition & 1 deletion collide.go
Original file line number Diff line number Diff line change
Expand Up @@ -235,7 +235,7 @@ func runCollideSubcommand(args []string, launchDir string) int {

cmd.EnsureProjectConfig(resolvedRoot)
cfg := config.Load(resolvedRoot)
filters := scanner.Filters{Only: cfg.Only, Exclude: cfg.Exclude}
filters := scanner.Filters{Only: cfg.Only, Exclude: cfg.Exclude, MaxFiles: cfg.MaxFiles}
// The scan outcome is kept rather than discarded: a package-resolved
// language needs the raw import strings, which the file graph does not
// preserve. BuildFileGraphFromOutcome reuses this same scan, so keeping it
Expand Down
1 change: 1 addition & 0 deletions config/config.go
Original file line number Diff line number Diff line change
Expand Up @@ -17,6 +17,7 @@ type ProjectConfig struct {
Only []string `json:"only,omitempty"`
Exclude []string `json:"exclude,omitempty"`
Depth int `json:"depth,omitempty"`
MaxFiles int `json:"max_files,omitempty"` // scan bound; 0 = default, negative = unbounded
Mode string `json:"mode,omitempty"`
Budgets HookBudgets `json:"budgets,omitempty"`
Routing RoutingConfig `json:"routing,omitempty"`
Expand Down
2 changes: 1 addition & 1 deletion find_cmd.go
Original file line number Diff line number Diff line change
Expand Up @@ -73,7 +73,7 @@ func runFindSubcommand(args []string, launchDir string) int {

cmd.EnsureProjectConfig(absRoot)
cfg := config.Load(absRoot)
report, err := find.Run(context.Background(), absRoot, scanner.Filters{Only: cfg.Only, Exclude: cfg.Exclude}, query, *limit)
report, err := find.Run(context.Background(), absRoot, scanner.Filters{Only: cfg.Only, Exclude: cfg.Exclude, MaxFiles: cfg.MaxFiles}, query, *limit)
if err != nil {
fmt.Fprintf(os.Stderr, "Error scanning project: %v\n", err)
return 1
Expand Down
22 changes: 22 additions & 0 deletions limits/limits.go
Original file line number Diff line number Diff line change
Expand Up @@ -19,8 +19,30 @@ const (
const (
MediumRepoFileCount = 2000
LargeRepoFileCount = 5000

// DefaultMaxScanFiles bounds the files a scan keeps and the size of repo
// a dependency scan is attempted on. Measured on 2026-10-11 with
// synthetic TypeScript repos: --deps peaked at 421 MB RSS on 60k files
// and 1,565 MB on 200k, about 8 MB per 1,000 files, linear. 100k is
// therefore roughly 800 MB and 10 s: the point where a default should
// stop and ask rather than let two concurrent scans exhaust a laptop
// (#186). Raised per run with --max-files or per repo with config
// max_files; a negative value removes the bound.
DefaultMaxScanFiles = 100000
)

// ScanFileBound resolves a configured bound: zero means the default,
// negative means unbounded.
func ScanFileBound(configured int) int {
if configured == 0 {
return DefaultMaxScanFiles
}
if configured < 0 {
return 0
}
return configured
}

// AdaptiveDepth returns a safe tree depth based on repository size.
// Unknown file count (<=0) defaults to a conservative depth.
func AdaptiveDepth(fileCount int) int {
Expand Down
18 changes: 15 additions & 3 deletions main.go
Original file line number Diff line number Diff line change
Expand Up @@ -237,6 +237,7 @@ func main() {
diffMode := flag.Bool("diff", false, "Only show files changed vs main (or use --ref to specify branch)")
diffRef := flag.String("ref", "main", "Branch/ref to compare against (use with --diff)")
depthLimit := flag.Int("depth", 0, "Limit tree depth (0 = unlimited)")
maxFiles := flag.Int("max-files", 0, "Scan bound: files kept and the largest repo a dependency scan runs on (0 = config max_files or 50000, -1 = unbounded)")
onlyExts := flag.String("only", "", "Only show files with these extensions (comma-separated, e.g., 'swift,go')")
excludePatterns := flag.String("exclude", "", "Exclude files matching patterns (comma-separated, e.g., '.xcassets,Fonts')")
jsonMode := flag.Bool("json", false, "Output JSON (for Python renderer compatibility)")
Expand Down Expand Up @@ -422,7 +423,10 @@ func main() {
if *depthLimit == 0 && projCfg.Depth > 0 {
*depthLimit = projCfg.Depth
}
filters := scanner.Filters{Only: only, Exclude: exclude}
if *maxFiles == 0 {
*maxFiles = projCfg.MaxFiles
}
filters := scanner.Filters{Only: only, Exclude: exclude, MaxFiles: *maxFiles}

if *debugMode {
fmt.Fprintf(os.Stderr, "[debug] Root path: %s\n", root)
Expand Down Expand Up @@ -500,12 +504,16 @@ func main() {
mode = "skyline"
}

// Scan files
files, err := scanner.ScanFiles(context.Background(), root, gitCache, only, exclude)
// Scan files, keeping at most the bound and counting the rest (#186).
files, totalFiles, err := scanner.ScanFilesBounded(context.Background(), root, gitCache, filters)
if err != nil {
fmt.Fprintf(os.Stderr, "Error walking tree: %v\n", err)
os.Exit(1)
}
var scanBound *scanner.ScanBound
if totalFiles > len(files) {
scanBound = &scanner.ScanBound{Scanned: len(files), Total: totalFiles, Limit: limits.ScanFileBound(*maxFiles)}
}

// Filter to changed files if --diff specified (with diff info annotations)
var impact []scanner.ImpactInfo
Expand All @@ -532,6 +540,7 @@ func main() {
Depth: *depthLimit,
Only: only,
Exclude: exclude,
ScanBound: scanBound,
}

// Render or output JSON
Expand All @@ -542,6 +551,9 @@ func main() {
} else {
render.Tree(os.Stdout, project)
}
if !*jsonMode && scanBound != nil {
fmt.Printf(" Coverage: partial — %s\n", scanBound.Note())
}
}

func applyGlobalRootOptions(args []string) ([]string, bool, error) {
Expand Down
5 changes: 5 additions & 0 deletions mcp/main.go
Original file line number Diff line number Diff line change
Expand Up @@ -18,6 +18,7 @@ import (
"time"
"unicode/utf8"

"codemap/analysis"
"codemap/config"
"codemap/find"
"codemap/handoff"
Expand Down Expand Up @@ -940,6 +941,10 @@ func handleGetImporters(ctx context.Context, req *mcp.CallToolRequest, input Imp
structured.NotIndexedReason = why.Reason
return textResult(notIndexedText(file, why, fg)), structured, nil
}
if fg.Coverage.EffectiveStatus() == analysis.CoverageUnavailable {
// No scan ran (file bound, ast-grep missing): unknown, not zero.
empty = fmt.Sprintf("Importers of '%s': unknown, no dependency scan ran", file)
}
return textResult(empty + mcpCoverageText(fg)), structured, nil
}

Expand Down
22 changes: 21 additions & 1 deletion render/depgraph.go
Original file line number Diff line number Diff line change
Expand Up @@ -63,7 +63,13 @@ func Depgraph(ctx context.Context, w io.Writer, project scanner.DepsProject) {
projectName := filepath.Base(project.Root)

if len(files) == 0 {
fmt.Fprintln(w, " No source files found.")
if refusedByScanBound(project.Coverage) {
// The repo is above the file bound, so nothing was scanned and
// "no source files" is not what happened (#186).
fmt.Fprintln(w, " Dependency scan not run: repository is above the file bound.")
} else {
fmt.Fprintln(w, " No source files found.")
}
renderCoverageLine(w, project.Coverage)
return
}
Expand Down Expand Up @@ -459,6 +465,20 @@ func pathDir(path string) string {

// renderCoverageLine surfaces degraded scan coverage in text output so a
// fail-closed or partial scan never reads as a complete, empty graph.
// refusedByScanBound reports whether coverage is unavailable because the
// scanner refused to run above the file bound (its note starts "bounded: ").
func refusedByScanBound(coverage analysis.Coverage) bool {
if coverage.Status != analysis.CoverageUnavailable {
return false
}
for _, source := range coverage.Sources {
if source.Name == "scan-bound" {
return true
}
}
return false
}

func renderCoverageLine(w io.Writer, coverage analysis.Coverage) {
if coverage.Status == "" || coverage.Status == analysis.CoverageComplete {
return
Expand Down
29 changes: 29 additions & 0 deletions scan_bound_cli_test.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,29 @@
package main

import (
"bytes"
"strings"
"testing"

"codemap/analysis"
"codemap/scanner"
)

// Above the file bound no dependency scan runs, so --importers has no answer.
// It used to print "No files import X" above an unavailable coverage line,
// which reads as a confident zero (#186, #138).
func TestImportersReportUnavailableSaysUnknown(t *testing.T) {
var out bytes.Buffer
renderImportersReportCLI(&out, scanner.ImportersReport{
File: "src/a.ts",
CoverageStatus: string(analysis.CoverageUnavailable),
CoverageNotes: []string{scanner.ScanBoundNote(0, 60001, 50000)},
})
got := out.String()
if strings.Contains(got, "No files import") {
t.Fatalf("printed a confident zero with no scan:\n%s", got)
}
if !strings.Contains(got, "unknown, no dependency scan ran") || !strings.Contains(got, "scanned 0 of 60001 files (limit 50000") {
t.Fatalf("output = %q, want the unknown wording and the bound note", got)
}
}
24 changes: 24 additions & 0 deletions scanner/cargofallback.go
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,8 @@ import (
"path/filepath"
"sort"
"strings"

"codemap/limits"
)

var errCargoFallbackUnavailable = errors.New("cargo dependency fallback unavailable")
Expand Down Expand Up @@ -73,6 +75,28 @@ func scanForGraphOutcomeWithFilters(ctx context.Context, root string, filters Fi
}
root = absRoot

// ast-grep walks the tree itself and its full JSON output is held in
// memory, so the only place to bound a dependency scan is before it
// starts. Every graph build routes through here (ScanForDeps and
// BuildFileGraph both). Count first, a stat walk that keeps one entry,
// and refuse the scan above the bound with the reason on the coverage
// line; --importers, blast-radius, find, hooks and MCP all read it.
if bound := limits.ScanFileBound(filters.MaxFiles); bound > 0 {
// A walk error (missing root, permission) is the scanner's to report
// with its own precedence; only a cancelled context stops here.
_, total, countErr := ScanFilesBounded(ctx, root, NewGitIgnoreCache(root), Filters{Only: filters.Only, Exclude: filters.Exclude, MaxFiles: 1})
if ctxErr := ctx.Err(); ctxErr != nil {
return ScanOutcome{}, false, ctxErr
}
if countErr == nil && total > bound {
return ScanOutcome{Sources: []ScanSourceOutcome{{
Name: scanBoundSource,
Status: ScanSourceUnavailable,
Detail: ScanBoundNote(0, total, bound),
}}}, true, nil
}
}

outcome, err := scan(root)
degraded := false
if err == nil {
Expand Down
2 changes: 1 addition & 1 deletion scanner/filegraph.go
Original file line number Diff line number Diff line change
Expand Up @@ -159,7 +159,7 @@ func BuildFileGraphFromAnalyses(ctx context.Context, root string, analyses []Fil

func buildFileGraphFromAnalysesWithCargoMetadata(ctx context.Context, root string, analyses []FileAnalysis, loader cargoMetadataLoader) (*FileGraph, error) {
cfg := config.Load(root)
filters := Filters{Only: cfg.Only, Exclude: cfg.Exclude}
filters := Filters{Only: cfg.Only, Exclude: cfg.Exclude, MaxFiles: cfg.MaxFiles}
filtered, err := filterAnalysesContext(ctx, analyses, filters)
if err != nil {
return nil, err
Expand Down
89 changes: 89 additions & 0 deletions scanner/scan_bound_test.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,89 @@
package scanner

import (
"context"
"fmt"
"os"
"path/filepath"
"strings"
"testing"

"codemap/analysis"
)

func writeChainRepo(t *testing.T, n int) string {
t.Helper()
dir := t.TempDir()
for i := 0; i < n; i++ {
body := fmt.Sprintf("import { x } from './f%d';\nexport const x = %d;\n", (i+1)%n, i)
if err := os.WriteFile(filepath.Join(dir, fmt.Sprintf("f%d.ts", i)), []byte(body), 0o644); err != nil {
t.Fatal(err)
}
}
return dir
}

// A repository above the file bound used to be walked and graphed in full,
// and a machine running two such scans ran out of memory (#186). The walk
// now keeps the first N files and reports the whole count.
func TestScanFilesBoundedStopsAtLimit(t *testing.T) {
dir := writeChainRepo(t, 5)
files, total, err := ScanFilesBounded(context.Background(), dir, nil, Filters{MaxFiles: 3})
if err != nil {
t.Fatal(err)
}
if len(files) != 3 || total != 5 {
t.Fatalf("files = %d, total = %d, want 3 of 5", len(files), total)
}
files, total, err = ScanFilesBounded(context.Background(), dir, nil, Filters{MaxFiles: -1})
if err != nil {
t.Fatal(err)
}
if len(files) != 5 || total != 5 {
t.Fatalf("unbounded: files = %d, total = %d, want 5 of 5", len(files), total)
}
}

// Above the bound the dependency scan is not attempted at all, so no graph is
// allocated, and every consumer of the coverage line sees why.
func TestScanForDepsRefusesAboveBound(t *testing.T) {
dir := writeChainRepo(t, 3)
outcome, err := ScanForDeps(context.Background(), dir, Filters{MaxFiles: 2})
if err != nil {
t.Fatal(err)
}
if len(outcome.Analyses) != 0 {
t.Fatalf("analyses = %d, want none above the bound", len(outcome.Analyses))
}
coverage := CoverageFromSources(outcome.Sources)
if coverage.Status != analysis.CoverageUnavailable {
t.Fatalf("status = %v, want unavailable (sources %v)", coverage.Status, outcome.Sources)
}
want := "bounded: scanned 0 of 3 files (limit 2; raise with --max-files or config max_files)"
found := false
for _, source := range outcome.Sources {
if source.Name == "scan-bound" && source.Detail == want {
found = true
}
}
if !found {
t.Fatalf("sources = %v, want scan-bound with detail %q", outcome.Sources, want)
}

graph, err := BuildFileGraph(context.Background(), dir, Filters{MaxFiles: 2})
if err != nil {
t.Fatal(err)
}
if len(graph.Imports) != 0 || graph.Coverage.EffectiveStatus() != analysis.CoverageUnavailable || !strings.Contains(strings.Join(graph.Coverage.Notes, "\n"), "bounded: scanned 0 of 3 files") {
t.Fatalf("graph above bound: imports = %d, status = %v, notes = %v", len(graph.Imports), graph.Coverage.EffectiveStatus(), graph.Coverage.Notes)
}

// Under the bound nothing changes.
outcome, err = ScanForDeps(context.Background(), dir, Filters{MaxFiles: 3})
if err != nil {
t.Fatal(err)
}
if len(outcome.Analyses) != 3 || CoverageFromSources(outcome.Sources).Status != analysis.CoverageComplete {
t.Fatalf("at bound: analyses = %d, status = %v", len(outcome.Analyses), CoverageFromSources(outcome.Sources).Status)
}
}
Loading
Loading