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
1 change: 1 addition & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -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 <name>` 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`.
Expand Down
1 change: 1 addition & 0 deletions docs/USAGE.md
Original file line number Diff line number Diff line change
Expand Up @@ -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). |
Expand Down
7 changes: 5 additions & 2 deletions internal/cli/derive.go
Original file line number Diff line number Diff line change
Expand Up @@ -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"
Expand Down Expand Up @@ -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":
Expand Down
3 changes: 2 additions & 1 deletion internal/cli/setup.go
Original file line number Diff line number Diff line change
Expand Up @@ -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"
Expand Down Expand Up @@ -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
}
Expand Down
4 changes: 4 additions & 0 deletions internal/config/config.go
Original file line number Diff line number Diff line change
Expand Up @@ -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"`
Expand Down
26 changes: 26 additions & 0 deletions internal/config/config_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -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")
}
}
16 changes: 16 additions & 0 deletions internal/config/scan.go
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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, "../") {
Expand Down Expand Up @@ -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"
}
161 changes: 118 additions & 43 deletions internal/derive/derive.go
Original file line number Diff line number Diff line change
Expand Up @@ -3,16 +3,24 @@ package derive
import (
"fmt"
"os"
"path"
"path/filepath"
"sort"
"strings"

"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
Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -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 <target>\n")
Expand All @@ -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")

Expand Down Expand Up @@ -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.
Expand Down
Loading