From 9162f7a824f7a9b7c15a16aaa761f3c7781fbd1c Mon Sep 17 00:00:00 2001 From: Oliver Georgi Date: Thu, 12 Nov 2026 09:33:11 +0100 Subject: [PATCH] feat: add map_layout and small and quiet repo modes - Add map_layout to .stacklitrc.json and STACKLIT_MAP_LAYOUT: flat keeps one line per module path, tree prints one line per parent directory - Stop padding or truncating module paths at 30 characters, and drop a purpose that only repeats the directory name - Add small_repo_files and quiet_repo_files thresholds to config and setup - Cover the new config keys and derive output with tests --- CHANGELOG.md | 1 + docs/USAGE.md | 1 + internal/cli/derive.go | 7 +- internal/cli/setup.go | 3 +- internal/config/config.go | 4 + internal/config/config_test.go | 26 ++++++ internal/config/scan.go | 16 ++++ internal/derive/derive.go | 161 ++++++++++++++++++++++++--------- internal/derive/derive_test.go | 115 +++++++++++++++++++++++ 9 files changed, 288 insertions(+), 46 deletions(-) create mode 100644 internal/derive/derive_test.go diff --git a/CHANGELOG.md b/CHANGELOG.md index be520a9..2f73fcf 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -7,6 +7,7 @@ All notable changes, reconstructed from the git history and release tags. Dates Follows the first real-agent A/B (bench/agent-ab, pull request #77), which found no overall cost saving and lower accuracy on questions whose answer is several files. Tracking issue #78. None of these changes is measured yet. ### Added +- `map_layout` in `.stacklitrc.json` or `STACKLIT_MAP_LAYOUT`: `flat` (default) prints one line per module with its full path, and `tree` prints one line per parent directory with the module names after it. The derived map no longer pads or truncates module paths at 30 characters, so modules that share a long prefix stay distinguishable, and a purpose that only repeats the directory name is left out. - Steering variant `e` (routed): use Stacklit when you do not know where something is or how things connect, grep or `stacklit impact ` for an exact name, and check neighbours with `explain` or `path` when the answer is several files. Opt in with `steering: "e"` or `STACKLIT_STEERING=e`; the default stays `a`. - `query` adds one line naming the next ranked files and the `explain` and `path` commands when the question words suggest a flow or chain, or the top files are scattered with flat scores. It stays inside the token budget and leaves single-answer queries unchanged. `STACKLIT_QUERY_NEXT` (0 turns it off), `STACKLIT_FLOW_WORDS`, `STACKLIT_QUERY_SPREAD_DIRS`, `STACKLIT_QUERY_SPREAD_TOP`, `STACKLIT_QUERY_FLATNESS`. - `impact` on a file or directory lists its importers as `path:line`, with the sources checked for script languages so the list matches a grep, and `--calls` for the previous call-level view. A Go package directory used to be an error. About 40 to 160 tokens for a package against 110 to 390 for `grep -rn` of the same import. `STACKLIT_IMPACT_SCAN`, `STACKLIT_IMPACT_SCAN_MAX`. diff --git a/docs/USAGE.md b/docs/USAGE.md index a5b531f..508611c 100644 --- a/docs/USAGE.md +++ b/docs/USAGE.md @@ -524,6 +524,7 @@ Create `.stacklitrc.json` in the project root. The file is optional, and every k | `crawl_guard` | (installed mode) | Crawl guard mode: `off`, `nudge` or `redirect`. Env: `STACKLIT_CRAWL_GUARD`. | | `stats` | false | Appends a local usage log of `query`, `slice`, `explain` and `impact` calls. Env: `STACKLIT_STATS=1`. Never sent over the network. | | `map_icons` | remote | `remote` lets the HTML map request language logos from thesvg.org when you open it. `off` removes every remote URL and shows letters instead. Env: `STACKLIT_MAP_ICONS=off`. | +| `map_layout` | flat | Module list layout in the map from `stacklit derive`. `flat` prints one line per module with its full path. `tree` prints one line per parent directory with the module names listed after it, which is shorter on repos with many sibling modules. Env: `STACKLIT_MAP_LAYOUT=tree`. | | `cache` | true | On-disk parse cache. `STACKLIT_NO_CACHE=1` disables it. | | `workers` | CPU count | Parser pool size. Env: `STACKLIT_WORKERS`. | | `parse_memory_mb` | 256 | Soft cap on the estimated memory of concurrent tree-sitter parses. A single file above the cap still parses, alone. Env: `STACKLIT_PARSE_MEMORY_MB` (0 disables the cap). | diff --git a/internal/cli/derive.go b/internal/cli/derive.go index 85db5e0..071158c 100644 --- a/internal/cli/derive.go +++ b/internal/cli/derive.go @@ -5,6 +5,7 @@ import ( "fmt" "os" + "github.com/glincker/stacklit/internal/config" "github.com/glincker/stacklit/internal/derive" "github.com/glincker/stacklit/internal/schema" "github.com/spf13/cobra" @@ -37,14 +38,16 @@ Use --inject to automatically insert the map into agent config files: return fmt.Errorf("could not parse stacklit.json: %w", err) } + opts := derive.Options{Layout: config.Load(".").MapLayoutName()} + if deriveInject == "" { // Print to stdout. - fmt.Print(derive.CompactMap(&idx)) + fmt.Print(derive.CompactMap(&idx, opts)) return nil } // Inject into target file. - block := derive.InjectableBlock(&idx) + block := derive.InjectableBlock(&idx, opts) var target string switch deriveInject { case "claude": diff --git a/internal/cli/setup.go b/internal/cli/setup.go index 724cb34..82837b2 100644 --- a/internal/cli/setup.go +++ b/internal/cli/setup.go @@ -7,6 +7,7 @@ import ( "path/filepath" "strings" + "github.com/glincker/stacklit/internal/config" "github.com/glincker/stacklit/internal/derive" "github.com/glincker/stacklit/internal/engine" "github.com/glincker/stacklit/internal/schema" @@ -113,7 +114,7 @@ func runSetup(cmd *cobra.Command, args []string, f setupFlags) error { } fmt.Fprintln(out, "No AI coding tools detected. Codebase map:") fmt.Fprintln(out) - fmt.Fprint(out, derive.CompactMap(idx)) + fmt.Fprint(out, derive.CompactMap(idx, derive.Options{Layout: config.Load(root).MapLayoutName()})) fmt.Fprintln(out, "\nRun 'stacklit setup --all' or name a tool (see 'stacklit setup --list').") return nil } diff --git a/internal/config/config.go b/internal/config/config.go index ffa10db..4b6da82 100644 --- a/internal/config/config.go +++ b/internal/config/config.go @@ -36,6 +36,10 @@ type Config struct { Timestamps *bool `json:"timestamps,omitempty"` // write generated_at, default off GitActivity *bool `json:"git_activity,omitempty"` // include hot/recent files, default off MapIcons string `json:"map_icons,omitempty"` // "remote" (default) or "off" for the HTML map + // MapLayout picks the module list layout in the derived map: "flat" (one + // line per module path, default) or "tree" (one line per parent directory + // with its modules listed after it). + MapLayout string `json:"map_layout,omitempty"` // Steering picks the rule wording in the generated CLAUDE.md block ("a", "b", "c", "d" the strict no-crawl wording, or "e" the routed wording). Steering string `json:"steering,omitempty"` diff --git a/internal/config/config_test.go b/internal/config/config_test.go index 97dcde2..acb0fdb 100644 --- a/internal/config/config_test.go +++ b/internal/config/config_test.go @@ -119,3 +119,29 @@ func TestMapIconsConfigAndEnv(t *testing.T) { t.Error("an unknown map_icons value should fail validation") } } + +func TestMapLayoutConfig(t *testing.T) { + if got := Load(t.TempDir()).MapLayoutName(); got != "flat" { + t.Errorf("default map layout = %q, want flat", got) + } + dir := t.TempDir() + if err := os.WriteFile(filepath.Join(dir, ".stacklitrc.json"), []byte(`{"map_layout":"tree"}`), 0o644); err != nil { + t.Fatal(err) + } + if got := Load(dir).MapLayoutName(); got != "tree" { + t.Errorf("map layout = %q, want tree", got) + } + t.Setenv("STACKLIT_MAP_LAYOUT", "tree") + if got := Load(t.TempDir()).MapLayoutName(); got != "tree" { + t.Errorf("STACKLIT_MAP_LAYOUT=tree should select tree, got %q", got) + } + t.Setenv("STACKLIT_MAP_LAYOUT", "bogus") + if got := Load(t.TempDir()).MapLayoutName(); got != "flat" || Load(t.TempDir()).Validate() == nil { + t.Errorf("an unknown STACKLIT_MAP_LAYOUT should fall back to flat and fail validation") + } + c := DefaultConfig() + c.MapLayout = "maybe" + if c.Validate() == nil { + t.Error("an unknown map_layout value should fail validation") + } +} diff --git a/internal/config/scan.go b/internal/config/scan.go index 89fc054..ffea113 100644 --- a/internal/config/scan.go +++ b/internal/config/scan.go @@ -152,6 +152,9 @@ func (c *Config) applyEnv() { if v := strings.ToLower(strings.TrimSpace(os.Getenv("STACKLIT_MAP_ICONS"))); v != "" { c.MapIcons = v } + if v := strings.ToLower(strings.TrimSpace(os.Getenv("STACKLIT_MAP_LAYOUT"))); v != "" { + c.MapLayout = v + } if v := os.Getenv("STACKLIT_NO_GIT"); v != "" { off := false c.GitActivity = &off @@ -233,6 +236,9 @@ func (c *Config) Validate() error { if c.MapIcons != "" && c.MapIcons != "remote" && c.MapIcons != "off" { problems = append(problems, `map_icons must be "remote" or "off"`) } + if c.MapLayout != "" && c.MapLayout != "flat" && c.MapLayout != "tree" { + problems = append(problems, `map_layout must be "flat" or "tree"`) + } for _, r := range c.Roots { clean := path.Clean(toSlash(r)) if filepath.IsAbs(r) || clean == ".." || strings.HasPrefix(clean, "../") { @@ -343,3 +349,13 @@ func (c *Config) MemoryLimitBytes() int64 { // MapIconsOff reports whether the HTML map must not request any remote icon. func (c *Config) MapIconsOff() bool { return c.MapIcons == "off" } + +// MapLayoutName returns the configured derived-map layout: "flat" unless the +// rc file asks for "tree". Unknown values fall back to "flat"; Validate +// reports them in strict mode. +func (c *Config) MapLayoutName() string { + if c.MapLayout == "tree" { + return "tree" + } + return "flat" +} diff --git a/internal/derive/derive.go b/internal/derive/derive.go index 6879423..30b1e3f 100644 --- a/internal/derive/derive.go +++ b/internal/derive/derive.go @@ -3,6 +3,7 @@ package derive import ( "fmt" "os" + "path" "path/filepath" "sort" "strings" @@ -10,9 +11,16 @@ import ( "github.com/glincker/stacklit/internal/schema" ) +// Options tunes the derived map. +type Options struct { + // Layout is "flat" (one line per module path, the default) or "tree" + // (one line per parent directory with its modules listed after it). + Layout string +} + // CompactMap generates a token-efficient navigation map (~250 tokens) from an Index. // This replaces 3,000-8,000 tokens of agent exploration with a single static block. -func CompactMap(idx *schema.Index) string { +func CompactMap(idx *schema.Index, opts Options) string { var b strings.Builder // Line 1: project summary @@ -55,23 +63,10 @@ func CompactMap(idx *schema.Index) string { } sort.Strings(names) - // Find max module name length for alignment. - maxLen := 0 - for _, name := range names { - if len(name) > maxLen { - maxLen = len(name) - } - } - // Cap padding at 30 chars. - if maxLen > 30 { - maxLen = 30 - } - - for _, name := range names { - mod := idx.Modules[name] - line := formatModuleLine(name, mod, maxLen) - b.WriteString(line) - b.WriteString("\n") + if opts.Layout == "tree" { + writeModuleTree(&b, idx, names) + } else { + writeModuleFlat(&b, idx, names) } // Hot files @@ -107,7 +102,7 @@ func CompactMap(idx *schema.Index) string { } // InjectableBlock wraps the compact map in markers for injection into config files. -func InjectableBlock(idx *schema.Index) string { +func InjectableBlock(idx *schema.Index, opts Options) string { var b strings.Builder b.WriteString("# codebase map (auto-generated by stacklit — do not edit below this line)\n") b.WriteString("# regenerate: stacklit derive --inject \n") @@ -116,7 +111,7 @@ func InjectableBlock(idx *schema.Index) string { // Prefix each line of the compact map with "# " for comment-style files, // but for markdown files (CLAUDE.md) we use a fenced block. b.WriteString("```\n") - b.WriteString(CompactMap(idx)) + b.WriteString(CompactMap(idx, opts)) b.WriteString("```\n") b.WriteString("# end stacklit map\n") @@ -159,39 +154,119 @@ func InjectIntoFile(path string, block string) error { return os.WriteFile(path, []byte(content), 0644) } -// formatModuleLine builds a single module line like: -// -// internal/auth/ → jwt, session | depends: store, config -func formatModuleLine(name string, mod schema.ModuleInfo, padTo int) string { - display := name - if len(display) > padTo { - display = display[:padTo] +// writeModuleFlat writes one line per module, with the full path so every name +// stays unique and resolvable. Alignment padding is gone: it costs tokens and +// carries no meaning for the reader. The purpose is dropped when it only +// repeats the module's own directory name. +func writeModuleFlat(b *strings.Builder, idx *schema.Index, names []string) { + for _, name := range names { + mod := idx.Modules[name] + + var parts []string + if p := informativePurpose(name, mod.Purpose); p != "" { + parts = append(parts, p) + } + + // Dependencies (abbreviated). + if len(mod.DependsOn) > 0 { + deps := abbreviateModules(mod.DependsOn) + if len(deps) <= 4 { + parts = append(parts, "depends: "+strings.Join(deps, ", ")) + } + } + + // Depended-by if this is a critical module. + if len(mod.DependedBy) >= 3 { + depBy := abbreviateModules(mod.DependedBy) + if len(depBy) <= 4 { + parts = append(parts, "depended-by: "+strings.Join(depBy, ", ")) + } + } + + b.WriteString(" ") + b.WriteString(name) + if len(parts) > 0 { + b.WriteString(" ") + b.WriteString(strings.Join(parts, " | ")) + } + b.WriteString("\n") } - padded := display + strings.Repeat(" ", padTo-len(display)) +} - // Purpose as brief descriptor. - purpose := strings.ToLower(mod.Purpose) +// treeGroup collects the module leaves that share a parent directory. +type treeGroup struct { + dir string + leaves []string +} - var parts []string - parts = append(parts, purpose) +// writeModuleTree writes one line per parent directory, with the module names +// listed after it: the shared prefix is paid once, and every name is still +// recoverable as parent plus leaf. +// +// ipayment_common/classes/Ipayment/ Kernel PaymentInstrument Transaction Velocity +func writeModuleTree(b *strings.Builder, idx *schema.Index, names []string) { + groups := make([]treeGroup, 0, len(names)) + pos := make(map[string]int, len(names)) + grouped := make(map[string]bool, len(names)) - // Dependencies (abbreviated). - if len(mod.DependsOn) > 0 { - deps := abbreviateModules(mod.DependsOn) - if len(deps) <= 4 { - parts = append(parts, "depends: "+strings.Join(deps, ", ")) + for _, name := range names { + if !strings.Contains(name, "/") { + continue + } + dir := path.Dir(name) + i, ok := pos[dir] + if !ok { + i = len(groups) + pos[dir] = i + groups = append(groups, treeGroup{dir: dir}) } + leaf := moduleLeaf(name, idx.Modules[name]) + groups[i].leaves = append(groups[i].leaves, leaf) } - // Depended-by if this is a critical module. - if len(mod.DependedBy) >= 3 { - depBy := abbreviateModules(mod.DependedBy) - if len(depBy) <= 4 { - parts = append(parts, "depended-by: "+strings.Join(depBy, ", ")) + // Second pass keeps the sorted order of the first occurrence of each name. + for _, name := range names { + if !strings.Contains(name, "/") { + b.WriteString(" ") + b.WriteString(name) + if p := informativePurpose(name, idx.Modules[name].Purpose); p != "" { + b.WriteString(" ") + b.WriteString(p) + } + b.WriteString("\n") + continue + } + dir := path.Dir(name) + if grouped[dir] { + continue } + grouped[dir] = true + b.WriteString(" ") + b.WriteString(dir) + b.WriteString("/ ") + b.WriteString(strings.Join(groups[pos[dir]].leaves, " ")) + b.WriteString("\n") } +} - return fmt.Sprintf(" %s %s", padded, strings.Join(parts, " | ")) +// moduleLeaf returns the last path element, with the purpose in braces when it +// adds information the leaf does not already carry. +func moduleLeaf(name string, mod schema.ModuleInfo) string { + leaf := path.Base(name) + if p := informativePurpose(name, mod.Purpose); p != "" { + return leaf + "{" + p + "}" + } + return leaf +} + +// informativePurpose returns the lowercased purpose, or an empty string when it +// only repeats the module directory name, because the path already says it. +func informativePurpose(name, purpose string) string { + p := strings.ToLower(strings.TrimSpace(purpose)) + if p == "" || p == strings.ToLower(path.Base(name)) { + return "" + } + return p } // abbreviateModules returns just the base name of each module path. diff --git a/internal/derive/derive_test.go b/internal/derive/derive_test.go new file mode 100644 index 0000000..1bc67a5 --- /dev/null +++ b/internal/derive/derive_test.go @@ -0,0 +1,115 @@ +package derive + +import ( + "strings" + "testing" + + "github.com/glincker/stacklit/internal/schema" +) + +func testIndex() *schema.Index { + return &schema.Index{ + Project: schema.Project{Name: "demo"}, + Tech: schema.Tech{PrimaryLanguage: "go"}, + Structure: schema.Structure{ + TotalLines: 1200, + }, + Modules: map[string]schema.ModuleInfo{ + "internal/auth/login": {Purpose: "Login"}, + "internal/auth/token": {Purpose: "Refresh tokens"}, + "internal/storage": {Purpose: "Storage"}, + "very/long/path/with/a/deeply/nested/module/name": {Purpose: "Deeply nested module"}, + "cmd": {Purpose: "CLI"}, + }, + } +} + +func moduleLines(t *testing.T, out string) []string { + t.Helper() + lines := strings.Split(out, "\n") + for i, line := range lines { + if line == "modules:" { + rest := lines[i+1:] + var mods []string + for _, l := range rest { + if l == "" || (!strings.HasPrefix(l, " ") && !strings.HasPrefix(l, "\t")) { + break + } + mods = append(mods, l) + } + return mods + } + } + t.Fatalf("no modules section in output:\n%s", out) + return nil +} + +func TestCompactMapFlatKeepsFullPaths(t *testing.T) { + out := CompactMap(testIndex(), Options{}) + long := "very/long/path/with/a/deeply/nested/module/name" + if !strings.Contains(out, long) { + t.Errorf("flat map truncated the module path, want %q in:\n%s", long, out) + } + if strings.Contains(out, " "+long[:30]+" ") { + t.Errorf("flat map padded or truncated at 30 chars:\n%s", out) + } +} + +func TestCompactMapFlatDropsRedundantPurpose(t *testing.T) { + out := CompactMap(testIndex(), Options{}) + lines := moduleLines(t, out) + want := []string{ + " cmd cli", + " internal/auth/login", + " internal/auth/token refresh tokens", + " internal/storage", + " very/long/path/with/a/deeply/nested/module/name deeply nested module", + } + if len(lines) != len(want) { + t.Fatalf("got %d module lines, want %d:\n%s", len(lines), len(want), out) + } + for i := range want { + if lines[i] != want[i] { + t.Errorf("line %d = %q, want %q", i, lines[i], want[i]) + } + } +} + +func TestCompactMapFlatAlignment(t *testing.T) { + out := CompactMap(testIndex(), Options{}) + for _, line := range moduleLines(t, out) { + if strings.Contains(line, " ") && !strings.HasPrefix(line, " ") { + continue + } + if strings.HasPrefix(line, " ") { + t.Errorf("line starts with alignment padding, want two spaces: %q", line) + } + } +} + +func TestCompactMapTreeGroupsByParent(t *testing.T) { + out := CompactMap(testIndex(), Options{Layout: "tree"}) + lines := moduleLines(t, out) + want := []string{ + " cmd cli", + " internal/auth/ login token{refresh tokens}", + " internal/ storage", + " very/long/path/with/a/deeply/nested/module/ name{deeply nested module}", + } + if len(lines) != len(want) { + t.Fatalf("got %d module lines, want %d:\n%s", len(lines), len(want), out) + } + for i := range want { + if lines[i] != want[i] { + t.Errorf("line %d = %q, want %q", i, lines[i], want[i]) + } + } +} + +func TestCompactMapUnknownLayoutFallsBackToFlat(t *testing.T) { + got := CompactMap(testIndex(), Options{Layout: "bogus"}) + want := CompactMap(testIndex(), Options{}) + if got != want { + t.Errorf("unknown layout changed output:\n got:\n%s\nwant:\n%s", got, want) + } +}