diff --git a/docs/multi-repo.md b/docs/multi-repo.md index c0be9251..b3645c83 100644 --- a/docs/multi-repo.md +++ b/docs/multi-repo.md @@ -115,7 +115,50 @@ Agents can manage repos at runtime without CLI access: | `set_active_project` | Switch project scope for all subsequent queries | | `get_active_project` | Return current project name and repo list | -All query tools (`search_symbols`, `get_symbol`, `find_usages`, `get_file_summary`, `get_call_chain`, `smart_context`) accept optional `repo`, `project`, and `ref` parameters for scoping. When an active project is set, it applies as the default scope. +Locate, reach, and analyze query tools uniformly accept `repo`, `project`, `workspace`, and `scope` parameters for scoping (plus `ref` where reference tags apply). All are clamped to the session workspace — the hard isolation boundary. Default breadth now follows **tool intent** when `scope.intent_defaults` is enabled (the default); see [Tool scoping by intent](#tool-scoping-by-intent) below. + +For `analyze`, the overrides genuinely narrow its **graph-node** kinds — `dead_code`, `hotspots`, `cycles`, `health_score`, `todos`, `stale_code`, `ownership`, `coverage_gaps`, `coverage_summary`, `impact`, `bottlenecks`, `role`, `k8s_resources`, `images`, `kustomize`, `dbt_models`, `external_calls`, and the like — and, since v1, its **edge-walk / graph-algorithm / framework / file-AST-scan** kinds too (`channel_ops`, `pubsub`, `routes`, `models`, `pagerank`, `kcore`, `edge_audit`, `tests_as_edges`, `sast`, `review`, …), which prune their rows / re-tally their counts against the same workspace + repo allow-set. The narrowing also resolves the two kind-specific collisions: `kind=cross_repo` keeps `repo` as its boundary filter and `kind=cycles` keeps `scope` as a file-path / package prefix (both are stripped from the uniform scope-resolution view). **v1 caveat:** the remaining long-tail kinds — community detection (`clusters`, `concepts`, `suggest_boundaries`), git/disk-mining (`blame`, `coverage`, `fixes_history`, `retrieval_log`, `temporal_verify`), per-id (`would_create_cycle`, `def_use`), `synthesizers` / `resolution_outcomes`, and `sql_rebuild` — remain workspace-bound but are **not** repo-narrowed — passing a narrowing arg on such a kind stamps a `scope_note` on the response disclosing the no-op. + +## Tool scoping by intent + +Tools are split by intent — each group has a different default scope: + +| Intent | Tools | Default scope | +|--------|-------|---------------| +| **Locate** ("where is X defined") | `search_symbols`, `search_text`, `find_files` | current repo | +| **Reach** ("who consumes X") | `find_usages`, `get_callers`, `get_call_chain`, `contracts` | workspace | +| **Analyze** | `analyze`, `review`, sast | workspace (graph-node + edge-walk / algorithm / framework / scan kinds narrow to `repo`/`project`/`scope`; community / git-mining / per-id kinds stay workspace-bound — see the caveat above) | + +Other query tools (`get_symbol`, `get_file_summary`, `smart_context`, etc.) keep their existing per-tool scope classification; the intent defaults above apply to the locate/reach/analyze groups listed in the table. + +### `scope.intent_defaults` config flag + +- Controls the intent-based default scoping described above +- **Defaults ON** (enabled out of the box — this is the new behavior after upgrade) +- **Narrow-only invariant:** the intent defaults only ever *narrow* within the session workspace (the hard isolation boundary); they never widen past it, and an explicit `repo` / `project` / `workspace` / `scope` arg always overrides the default +- Opt out: set `scope.intent_defaults: false` in `.gortex.yaml`, or set env var `GORTEX_SCOPE_INTENT_DEFAULTS=0` + +**⚠ Upgrade note (behavior change):** When upgrading to this version: + +- Locate tools narrow their default: project → repo (you now need `repo:"*"` to search the whole workspace) +- Reach tools widen their default: project → workspace (cross-repo callers surface automatically) +- Restore the old behavior with `scope.intent_defaults: false` or `GORTEX_SCOPE_INTENT_DEFAULTS=0` + +### Widen sentinels + +When intent defaults are on, you can still widen or narrow explicitly: + +- `repo:"*"` — widen a locate tool back to the whole workspace +- `project:` — select the middle rung (explicit project scope) +- `scope:` — select a named saved scope + +### Uniform parameter set + +Every locate/reach/analyze tool now uniformly accepts `repo`, `project`, `workspace`, and `scope` parameters. All are clamped to the session workspace (the hard isolation boundary). For `analyze` this narrows the graph-node, edge-walk, graph-algorithm, framework, and file/AST-scan kinds; the remaining community / git-mining / per-id / synthesizer kinds are workspace-bound but not repo-narrowed in v1 (see the [MCP tools](#mcp-tools) caveat above). + +### Response metadata + +Scoped tool responses carry a `scope_applied` meta field plus a one-line widen hint naming an explicit override that re-broadens the result (e.g. `repo:"*"` for the whole workspace, or `project:` / `scope:` to re-scope to a deliberate rung). `analyze` additionally stamps a `scope_note` when a narrowing arg is passed to a kind that does not repo-narrow its rows in v1, so the no-op is self-documenting rather than silent. ## How it works diff --git a/internal/config/config.go b/internal/config/config.go index 231b9e02..52a0d119 100644 --- a/internal/config/config.go +++ b/internal/config/config.go @@ -470,6 +470,7 @@ type Config struct { Index IndexConfig `mapstructure:"index" yaml:"index,omitempty"` Watch WatchConfig `mapstructure:"watch" yaml:"watch,omitempty"` Query QueryConfig `mapstructure:"query" yaml:"query,omitempty"` + Scope ScopeConfig `mapstructure:"scope" yaml:"scope,omitempty"` Search SearchConfig `mapstructure:"search" yaml:"search,omitempty"` // Embedding configures the semantic-search vector channel: the // embedding provider plus the chunking / concurrency knobs the @@ -1261,6 +1262,23 @@ type QueryConfig struct { MaxDepth int `mapstructure:"max_depth" yaml:"max_depth,omitempty"` } +type ScopeConfig struct { + IntentDefaults bool `mapstructure:"intent_defaults" yaml:"intent_defaults,omitempty"` +} + +// MergeEnv overlays the scope-specific environment knobs on top of +// file/default config values. Invalid values are ignored so a typo does not +// silently disable scoped queries. +func (c ScopeConfig) MergeEnv() ScopeConfig { + switch strings.ToLower(strings.TrimSpace(os.Getenv("GORTEX_SCOPE_INTENT_DEFAULTS"))) { + case "0", "false", "no": + c.IntentDefaults = false + case "1", "true", "yes": + c.IntentDefaults = true + } + return c +} + // SearchConfig configures the I13 11-signal rerank pipeline that // orders `search_symbols` / `winnow_symbols` results. The Weights // map is keyed by canonical signal name (rerank.SignalBM25, @@ -1667,6 +1685,9 @@ func Default() *Config { DefaultDepth: 3, MaxDepth: 10, }, + Scope: ScopeConfig{ + IntentDefaults: true, + }, MCP: MCPConfig{ Transport: "stdio", Port: 8765, @@ -1737,6 +1758,7 @@ func Load(configPath string) (*Config, error) { if err := v.Unmarshal(cfg); err != nil { return nil, err } + cfg.Scope = cfg.Scope.MergeEnv() if err := cfg.validateWorkspaceSchema(); err != nil { return nil, err diff --git a/internal/indexer/multi.go b/internal/indexer/multi.go index 29fee9f4..362b29d8 100644 --- a/internal/indexer/multi.go +++ b/internal/indexer/multi.go @@ -7,6 +7,7 @@ import ( "os" "path/filepath" "runtime" + "sort" "strings" "sync" "time" @@ -1896,6 +1897,68 @@ func (mi *MultiIndexer) GetIndexer(repoPrefix string) *Indexer { return mi.indexers[repoPrefix] } +type grepRepoJob struct { + prefix string + idx *Indexer +} + +func (mi *MultiIndexer) grepRepoJobs(repoAllow map[string]bool) []grepRepoJob { + if mi == nil { + return nil + } + mi.mu.RLock() + defer mi.mu.RUnlock() + capHint := len(mi.indexers) + if repoAllow != nil { + capHint = len(repoAllow) + } + jobs := make([]grepRepoJob, 0, capHint) + for prefix, idx := range mi.indexers { + if idx == nil { + continue + } + if repoAllow != nil && !repoAllow[prefix] { + continue + } + jobs = append(jobs, grepRepoJob{prefix: prefix, idx: idx}) + } + sort.Slice(jobs, func(i, j int) bool { return jobs[i].prefix < jobs[j].prefix }) + return jobs +} + +func singleAllowedRepo(repoAllow map[string]bool) (string, bool) { + if repoAllow == nil { + return "", false + } + var only string + count := 0 + for prefix, allowed := range repoAllow { + if !allowed { + continue + } + only = prefix + count++ + } + return only, count == 1 +} + +func stampGrepMatchPaths(prefix string, hits []trigram.Match) []trigram.Match { + if prefix == "" { + return hits + } + for i := range hits { + hits[i].Path = prefix + "/" + hits[i].Path + } + return hits +} + +func capGrepMatches(matches []trigram.Match, limit int) []trigram.Match { + if limit > 0 && len(matches) > limit { + return matches[:limit] + } + return matches +} + // GrepText fans out a trigram-accelerated literal search across every // tracked per-repo Indexer and returns the union, capped at limit. // Match paths are re-stamped from repo-root-relative to repo-prefixed @@ -1906,45 +1969,40 @@ func (mi *MultiIndexer) GetIndexer(repoPrefix string) *Indexer { // single-Indexer path (Indexer.GrepText) is used by callers without a // MultiIndexer. func (mi *MultiIndexer) GrepText(query string, limit int) []trigram.Match { + return capGrepMatches(mi.GrepTextForRepos(query, nil, limit), limit) +} + +// GrepTextForRepos is the scoped variant of GrepText. When repoAllow is +// non-nil, only those repo prefixes are searched. perRepoLimit caps each +// searched repo independently; the returned union is intentionally not +// globally capped so callers can apply path / graph-scope filters first. +func (mi *MultiIndexer) GrepTextForRepos(query string, repoAllow map[string]bool, perRepoLimit int) []trigram.Match { if mi == nil || query == "" { return nil } - mi.mu.RLock() - type job struct { - prefix string - idx *Indexer - } - jobs := make([]job, 0, len(mi.indexers)) - for prefix, idx := range mi.indexers { + if prefix, ok := singleAllowedRepo(repoAllow); ok { + idx := mi.GetIndexer(prefix) if idx == nil { - continue + return nil } - jobs = append(jobs, job{prefix: prefix, idx: idx}) + return stampGrepMatchPaths(prefix, idx.GrepText(query, perRepoLimit)) } - mi.mu.RUnlock() - // Per-repo cap mirrors the overall limit when set; the merge below - // applies the final cap so a small repo contributing 100 matches - // doesn't starve a larger one. Zero / negative means no per-repo - // cap (let each searcher return everything). - perCap := limit + // Per-repo cap mirrors the caller's page size when set. The caller + // applies the final cap after any path / graph-scope filters, so a + // repo outside those filters cannot consume the page first. Zero / + // negative means no per-repo cap (let each searcher return everything). + jobs := mi.grepRepoJobs(repoAllow) out := make([]trigram.Match, 0, len(jobs)*8) for _, j := range jobs { - hits := j.idx.GrepText(query, perCap) + hits := j.idx.GrepText(query, perRepoLimit) if len(hits) == 0 { continue } - for i := range hits { - // Trigram emits forward-slash repo-relative paths. Stamp - // the repo prefix so downstream tools (resolveGraphPath, - // path-prefix filters) see the same shape they get from - // the graph nodes. - hits[i].Path = j.prefix + "/" + hits[i].Path - } - out = append(out, hits...) - } - if limit > 0 && len(out) > limit { - out = out[:limit] + // Trigram emits forward-slash repo-relative paths. Stamp the repo + // prefix so downstream tools (resolveGraphPath, path-prefix filters) + // see the same shape they get from the graph nodes. + out = append(out, stampGrepMatchPaths(j.prefix, hits)...) } return out } @@ -1958,27 +2016,35 @@ func (mi *MultiIndexer) GrepText(query string, limit int) []trigram.Match { // compile in any indexer is reported once; per-indexer errors after // the first compile are otherwise treated as no-match. func (mi *MultiIndexer) GrepRegexp(pattern, pathPrefix string, limit int) ([]trigram.Match, error) { + hits, err := mi.GrepRegexpForRepos(pattern, pathPrefix, nil, limit) + if err != nil { + return nil, err + } + return capGrepMatches(hits, limit), nil +} + +// GrepRegexpForRepos is the scoped variant of GrepRegexp. repoAllow and +// perRepoLimit have the same semantics as GrepTextForRepos. +func (mi *MultiIndexer) GrepRegexpForRepos(pattern, pathPrefix string, repoAllow map[string]bool, perRepoLimit int) ([]trigram.Match, error) { if mi == nil || pattern == "" { return nil, nil } - mi.mu.RLock() - type job struct { - prefix string - idx *Indexer - } - jobs := make([]job, 0, len(mi.indexers)) - for prefix, idx := range mi.indexers { + if prefix, ok := singleAllowedRepo(repoAllow); ok { + idx := mi.GetIndexer(prefix) if idx == nil { - continue + return nil, nil + } + hits, err := idx.GrepRegexp(pattern, pathPrefix, perRepoLimit) + if err != nil { + return nil, err } - jobs = append(jobs, job{prefix: prefix, idx: idx}) + return stampGrepMatchPaths(prefix, hits), nil } - mi.mu.RUnlock() - perCap := limit + jobs := mi.grepRepoJobs(repoAllow) out := make([]trigram.Match, 0, len(jobs)*8) for _, j := range jobs { - hits, err := j.idx.GrepRegexp(pattern, pathPrefix, perCap) + hits, err := j.idx.GrepRegexp(pattern, pathPrefix, perRepoLimit) if err != nil { // First compile error short-circuits — the pattern is the // caller's fault and won't compile in any other indexer @@ -1988,13 +2054,7 @@ func (mi *MultiIndexer) GrepRegexp(pattern, pathPrefix string, limit int) ([]tri if len(hits) == 0 { continue } - for i := range hits { - hits[i].Path = j.prefix + "/" + hits[i].Path - } - out = append(out, hits...) - } - if limit > 0 && len(out) > limit { - out = out[:limit] + out = append(out, stampGrepMatchPaths(j.prefix, hits)...) } return out, nil } diff --git a/internal/mcp/analyze_kinds.go b/internal/mcp/analyze_kinds.go index 4c5faa2c..781ee891 100644 --- a/internal/mcp/analyze_kinds.go +++ b/internal/mcp/analyze_kinds.go @@ -93,6 +93,101 @@ var analyzeKinds = []string{ "would_create_cycle", } +// analyzeScopeAwareKinds is the set of analyze kinds whose result rows +// are genuinely narrowed by the uniform repo/project/workspace/scope +// overrides in v1. Three tiers populate it: +// +// - AUTO: kinds that obtain their working node set through the +// scopedNodes / scopedNodesByKinds / scopedNodeSlice accessors, so +// RepoAllow narrows them centrally with no per-handler code. +// - Per-row-filtered: kinds that read s.graph directly but gate every +// emitted row on analyzeNodeVisible (workspace ceiling + optional +// repo allow-set) — the three flagship kinds (dead_code, hotspots, +// cycles) plus the category-(a) edge-walk / graph-algorithm / +// framework kinds that prune their rows and re-tally their counts. +// - File-path-filtered: the category-(c) file/AST scans (sast, hygiene, +// review, domain, named, unsafe_patterns), already narrowed by +// resolveRepoFilter / buildASTTargets before the handler runs. +// +// handleAnalyze consults this set so that when a caller asks to narrow +// (resolved.RepoAllow != nil) but picks a kind that is NOT in the set, +// the response carries a `scope_note` disclosing that the kind is not +// repo-narrowed in v1 (a community / git-mining / per-id / synthesizer +// kind). This keeps the uniform `scope_applied` truthful while flagging +// the remaining long-tail no-op kinds — "no silent no-ops". +var analyzeScopeAwareKinds = map[string]bool{ + // AUTO — narrowed centrally by the scoped-node accessors. + "todos": true, + "stale_code": true, + "ownership": true, + "coverage_gaps": true, + "stale_flags": true, + "cgo_users": true, + "wasm_users": true, + "orphan_tables": true, + "unreferenced_tables": true, + "coverage_summary": true, + "health_score": true, + "external_calls": true, + "k8s_resources": true, + "images": true, + "kustomize": true, + "dbt_models": true, + "role": true, + "constructors_missing_fields": true, + "impact": true, + "bottlenecks": true, + "connectivity_health": true, + // Tier-2 — bypass the accessors but filtered via analyzeNodeVisible. + "dead_code": true, + "hotspots": true, + "cycles": true, + // releases reads s.graph directly (NOT via the scoped-node accessors); + // per-row filtered via analyzeNodeVisible like the three flagship kinds. + "releases": true, + // Category (a) — per-row visibility filtered (workspace + repo allow-set). + "channel_ops": true, + "goroutine_spawns": true, + "field_writers": true, + "indirect_mutations": true, + "config_readers": true, + "env_var_users": true, + "event_emitters": true, + "pubsub": true, + "error_surface": true, + "speculative": true, + "ref_facts": true, + "annotation_users": true, + "race_writes": true, + "unclosed_channels": true, + "log_events": true, + "sql_call_sites": true, + "string_emitters": true, + "routes": true, + "route_frameworks": true, + "swiftui_views": true, + "uikit_classes": true, + "drupal_hooks": true, + "models": true, + "components": true, + "pagerank": true, + "louvain": true, + "kcore": true, + "wcc": true, + "scc": true, + "edge_audit": true, + "tests_as_edges": true, + "doc_staleness": true, + "temporal_orphans": true, + // Category (c) — file/AST scans, already narrowed via resolveRepoFilter/buildASTTargets. + "sast": true, + "hygiene": true, + "review": true, + "domain": true, + "named": true, + "unsafe_patterns": true, +} + // AnalyzeKinds returns a defensive copy of the canonical analyze-kind // set, in sorted order. Callers must not mutate the returned slice's // backing array of the package-level source. diff --git a/internal/mcp/analyze_scope_test.go b/internal/mcp/analyze_scope_test.go new file mode 100644 index 00000000..a61ea78e --- /dev/null +++ b/internal/mcp/analyze_scope_test.go @@ -0,0 +1,722 @@ +package mcp + +// Tests for the uniform repo/project/workspace/scope narrowing added to +// the `analyze` MCP tool. They mirror the fixtures and assertion shapes +// of scope_resolve_test.go, tools_search_text_test.go and +// workspace_isolation_test.go. +// +// Substring checks on the response text ("repo-a" / "repo-b") are a +// format-agnostic leak probe: multi-repo node IDs and file paths are +// prefixed with the configured repo name, so a result narrowed to +// repo-a must never contain the string "repo-b" (and vice versa). The +// fixtures use repo dir / config names "repo-a" and "repo-b" exactly so +// this probe works across every analyze kind regardless of its row +// schema or wire format. + +import ( + "context" + "encoding/json" + "fmt" + "os" + "path/filepath" + "sort" + "testing" + + mcplib "github.com/mark3labs/mcp-go/mcp" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" + "go.uber.org/zap" + + "github.com/zzet/gortex/internal/config" + "github.com/zzet/gortex/internal/graph" + "github.com/zzet/gortex/internal/indexer" + "github.com/zzet/gortex/internal/parser" + "github.com/zzet/gortex/internal/parser/languages" + "github.com/zzet/gortex/internal/query" + "github.com/zzet/gortex/internal/search" +) + +// ─── fixtures ─────────────────────────────────────────────────────────────── + +// analyzeRepoSpec declares one repo for newAnalyzeServer: its config +// name, workspace + project slugs (project omitted from the .gortex.yaml +// when ""), and the main.go body. +type analyzeRepoSpec struct { + name string + workspace string + project string + body string +} + +// newAnalyzeServer indexes the given repos into one multi-repo graph and +// returns a *Server plus a name→absolute-path map (the path doubles as +// the session CWD anchor). It mirrors newSharedWorkspaceServer's wiring +// but lets each test shape the repos/bodies it needs — the shared +// fixtures only carry exported leaf functions, which the structural +// kinds (dead_code/hotspots/cycles) deliberately exclude. +func newAnalyzeServer(t *testing.T, flagOn bool, repos ...analyzeRepoSpec) (*Server, map[string]string) { + t.Helper() + + paths := map[string]string{} + entries := make([]config.RepoEntry, 0, len(repos)) + for _, r := range repos { + dir := filepath.Join(t.TempDir(), r.name) + require.NoError(t, os.MkdirAll(dir, 0o755)) + yaml := "workspace: " + r.workspace + "\n" + if r.project != "" { + yaml += "project: " + r.project + "\n" + } + require.NoError(t, os.WriteFile(filepath.Join(dir, ".gortex.yaml"), []byte(yaml), 0o644)) + require.NoError(t, os.WriteFile(filepath.Join(dir, "main.go"), []byte(r.body), 0o644)) + paths[r.name] = dir + entries = append(entries, config.RepoEntry{Path: dir, Name: r.name, Project: r.project}) + } + + tmpCfg := filepath.Join(t.TempDir(), "config.yaml") + gc := &config.GlobalConfig{Repos: entries} + gc.SetConfigPath(tmpCfg) + require.NoError(t, gc.Save()) + + cm, err := config.NewConfigManager(tmpCfg) + require.NoError(t, err) + + g := graph.New() + reg := parser.NewRegistry() + languages.RegisterAll(reg) + bm := search.NewBM25() + mi := indexer.NewMultiIndexer(g, reg, bm, cm, zap.NewNop()) + _, err = mi.IndexScoped("", "") + require.NoError(t, err) + + eng := query.NewEngine(g) + eng.SetSearch(bm) + + flagVal := flagOn + srv := NewServer(eng, g, nil, nil, zap.NewNop(), nil, MultiRepoOptions{ + MultiIndexer: mi, + ConfigManager: cm, + ScopeIntentDefaults: &flagVal, + }) + return srv, paths +} + +// structuralBody returns a package-main body whose functions exercise the +// structural analyze kinds:

Hub gains fan-in (hotspot candidate), +//

One/

Two/

Three/

Dead are unreferenced + unexported (dead +// code), and

MutualX/

MutualY form a call cycle. p is a per-repo +// prefix so symbol names never collide across repos. +func structuralBody(p string) string { + return fmt.Sprintf(`package main + +func %[1]sHub() {} +func %[1]sOne() { %[1]sHub() } +func %[1]sTwo() { %[1]sHub() } +func %[1]sThree() { %[1]sHub() } +func %[1]sDead() {} +func %[1]sMutualX() { %[1]sMutualY() } +func %[1]sMutualY() { %[1]sMutualX() } +`, p) +} + +// newStructuralWorkspaceServer builds a two-repo single-workspace +// ("shared-ws", project "backend") server whose repos carry the +// structural body — so dead_code / hotspots / cycles actually emit rows. +func newStructuralWorkspaceServer(t *testing.T, flagOn bool) (*Server, map[string]string) { + t.Helper() + return newAnalyzeServer(t, flagOn, + analyzeRepoSpec{name: "repo-a", workspace: "shared-ws", project: "backend", body: structuralBody("a")}, + analyzeRepoSpec{name: "repo-b", workspace: "shared-ws", project: "backend", body: structuralBody("b")}, + ) +} + +// ─── handler helper ────────────────────────────────────────────────────────── + +// runAnalyze drives handleAnalyze and returns the (non-error) result +// text, the scope_applied meta value, and the raw result. +func runAnalyze(t *testing.T, srv *Server, ctx context.Context, args map[string]any) (string, string, *mcplib.CallToolResult) { + t.Helper() + res, err := srv.handleAnalyze(ctx, makeReq("analyze", args)) + require.NoError(t, err) + require.NotNil(t, res) + require.False(t, res.IsError, "analyze %v errored: %s", args, toolResultText(res)) + require.NotNil(t, res.Meta, "every analyze response must be decorated with scope meta") + applied, _ := res.Meta.AdditionalFields["scope_applied"].(string) + return toolResultText(res), applied, res +} + +// ─── 1. AUTO narrowing (health_score routes through scopedNodesByKinds) ────── + +func TestAnalyzeScope_HealthScore_RepoNarrows(t *testing.T) { + fx := newSharedWorkspaceServer(t, true) + ctx := sessionCtx("s-a", fx.repoA) + + // No narrowing arg → whole (bound) workspace: both repos visible. + text, applied, _ := runAnalyze(t, fx.srv, ctx, map[string]any{"kind": "health_score"}) + assert.Contains(t, text, "repo-a", "unnarrowed health_score must include repo-a") + assert.Contains(t, text, "repo-b", "unnarrowed health_score must include repo-b (whole workspace)") + assert.Equal(t, "workspace", applied) + + // repo=repo-a → rows only from repo-a. + text, applied, _ = runAnalyze(t, fx.srv, ctx, map[string]any{"kind": "health_score", "repo": "repo-a"}) + assert.Contains(t, text, "repo-a", "narrowed health_score must include repo-a rows") + assert.NotContains(t, text, "repo-b", "narrowed health_score must not leak repo-b rows") + assert.Equal(t, "repo:repo-a", applied) +} + +func TestAnalyzeScope_HealthScore_ProjectNarrows(t *testing.T) { + // repo-a → project frontend, repo-b → project backend, both in one + // workspace, so project=backend genuinely narrows to repo-b. + fx := newSplitProjectWorkspaceServer(t, true) + ctx := sessionCtx("s-a", fx.repoA) + + text, applied, _ := runAnalyze(t, fx.srv, ctx, map[string]any{"kind": "health_score", "project": "backend"}) + assert.Contains(t, text, "repo-b", "project=backend must include the backend repo (repo-b)") + assert.NotContains(t, text, "repo-a", "project=backend must exclude the frontend repo (repo-a)") + assert.Equal(t, "project:backend", applied) +} + +// ─── 2. Tier-2 narrowing (dead_code/hotspots/cycles via analyzeNodeVisible) ── + +func TestAnalyzeScope_DeadCode_RepoNarrows(t *testing.T) { + srv, paths := newStructuralWorkspaceServer(t, true) + ctx := sessionCtx("s-a", paths["repo-a"]) + + // Whole workspace: dead functions from both repos. + text, applied, _ := runAnalyze(t, srv, ctx, map[string]any{"kind": "dead_code"}) + assert.Contains(t, text, "repo-a", "unnarrowed dead_code must include repo-a dead funcs") + assert.Contains(t, text, "repo-b", "unnarrowed dead_code must include repo-b dead funcs") + assert.Equal(t, "workspace", applied) + + // repo=repo-a → only repo-a dead funcs, no repo-b leak. + text, applied, _ = runAnalyze(t, srv, ctx, map[string]any{"kind": "dead_code", "repo": "repo-a"}) + assert.Contains(t, text, "repo-a", "narrowed dead_code must include repo-a rows") + assert.NotContains(t, text, "repo-b", "narrowed dead_code must not leak repo-b rows") + assert.Equal(t, "repo:repo-a", applied) +} + +func TestAnalyzeScope_Hotspots_Smoke(t *testing.T) { + srv, paths := newStructuralWorkspaceServer(t, true) + ctx := sessionCtx("s-a", paths["repo-a"]) + + // Unnarrowed: must not error (graph has >=10 nodes). + r, err := srv.handleAnalyze(ctx, makeReq("analyze", map[string]any{"kind": "hotspots"})) + require.NoError(t, err) + require.False(t, r.IsError, "unnarrowed hotspots errored: %s", toolResultText(r)) + + // repo=repo-a: no error, no repo-b leak, scope stamped. + text, applied, _ := runAnalyze(t, srv, ctx, map[string]any{"kind": "hotspots", "repo": "repo-a"}) + assert.NotContains(t, text, "repo-b", "narrowed hotspots must not leak repo-b rows") + assert.Equal(t, "repo:repo-a", applied) +} + +func TestAnalyzeScope_Cycles_Smoke(t *testing.T) { + srv, paths := newStructuralWorkspaceServer(t, true) + ctx := sessionCtx("s-a", paths["repo-a"]) + + text, applied, _ := runAnalyze(t, srv, ctx, map[string]any{"kind": "cycles", "repo": "repo-a"}) + assert.NotContains(t, text, "repo-b", "narrowed cycles must not leak repo-b cycle members") + assert.Equal(t, "repo:repo-a", applied) +} + +// ─── 7. cycles `scope` collision regression (§D resolve-view strip) ────────── + +func TestAnalyzeScope_Cycles_ScopeArgDoesNotCollide(t *testing.T) { + fx := newSharedWorkspaceServer(t, true) + ctx := sessionCtx("s-a", fx.repoA) + + // cycles owns `scope` as a path/pkg prefix. resolveScope must NOT try + // to look it up as a saved scope (which would hard-error + // "unknown scope"). The handler still reads its own scope arg. + r, err := fx.srv.handleAnalyze(ctx, makeReq("analyze", map[string]any{ + "kind": "cycles", "scope": "internal/auth/", + })) + require.NoError(t, err) + require.False(t, r.IsError, "cycles+scope must not error: %s", toolResultText(r)) + assert.NotContains(t, toolResultText(r), "unknown scope", + "the path-prefix scope arg must not be mis-read as a saved-scope name") + require.NotNil(t, r.Meta) + assert.Equal(t, "workspace", r.Meta.AdditionalFields["scope_applied"]) + + // A repo arg alongside the path-prefix scope still narrows. + r2, err := fx.srv.handleAnalyze(ctx, makeReq("analyze", map[string]any{ + "kind": "cycles", "scope": "internal/auth/", "repo": "repo-a", + })) + require.NoError(t, err) + require.False(t, r2.IsError) + require.NotNil(t, r2.Meta) + assert.Equal(t, "repo:repo-a", r2.Meta.AdditionalFields["scope_applied"]) +} + +// TestAnalyzeScope_Images_RefArgDoesNotCollide is a regression for the +// `ref` collision: analyze kind=images owns `ref` as the image reference +// filter (e.g. "ghcr.io/acme"). resolveScope must NOT mis-read it as a +// git ref / scope dimension — left unstripped it errors with +// "configuration manager is not available" on a server without a config +// manager. Mirrors the cycles/`scope` and cross_repo/`repo` strips (§D). +func TestAnalyzeScope_Images_RefArgDoesNotCollide(t *testing.T) { + srv, _ := setupTestServer(t) // single-repo server, no MultiIndexer scope + + r, err := srv.handleAnalyze(context.Background(), makeReq("analyze", map[string]any{ + "kind": "images", "ref": "ghcr.io/acme", + })) + require.NoError(t, err) + require.False(t, r.IsError, "images+ref must not error: %s", toolResultText(r)) + assert.NotContains(t, toolResultText(r), "configuration manager is not available", + "the image-ref arg must not be mis-read as a git ref / saved scope") +} + +// ─── 3. Workspace isolation preserved (SECURITY INVARIANT, must-pass) ──────── + +func TestAnalyzeScope_WorkspaceIsolation_HealthScore(t *testing.T) { + srv, repoA, _ := newIsolationServer(t) // repo-a→alpha, repo-b→beta + ctxA := sessionCtx("s-alpha", repoA) + + // Without any narrowing arg, a bound-alpha session must never surface + // beta nodes. + text, _, _ := runAnalyze(t, srv, ctxA, map[string]any{"kind": "health_score"}) + assert.Contains(t, text, "repo-a", "alpha session must see its own repo") + assert.NotContains(t, text, "repo-b", "alpha session must NOT see beta nodes via health_score") + assert.NotContains(t, text, "BetaThing", "alpha session must NOT see the beta symbol") + + // With an in-workspace narrowing arg, still no beta. + text, _, _ = runAnalyze(t, srv, ctxA, map[string]any{"kind": "health_score", "repo": "repo-a"}) + assert.NotContains(t, text, "repo-b", "alpha+repo arg must NOT leak beta nodes") +} + +func TestAnalyzeScope_WorkspaceIsolation_DeadCode(t *testing.T) { + // Structural repos in two distinct workspaces so dead_code emits rows + // AND the cross-workspace boundary is exercised. + srv, paths := newAnalyzeServer(t, true, + analyzeRepoSpec{name: "repo-a", workspace: "alpha", body: structuralBody("a")}, + analyzeRepoSpec{name: "repo-b", workspace: "beta", body: structuralBody("b")}, + ) + ctxA := sessionCtx("s-alpha", paths["repo-a"]) + + text, _, _ := runAnalyze(t, srv, ctxA, map[string]any{"kind": "dead_code"}) + assert.Contains(t, text, "repo-a", "alpha session must see its own dead funcs") + assert.NotContains(t, text, "repo-b", "alpha session must NOT see beta dead funcs via dead_code") + + text, _, _ = runAnalyze(t, srv, ctxA, map[string]any{"kind": "dead_code", "repo": "repo-a"}) + assert.NotContains(t, text, "repo-b", "alpha+repo arg must NOT leak beta dead funcs") +} + +// ─── 5. scope_note disclosure for non-scope-aware (bypass) kinds ───────────── + +func TestAnalyzeScope_ScopeNote_BypassKind(t *testing.T) { + // Membership gate is the source of truth. + require.True(t, analyzeScopeAwareKinds["health_score"], "health_score must be scope-aware") + require.False(t, analyzeScopeAwareKinds["clusters"], "clusters must NOT be scope-aware (community-detection bypass)") + + fx := newSharedWorkspaceServer(t, true) + ctx := sessionCtx("s-a", fx.repoA) + + // Bypass kind + narrowing arg → scope_note discloses the no-op. + r, err := fx.srv.handleAnalyze(ctx, makeReq("analyze", map[string]any{"kind": "clusters", "repo": "repo-a"})) + require.NoError(t, err) + require.False(t, r.IsError, "pubsub errored: %s", toolResultText(r)) + require.NotNil(t, r.Meta) + note, _ := r.Meta.AdditionalFields["scope_note"].(string) + assert.NotEmpty(t, note, "a bypass kind asked to narrow must carry a scope_note") + assert.Equal(t, "repo:repo-a", r.Meta.AdditionalFields["scope_applied"], + "scope_applied stays uniform/truthful about the resolved scope") + + // AUTO kind + narrowing arg → no scope_note (the narrowing is real). + r2, err := fx.srv.handleAnalyze(ctx, makeReq("analyze", map[string]any{"kind": "health_score", "repo": "repo-a"})) + require.NoError(t, err) + require.NotNil(t, r2.Meta) + _, hasNote := r2.Meta.AdditionalFields["scope_note"] + assert.False(t, hasNote, "an AUTO kind that genuinely narrows must NOT carry a scope_note") +} + +// ─── 6. Flag OFF — workspace-scoped default preserved, args still narrow ───── + +func TestAnalyzeScope_FlagOff(t *testing.T) { + fx := newSharedWorkspaceServer(t, false) // intent defaults OFF + ctx := sessionCtx("s-a", fx.repoA) + + // Default (no arg): still workspace breadth — both repos — no + // regression to a home-repo narrowing. + text, _, _ := runAnalyze(t, fx.srv, ctx, map[string]any{"kind": "health_score"}) + assert.Contains(t, text, "repo-a", "flag-off default must keep repo-a") + assert.Contains(t, text, "repo-b", "flag-off default must keep repo-b (workspace breadth)") + + // New args still narrow on demand. + text, applied, _ := runAnalyze(t, fx.srv, ctx, map[string]any{"kind": "health_score", "repo": "repo-a"}) + assert.Contains(t, text, "repo-a") + assert.NotContains(t, text, "repo-b", "flag-off + repo arg must still narrow") + assert.Equal(t, "repo:repo-a", applied) +} + +// ─── 8. Byte-for-byte unbound — no narrowing, full graph ───────────────────── + +func TestAnalyzeScope_Unbound_FullSet(t *testing.T) { + fx := newSharedWorkspaceServer(t, true) + bg := context.Background() // unbound: no session cwd + + // scopedNodes returns the whole graph unchanged for an unbound, + // un-narrowed session. + all := fx.srv.graph.AllNodes() + got := fx.srv.scopedNodes(bg) + assert.Equal(t, len(all), len(got), + "unbound scopedNodes must return every node (byte-for-byte legacy behaviour)") + + // analyze with no narrowing arg likewise spans the full graph. + text, _, _ := runAnalyze(t, fx.srv, bg, map[string]any{"kind": "health_score"}) + assert.Contains(t, text, "repo-a") + assert.Contains(t, text, "repo-b") +} + +// ─── ctx-layer: withRepoAllow folds into the scoped-node accessors ─────────── + +func TestAnalyzeScope_RepoAllowCtx_NarrowsScopedNodes(t *testing.T) { + fx := newSharedWorkspaceServer(t, true) + ctxA := sessionCtx("s-a", fx.repoA) + ctxAllow := withRepoAllow(ctxA, map[string]bool{"repo-a": true}) + + // Plain bound ctx sees the whole workspace (both repos) — the hard + // boundary is workspace-shaped, not repo-shaped. + plain := fx.srv.scopedNodes(ctxA) + hasA, hasB := false, false + for _, n := range plain { + switch n.RepoPrefix { + case "repo-a": + hasA = true + case "repo-b": + hasB = true + } + } + require.True(t, hasA && hasB, "bound ctx must see both repos before RepoAllow narrows") + + // withRepoAllow narrows scopedNodes to repo-a only. + narrowed := fx.srv.scopedNodes(ctxAllow) + require.NotEmpty(t, narrowed) + for _, n := range narrowed { + assert.Equal(t, "repo-a", n.RepoPrefix, + "withRepoAllow must drop every non-repo-a node, leaked: %s", n.ID) + } + + // analyzeNodeVisible mirrors the same gate per-node. + var repoBNode *graph.Node + for _, n := range plain { + if n.RepoPrefix == "repo-b" { + repoBNode = n + break + } + } + require.NotNil(t, repoBNode) + assert.True(t, fx.srv.analyzeNodeVisible(ctxA, repoBNode), + "without RepoAllow a repo-b node is visible inside the workspace") + assert.False(t, fx.srv.analyzeNodeVisible(ctxAllow, repoBNode), + "with RepoAllow{repo-a} a repo-b node must be filtered out") +} + +// ─── 9. Per-gate-shape narrowing for the category-(a)/(c) kinds ────────────── +// +// Wave 1 made the category-(a) edge-walk / graph-algorithm / framework +// kinds and the category-(c) file/AST scans scope-aware via the per-row +// analyzeNodeVisible gate (or, for the (c) scans, resolveRepoFilter). +// The cases below pin ONE representative kind per gate shape; each one +// is verified non-vacuous (BOTH repos present in the broad result) +// before the narrowing assertion runs, so a kind that silently stopped +// emitting rows fails loudly instead of passing on an empty set. + +// richAnalyzeBody returns a package-main body that exercises a broad set +// of category-(a) analyze kinds plus the category-(c) AST scan in one +// fixture: +// -

Box.Set /

Box.Bump write the

Box.Val field → field_writers +// (SubjectActor: subject=field, actors=writers). +// - the

Hub fan-in and the

MutualX/

MutualY cycle populate the +// call / reference graph → ref_facts (TwoPeer), wcc/scc (MemberList), +// pagerank/kcore (SingleNode), edge_audit (ReTally). +// -

Boom's panic() call trips the panic-in-library detector → +// unsafe_patterns (category-c file/AST scan). +// +// p is a per-repo prefix so symbol names never collide across repos and +// the "repo-a" / "repo-b" leak probe stays unambiguous. It is a superset +// of structuralBody (adds the field writes + the panic site) so the +// existing structural tests are untouched. +func richAnalyzeBody(p string) string { + return fmt.Sprintf(`package main + +type %[1]sBox struct{ Val int } + +func (b *%[1]sBox) Set() { b.Val = 1 } +func (b *%[1]sBox) Bump() { b.Val = b.Val + 1 } + +func %[1]sHub() {} +func %[1]sOne() { %[1]sHub() } +func %[1]sTwo() { %[1]sHub() } +func %[1]sBoom() { panic("boom") } +func %[1]sMutualX() { %[1]sMutualY() } +func %[1]sMutualY() { %[1]sMutualX() } +`, p) +} + +// newRichWorkspaceServer builds a two-repo single-workspace ("shared-ws", +// project "backend") server whose repos carry richAnalyzeBody — so every +// gate-shape kind below emits rows in BOTH repos. +func newRichWorkspaceServer(t *testing.T, flagOn bool) (*Server, map[string]string) { + t.Helper() + return newAnalyzeServer(t, flagOn, + analyzeRepoSpec{name: "repo-a", workspace: "shared-ws", project: "backend", body: richAnalyzeBody("a")}, + analyzeRepoSpec{name: "repo-b", workspace: "shared-ws", project: "backend", body: richAnalyzeBody("b")}, + ) +} + +// TestAnalyzeScope_GateShapes_RepoNarrows pins the per-row-filtered gate +// shapes that emit node-ID rows (SubjectActor / TwoPeer / MemberList / +// SingleNode) plus the category-(c) AST scan. For each: the broad +// (whole-workspace) result must carry BOTH repos (non-vacuous guard), +// then repo=repo-a must (i) keep repo-a rows, (ii) drop every repo-b +// row, (iii) stamp scope_applied truthfully, and (iv) carry NO +// scope_note (the kind is in analyzeScopeAwareKinds — the narrowing is +// real, not a documented no-op). +func TestAnalyzeScope_GateShapes_RepoNarrows(t *testing.T) { + cases := []struct { + kind string + shape string + }{ + {"field_writers", "SubjectActor (pubsub vacuous on a non-pubsub fixture)"}, + {"ref_facts", "TwoPeer (routes vacuous on a non-routing fixture)"}, + {"wcc", "MemberList"}, + {"pagerank", "SingleNode"}, + {"unsafe_patterns", "category-c file/AST scan"}, + } + for _, tc := range cases { + t.Run(tc.kind, func(t *testing.T) { + require.True(t, analyzeScopeAwareKinds[tc.kind], + "%s (%s) must be registered scope-aware", tc.kind, tc.shape) + + srv, paths := newRichWorkspaceServer(t, true) + ctx := sessionCtx("s-a", paths["repo-a"]) + + // Broad: whole bound workspace → BOTH repos present. + // This is the non-vacuous guard — without it a kind that + // emits zero rows would pass the narrowing assertion for + // the wrong reason. + broad, applied, _ := runAnalyze(t, srv, ctx, map[string]any{"kind": tc.kind}) + require.Contains(t, broad, "repo-a", "%s broad must include repo-a rows", tc.kind) + require.Contains(t, broad, "repo-b", + "%s broad must include repo-b rows — else the narrowing assertion is vacuous", tc.kind) + assert.Equal(t, "workspace", applied) + + // repo=repo-a → only repo-a rows, no repo-b leak. + narrow, applied, r := runAnalyze(t, srv, ctx, map[string]any{"kind": tc.kind, "repo": "repo-a"}) + assert.Contains(t, narrow, "repo-a", "%s narrowed must keep repo-a rows", tc.kind) + assert.NotContains(t, narrow, "repo-b", "%s narrowed must not leak repo-b rows", tc.kind) + assert.Equal(t, "repo:repo-a", applied) + + // A genuinely-narrowing scope-aware kind carries no scope_note. + _, hasNote := r.Meta.AdditionalFields["scope_note"] + assert.False(t, hasNote, "%s genuinely narrows — must NOT carry a scope_note", tc.kind) + }) + } +} + +// TestAnalyzeScope_EdgeAudit_ReTallyNarrows covers the ReTally gate +// shape. The plan's preferred ReTally kind (temporal_orphans) emits only +// empty lists on a non-Temporal fixture, so edge_audit — emitted by ANY +// edges — is the same-shape fallback. edge_audit returns no node-ID rows, +// only re-tallied counts, so the leak probe is replaced by a count +// assertion: narrowing to one repo must drop summary.total_edges below +// the whole-workspace tally while staying > 0. scope_applied is stamped +// and (the kind being scope-aware) no scope_note is attached. +func TestAnalyzeScope_EdgeAudit_ReTallyNarrows(t *testing.T) { + require.True(t, analyzeScopeAwareKinds["edge_audit"], "edge_audit must be scope-aware") + + srv, paths := newRichWorkspaceServer(t, true) + ctx := sessionCtx("s-a", paths["repo-a"]) + + broadText, applied, _ := runAnalyze(t, srv, ctx, map[string]any{"kind": "edge_audit"}) + assert.Equal(t, "workspace", applied) + broadEdges := edgeAuditTotalEdges(t, broadText) + require.Greater(t, broadEdges, 0, "whole-workspace edge_audit must tally >0 edges (non-vacuous)") + + narrowText, applied, r := runAnalyze(t, srv, ctx, map[string]any{"kind": "edge_audit", "repo": "repo-a"}) + assert.Equal(t, "repo:repo-a", applied) + narrowEdges := edgeAuditTotalEdges(t, narrowText) + require.Greater(t, narrowEdges, 0, "repo-a edge_audit must still tally its own edges") + assert.Less(t, narrowEdges, broadEdges, + "narrowing edge_audit to repo-a must re-tally below the whole-workspace edge count") + + _, hasNote := r.Meta.AdditionalFields["scope_note"] + assert.False(t, hasNote, "edge_audit genuinely re-tallies under scope — must NOT carry a scope_note") +} + +// edgeAuditTotalEdges pulls summary.total_edges out of an edge_audit JSON +// response. +func edgeAuditTotalEdges(t *testing.T, text string) int { + t.Helper() + var payload struct { + Summary struct { + TotalEdges int `json:"total_edges"` + } `json:"summary"` + } + require.NoError(t, json.Unmarshal([]byte(text), &payload), "edge_audit response must be JSON: %s", text) + return payload.Summary.TotalEdges +} + +// ─── 10. Workspace isolation for a category-(a) and a category-(c) kind ────── +// +// Mirrors TestAnalyzeScope_WorkspaceIsolation_DeadCode (§11 security +// invariant) but for a category-(a) per-row kind (pagerank, SingleNode) +// and a category-(c) AST scan (unsafe_patterns). The two repos live in +// DISTINCT workspaces (alpha / beta) and both carry richAnalyzeBody so +// each kind emits rows in BOTH repos; a session bound to the alpha repo +// must NEVER surface beta nodes — with or without a narrowing arg. +func TestAnalyzeScope_WorkspaceIsolation_GateShapes(t *testing.T) { + for _, kind := range []string{"pagerank", "unsafe_patterns"} { + t.Run(kind, func(t *testing.T) { + srv, paths := newAnalyzeServer(t, true, + analyzeRepoSpec{name: "repo-a", workspace: "alpha", body: richAnalyzeBody("a")}, + analyzeRepoSpec{name: "repo-b", workspace: "beta", body: richAnalyzeBody("b")}, + ) + ctxA := sessionCtx("s-alpha", paths["repo-a"]) + + // No narrowing arg: a bound-alpha session sees only its own + // repo — never the beta repo across the workspace boundary. + text, _, _ := runAnalyze(t, srv, ctxA, map[string]any{"kind": kind}) + assert.Contains(t, text, "repo-a", "%s: alpha session must see its own rows", kind) + assert.NotContains(t, text, "repo-b", + "%s: alpha session must NOT see beta nodes (workspace isolation)", kind) + + // In-workspace narrowing arg: still no beta leak. + text, _, _ = runAnalyze(t, srv, ctxA, map[string]any{"kind": kind, "repo": "repo-a"}) + assert.NotContains(t, text, "repo-b", "%s: alpha+repo arg must NOT leak beta nodes", kind) + }) + } +} + +// ─── 11. Full analyzeScopeAwareKinds sweep — cross-workspace leak guard ─────── +// +// REGRESSION GUARD. Until now no test iterated the whole +// analyzeScopeAwareKinds set, which is exactly how `releases` shipped +// mislabeled as scope-aware while leaking release nodes across the +// workspace boundary. This table binds a session to workspace alpha and +// asserts that EVERY scope-aware kind keeps repo-b (workspace beta) rows +// out of the response — so a future kind added to the set without real +// narrowing fails here loudly instead of silently leaking. +// +// `releases` is the motivating case: it reads s.graph directly and only +// became safe once the per-row analyzeNodeVisible gate landed in +// handleAnalyzeReleases. KindRelease nodes are normally materialised by +// the git-tag enricher, so the test injects one per repo (attributed +// exactly like the repo's real nodes) — without that, `releases` would +// emit an empty timeline and pass vacuously, hiding the very regression +// this test exists to catch. Revert the analyzeNodeVisible gate in +// handleAnalyzeReleases and the `releases` sub-test fails: the beta +// release node leaks into the alpha-bound session. +func TestAnalyzeScope_AllScopeAwareKinds_NoCrossWorkspaceLeak(t *testing.T) { + // Two repos in DISTINCT workspaces so the workspace boundary is + // exercised; leakProbeBody emits rows for the structural / edge-walk / + // field-write / panic / TODO kinds in BOTH repos. + srv, paths := newAnalyzeServer(t, true, + analyzeRepoSpec{name: "repo-a", workspace: "alpha", body: leakProbeBody("a")}, + analyzeRepoSpec{name: "repo-b", workspace: "beta", body: leakProbeBody("b")}, + ) + + // Make `releases` a non-vacuous probe: one KindRelease node per repo, + // scoped exactly like the repo's genuine nodes. + injectReleaseNode(t, srv, "repo-a") + injectReleaseNode(t, srv, "repo-b") + + ctxA := sessionCtx("s-alpha", paths["repo-a"]) + + // Sanity: the injected repo-b release leaks into an UNBOUND session + // (no workspace ceiling), proving the probe can actually surface + // "repo-b" — so the bound-session assertion below is meaningful and + // not vacuously green. + unboundReleases, _, _ := runAnalyze(t, srv, context.Background(), map[string]any{"kind": "releases"}) + require.Contains(t, unboundReleases, "repo-b", + "unbound releases must see the injected repo-b release node — else the leak probe is vacuous") + + // Kinds whose rows only exist after an external enrichment pass we + // cannot reproduce in-memory (git blame timestamps, a coverage + // profile). They emit empty here rather than leak, so exercising them + // would only assert a vacuous truth; skip + log instead. (The blame / + // coverage WRITER kinds are not in analyzeScopeAwareKinds at all, so + // they never reach this loop.) + skip := map[string]string{ + "stale_code": "needs git-blame meta.last_authored", + "ownership": "needs git-blame author data", + "stale_flags": "needs feature-flag toggles + git-blame timestamps", + "coverage_gaps": "needs a coverage profile", + "coverage_summary": "needs a coverage profile", + } + + kinds := make([]string, 0, len(analyzeScopeAwareKinds)) + for k := range analyzeScopeAwareKinds { + kinds = append(kinds, k) + } + sort.Strings(kinds) + + for _, kind := range kinds { + kind := kind + t.Run(kind, func(t *testing.T) { + if reason, ok := skip[kind]; ok { + t.Skipf("skipping %s — %s (cannot fixture in-memory; emits empty, never leaks)", kind, reason) + } + res, err := srv.handleAnalyze(ctxA, makeReq("analyze", map[string]any{"kind": kind})) + require.NoError(t, err) + require.NotNil(t, res) + // SECURITY INVARIANT: an alpha-bound session must NEVER surface + // a repo-b (workspace beta) row, with or without a narrowing + // arg. A leak shows up as the literal "repo-b" in a node ID / + // file path / repo_prefix field. + assert.NotContains(t, toolResultText(res), "repo-b", + "%s: alpha-bound session leaked a beta (repo-b) row — cross-workspace isolation breach", kind) + }) + } +} + +// leakProbeBody is a superset of richAnalyzeBody that also carries a TODO +// comment and an unreferenced func, so the workspace-leak sweep exercises +// the AUTO (todos), Tier-2 (dead_code), category-(a) (field_writers, +// ref_facts, pagerank, …) and category-(c) (unsafe_patterns) kinds with +// real rows in each repo. p prefixes every symbol so the "repo-b" leak +// probe stays unambiguous. +func leakProbeBody(p string) string { + return fmt.Sprintf(`package main + +// TODO(%[1]s): exercise the todos analyzer +type %[1]sBox struct{ Val int } + +func (b *%[1]sBox) Set() { b.Val = 1 } +func (b *%[1]sBox) Bump() { b.Val = b.Val + 1 } + +func %[1]sHub() {} +func %[1]sOne() { %[1]sHub() } +func %[1]sTwo() { %[1]sHub() } +func %[1]sBoom() { panic("boom") } +func %[1]sDead() {} +func %[1]sMutualX() { %[1]sMutualY() } +func %[1]sMutualY() { %[1]sMutualX() } +`, p) +} + +// injectReleaseNode adds one synthetic KindRelease node for the named +// repo, copying WorkspaceID / ProjectID / RepoPrefix from a real indexed +// node of that repo so nodeInSessionScope narrows it exactly like the +// repo's genuine nodes. Release nodes are normally materialised only by +// the git-tag enricher, which the in-memory test graph has no way to run. +func injectReleaseNode(t *testing.T, srv *Server, repo string) { + t.Helper() + var tmpl *graph.Node + for _, n := range srv.graph.AllNodes() { + if n.RepoPrefix == repo { + tmpl = n + break + } + } + require.NotNil(t, tmpl, "no indexed node found for repo %q to anchor a release node", repo) + const tag = "v1.0.0" + srv.graph.AddNode(&graph.Node{ + ID: "release::" + repo + "::" + tag, + Kind: graph.KindRelease, + Name: tag, + RepoPrefix: tmpl.RepoPrefix, + WorkspaceID: tmpl.WorkspaceID, + ProjectID: tmpl.ProjectID, + Meta: map[string]any{"tag": tag, "file_count": 1, "order": 0}, + }) +} diff --git a/internal/mcp/field_query.go b/internal/mcp/field_query.go index 3f65274b..ebfc9d45 100644 --- a/internal/mcp/field_query.go +++ b/internal/mcp/field_query.go @@ -26,7 +26,8 @@ type fieldQuery struct { // / path / repo) was supplied. project: is excluded — it merges into // the query scope rather than acting as a post-filter. func (fq fieldQuery) hasFieldFilters() bool { - return fq.Kind != "" || fq.Flavor != "" || fq.Lang != "" || fq.Path != "" || fq.Repo != "" || fq.Name != "" + repo := strings.TrimSpace(fq.Repo) + return fq.Kind != "" || fq.Flavor != "" || fq.Lang != "" || fq.Path != "" || (repo != "" && repo != "*") || fq.Name != "" } // parseFieldQuery splits a raw search string into its free text and @@ -69,6 +70,37 @@ func parseFieldQuery(raw string) fieldQuery { return fq } +// requestWithInlineScopeClauses returns a request whose repo/project args +// include inline field-query scope clauses when the corresponding explicit +// request arg is absent. The original request is left untouched. +func requestWithInlineScopeClauses(req mcp.CallToolRequest, fq fieldQuery) mcp.CallToolRequest { + inlineRepo := strings.TrimSpace(fq.Repo) + inlineProject := strings.TrimSpace(fq.Project) + if inlineRepo == "" && inlineProject == "" { + return req + } + + repoArg := strings.TrimSpace(req.GetString("repo", "")) + projectArg := strings.TrimSpace(req.GetString("project", "")) + if (repoArg != "" || inlineRepo == "") && (projectArg != "" || inlineProject == "") { + return req + } + + merged := req + args := make(map[string]any, len(req.GetArguments())+2) + for k, v := range req.GetArguments() { + args[k] = v + } + if repoArg == "" && inlineRepo != "" { + args["repo"] = inlineRepo + } + if projectArg == "" && inlineProject != "" { + args["project"] = inlineProject + } + merged.Params.Arguments = args + return merged +} + // normalizeLang folds the common short language aliases (ts, js, py, // …) onto the canonical language names the indexer stamps on nodes. // An unrecognised value is returned lowercased and trimmed. @@ -105,6 +137,9 @@ func applyFieldFilters(nodes []*graph.Node, fq fieldQuery) []*graph.Node { lang := normalizeLang(fq.Lang) path := strings.ToLower(strings.TrimSpace(fq.Path)) repo := strings.TrimSpace(fq.Repo) + if repo == "*" { + repo = "" + } name := strings.ToLower(strings.TrimSpace(fq.Name)) if lang == "" && path == "" && repo == "" && name == "" { return nodes diff --git a/internal/mcp/field_query_test.go b/internal/mcp/field_query_test.go index 4afd88e6..98b529f2 100644 --- a/internal/mcp/field_query_test.go +++ b/internal/mcp/field_query_test.go @@ -62,6 +62,9 @@ func TestFieldQueryHasFieldFilters(t *testing.T) { if (fieldQuery{Project: "web"}).hasFieldFilters() { t.Errorf("project: alone is scope, not a post-filter") } + if (fieldQuery{Repo: "*"}).hasFieldFilters() { + t.Errorf("repo:* is a scope sentinel, not a post-filter") + } for _, fq := range []fieldQuery{{Kind: "function"}, {Flavor: "struct"}, {Lang: "go"}, {Path: "src/"}, {Repo: "gortex"}} { if !fq.hasFieldFilters() { t.Errorf("%+v must report a field filter", fq) diff --git a/internal/mcp/scope_init.go b/internal/mcp/scope_init.go index b82d14a2..9b667564 100644 --- a/internal/mcp/scope_init.go +++ b/internal/mcp/scope_init.go @@ -25,6 +25,16 @@ package mcp // existing per-call `repo` parameter already produces a single // focused result and migration to fan-out is a UX change worth a // separate review. +// +// Note: `analyze` now accepts the uniform repo / project / workspace / +// scope overrides (resolved via resolveScope, applied as a RepoAllow in +// the scoped-node accessors and the analyzeNodeVisible Tier-2 filter), +// clamped to the session workspace. The narrowing reaches its +// graph-node kinds (dead_code, hotspots, cycles, health_score, todos, +// ownership, impact, …); edge-walk / file-scan / git-mining kinds stay +// workspace-bound but are not repo-narrowed in v1. The ScopeRepo wire- +// schema classification below is orthogonal to that narrowing and is +// unchanged. var defaultToolScopes = map[string]ToolScope{ // --- Workspace-shaped tools ---------------------------------- // "What can I ask about?" — bootstrap surface so an agent can @@ -127,6 +137,45 @@ var defaultToolScopes = map[string]ToolScope{ "get_cfg": ScopeRepo, } +var toolIntent = map[string]ToolIntent{ + // Locate: find definitions/files/text near the session's home repo by default. + "search_symbols": IntentLocate, + "search_text": IntentLocate, + "find_files": IntentLocate, + + // Reach: traversal/call-graph tools need the workspace to answer cross-repo use. + "find_usages": IntentReach, + "get_callers": IntentReach, + "get_call_chain": IntentReach, + "get_dependencies": IntentReach, + "get_dependents": IntentReach, + "find_implementations": IntentReach, + "find_overrides": IntentReach, + "get_class_hierarchy": IntentReach, + "get_cluster": IntentReach, + "walk_graph": IntentReach, + "context_closure": IntentReach, + "contracts": IntentReach, + + // Analyze: rollups stay at workspace breadth unless explicitly narrowed. + "analyze": IntentAnalyze, + "review": IntentAnalyze, + "sast": IntentAnalyze, + "hotspots": IntentAnalyze, + "dead_code": IntentAnalyze, + "cycles": IntentAnalyze, + "health_score": IntentAnalyze, + "impact": IntentAnalyze, + "connectivity_health": IntentAnalyze, +} + +func toolIntentForName(name string) ToolIntent { + if intent, ok := toolIntent[name]; ok { + return intent + } + return IntentAnalyze +} + // applyDefaultToolScopes registers the canonical scope for every // known tool on this Server. Called once during NewServer after the // register*Tools sweep so the registry mirrors the registered MCP diff --git a/internal/mcp/scope_resolve.go b/internal/mcp/scope_resolve.go new file mode 100644 index 00000000..c9c45e0f --- /dev/null +++ b/internal/mcp/scope_resolve.go @@ -0,0 +1,341 @@ +package mcp + +import ( + "context" + "fmt" + "strings" + + "github.com/mark3labs/mcp-go/mcp" + + "github.com/zzet/gortex/internal/query" +) + +type ResolvedScope struct { + WorkspaceID string + ProjectID string + RepoAllow map[string]bool + Applied string +} + +type ToolIntent string + +const ( + IntentLocate ToolIntent = "locate" + IntentReach ToolIntent = "reach" + IntentAnalyze ToolIntent = "analyze" +) + +func (s *Server) resolveScope(ctx context.Context, req mcp.CallToolRequest, intent ToolIntent) (ResolvedScope, *mcp.CallToolResult) { + scope, err := s.resolveScopeForRequest(ctx, req, intent) + if err != nil { + return ResolvedScope{}, mcp.NewToolResultError(err.Error()) + } + return scope, nil +} + +func (s *Server) resolveScopeForRequest(ctx context.Context, req mcp.CallToolRequest, intent ToolIntent) (ResolvedScope, error) { + intent = normalizeToolIntent(intent, req.Params.Name) + + repo := strings.TrimSpace(req.GetString("repo", "")) + // The selector may be a filesystem path (the CLI defaults to the + // caller's working directory) -- normalize to the tracked prefix so + // the filter matches what the workspace knows the repo as. + if repo != "*" { + if p := s.resolveRepoPrefix(repo); p != "" { + repo = p + } + } + project := strings.TrimSpace(req.GetString("project", "")) + ref := strings.TrimSpace(req.GetString("ref", "")) + workspaceArg := strings.TrimSpace(req.GetString("workspace", "")) + + scopeArg := strings.TrimSpace(req.GetString("scope", "")) + if gitDiffScopes[scopeArg] { + scopeArg = "" + } + explicitNarrowing := repo != "" || project != "" || ref != "" || workspaceArg != "" || scopeArg != "" + + var scopeRepos map[string]bool + if scopeArg != "" && repo == "" && project == "" && ref == "" { + sc, ok := s.lookupScope(scopeArg) + if !ok { + return ResolvedScope{}, fmt.Errorf("unknown scope %q — run list_scopes to see saved scopes, or create one with save_scope", scopeArg) + } + scopeRepos = s.scopeRepoSet(sc) + if len(scopeRepos) == 0 { + return ResolvedScope{}, fmt.Errorf("saved scope %q names no repositories", scopeArg) + } + } + + if sessWS, sessProj, bound := s.sessionScope(ctx); bound { + return s.resolveBoundSessionScope(ctx, sessWS, sessProj, workspaceArg, project, repo, ref, scopeArg, scopeRepos, intent, explicitNarrowing) + } + return s.resolveUnboundScope(ctx, workspaceArg, project, repo, ref, scopeArg, scopeRepos, intent, explicitNarrowing) +} + +func normalizeToolIntent(intent ToolIntent, toolName string) ToolIntent { + switch intent { + case IntentLocate, IntentReach, IntentAnalyze: + return intent + default: + return toolIntentForName(toolName) + } +} + +func (s *Server) resolveBoundSessionScope(ctx context.Context, sessWS, sessProj, workspaceArg, project, repo, ref, scopeArg string, scopeRepos map[string]bool, intent ToolIntent, explicitNarrowing bool) (ResolvedScope, error) { + resolved := ResolvedScope{ + WorkspaceID: sessWS, + ProjectID: sessProj, + Applied: appliedProjectOrWorkspace(sessProj), + } + if project != "" { + resolved.ProjectID = project + resolved.Applied = "project:" + project + } + + // A `workspace` arg may only name the session's own workspace. Any + // other value is a cross-workspace escape attempt -- reject it + // outright rather than silently honouring the boundary and + // returning a confusing empty result. + if workspaceArg != "" && workspaceArg != sessWS { + return ResolvedScope{}, fmt.Errorf( + "workspace %q is outside the active workspace %q; cross-workspace queries are not permitted", + workspaceArg, sessWS) + } + if workspaceArg != "" && project == "" && repo == "" && ref == "" && scopeRepos == nil { + resolved.ProjectID = "" + resolved.RepoAllow = nil + resolved.Applied = "workspace" + return resolved, nil + } + + wsRepos := map[string]bool{} + if s.multiIndexer != nil { + wsRepos = s.multiIndexer.ReposInWorkspace(sessWS) + } + + if !explicitNarrowing { + if s.scopeIntentDefaultsEnabled() { + return s.applyIntentDefault(ctx, resolved, intent), nil + } + // Layer-A compatibility: no explicit narrowing in a bound session + // stays on the session project, with the repo allow-set clamped to + // the whole workspace for handlers that filter by repo prefix. + resolved.RepoAllow = wsRepos + resolved.Applied = appliedProjectOrWorkspace(resolved.ProjectID) + return resolved, nil + } + + // A named scope, intersected with the workspace so it can only ever + // narrow -- a scope is a convenience, never a clamp escape. + if scopeRepos != nil { + intersected := intersectRepoSets(scopeRepos, wsRepos) + if len(intersected) == 0 { + return ResolvedScope{}, fmt.Errorf( + "saved scope %q resolves to nothing inside the active workspace %q", + scopeArg, sessWS) + } + resolved.ProjectID = "" + resolved.RepoAllow = intersected + resolved.Applied = "scope:" + scopeArg + return resolved, nil + } + + if repo == "*" && project == "" && ref == "" { + resolved.ProjectID = "" + resolved.RepoAllow = nil + resolved.Applied = "workspace" + return resolved, nil + } + if repo == "*" { + repo = "" + } + + // Explicit narrowing: resolve the args, then intersect with the + // workspace so a repo/project/ref arg can never escape it. + narrowed, err := s.resolveRepoFilterArgs(repo, project, ref, false) + if err != nil { + return ResolvedScope{}, err + } + if narrowed == nil { + resolved.ProjectID = project + resolved.RepoAllow = nil + resolved.Applied = appliedForExplicit(project, repo, ref, nil) + return resolved, nil + } + intersected := intersectRepoSets(narrowed, wsRepos) + if len(intersected) == 0 { + return ResolvedScope{}, fmt.Errorf( + "repo/project/ref filter resolves to nothing inside the active workspace %q; cross-workspace queries are not permitted", + sessWS) + } + resolved.ProjectID = "" + if project != "" { + resolved.ProjectID = project + } + resolved.RepoAllow = intersected + resolved.Applied = appliedForExplicit(project, repo, ref, intersected) + return resolved, nil +} + +func (s *Server) resolveUnboundScope(ctx context.Context, workspaceArg, project, repo, ref, scopeArg string, scopeRepos map[string]bool, intent ToolIntent, explicitNarrowing bool) (ResolvedScope, error) { + resolved := ResolvedScope{ + WorkspaceID: s.scopeWorkspace, + ProjectID: s.scopeProject, + Applied: appliedProjectOrWorkspace(s.scopeProject), + } + if workspaceArg != "" { + resolved.WorkspaceID = workspaceArg + if project == "" { + resolved.ProjectID = "" + resolved.Applied = "workspace" + } + } + if project != "" { + resolved.ProjectID = project + resolved.Applied = "project:" + project + } + + if !explicitNarrowing && s.scopeIntentDefaultsEnabled() { + return s.applyIntentDefault(ctx, resolved, intent), nil + } + + if scopeRepos != nil { + resolved.ProjectID = "" + resolved.RepoAllow = scopeRepos + resolved.Applied = "scope:" + scopeArg + return resolved, nil + } + if repo == "*" && project == "" && ref == "" { + resolved.ProjectID = "" + resolved.RepoAllow = nil + resolved.Applied = "workspace" + return resolved, nil + } + if repo == "*" { + repo = "" + } + + allowed, err := s.resolveRepoFilterArgs(repo, project, ref, !explicitNarrowing) + if err != nil { + return ResolvedScope{}, err + } + resolved.RepoAllow = allowed + if repo != "" || project != "" || ref != "" { + resolved.Applied = appliedForExplicit(project, repo, ref, allowed) + } else if allowed != nil { + resolved.Applied = appliedForExplicit(s.activeProject, "", "", allowed) + } + return resolved, nil +} + +func (s *Server) scopeIntentDefaultsEnabled() bool { + if s == nil { + return true + } + return s.scopeIntentDefaults +} + +func (s *Server) applyIntentDefault(ctx context.Context, resolved ResolvedScope, intent ToolIntent) ResolvedScope { + resolved.ProjectID = "" + resolved.RepoAllow = nil + resolved.Applied = "workspace" + if intent != IntentLocate { + return resolved + } + repo, _ := s.sessionLocality(ctx) + if repo == "" { + return resolved + } + resolved.RepoAllow = map[string]bool{repo: true} + resolved.Applied = "repo:" + repo + return resolved +} + +func appliedProjectOrWorkspace(project string) string { + if project != "" { + return "project:" + project + } + return "workspace" +} + +func appliedForExplicit(project, repo, ref string, allowed map[string]bool) string { + switch { + case repo != "" && repo != "*": + return "repo:" + repo + case project != "": + return "project:" + project + case ref != "": + return "ref:" + ref + case len(allowed) == 1: + for p := range allowed { + return "repo:" + p + } + case len(allowed) > 1: + return fmt.Sprintf("repos:%d", len(allowed)) + } + return "workspace" +} + +func scopeApplied(scope ResolvedScope) string { + if scope.Applied != "" { + return scope.Applied + } + if len(scope.RepoAllow) == 1 { + for repo := range scope.RepoAllow { + return "repo:" + repo + } + } + if len(scope.RepoAllow) > 1 { + return fmt.Sprintf("repos:%d", len(scope.RepoAllow)) + } + return appliedProjectOrWorkspace(scope.ProjectID) +} + +func decorateResultWithScope(res *mcp.CallToolResult, scope ResolvedScope) *mcp.CallToolResult { + if res == nil { + return nil + } + fields := map[string]any{ + "scope_applied": scopeApplied(scope), + "scope_widen_hint": `to widen: pass repo:"*" or project: or scope:`, + } + if res.Meta == nil { + res.Meta = mcp.NewMetaFromMap(fields) + return res + } + if res.Meta.AdditionalFields == nil { + res.Meta.AdditionalFields = map[string]any{} + } + for k, v := range fields { + res.Meta.AdditionalFields[k] = v + } + return res +} + +func withScopeResult(res *mcp.CallToolResult, err error, scope ResolvedScope) (*mcp.CallToolResult, error) { + if err != nil { + return res, err + } + return decorateResultWithScope(res, scope), nil +} + +func (s *Server) respondScopedJSONOrTOON(ctx context.Context, req mcp.CallToolRequest, payload any, scope ResolvedScope) (*mcp.CallToolResult, error) { + res, err := s.respondJSONOrTOON(ctx, req, payload) + return withScopeResult(res, err, scope) +} + +func (s *Server) returnScopedSubGraph(ctx context.Context, req mcp.CallToolRequest, sg *query.SubGraph, scope ResolvedScope) (*mcp.CallToolResult, error) { + res, err := s.returnSubGraph(ctx, req, sg) + return withScopeResult(res, err, scope) +} + +func intersectRepoSets(a, b map[string]bool) map[string]bool { + out := make(map[string]bool) + for p := range a { + if b[p] { + out[p] = true + } + } + return out +} diff --git a/internal/mcp/scope_resolve_test.go b/internal/mcp/scope_resolve_test.go new file mode 100644 index 00000000..c83526ca --- /dev/null +++ b/internal/mcp/scope_resolve_test.go @@ -0,0 +1,670 @@ +package mcp + +// Tests for the intent-based scope resolver added in the scope-intent-defaults +// change (Layer A + Layer B). See internal/query/scope_allows_test.go for +// the lower-level ScopeAllows node-predicate matrix. + +import ( + "context" + "encoding/json" + "fmt" + "os" + "path/filepath" + "testing" + + mcplib "github.com/mark3labs/mcp-go/mcp" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" + "go.uber.org/zap" + + "github.com/zzet/gortex/internal/config" + "github.com/zzet/gortex/internal/graph" + "github.com/zzet/gortex/internal/indexer" + "github.com/zzet/gortex/internal/parser" + "github.com/zzet/gortex/internal/parser/languages" + "github.com/zzet/gortex/internal/query" + "github.com/zzet/gortex/internal/search" +) + +// ─── helpers ─────────────────────────────────────────────────────────────── + +// wsRepoFull writes a minimal Go repo whose .gortex.yaml declares +// both a workspace and an optional project slug. When project is "", +// the project: line is omitted (the repo falls back to its prefix as +// project slug). +func wsRepoFull(t *testing.T, name, workspace, project, symbol string) string { + t.Helper() + dir := filepath.Join(t.TempDir(), name) + require.NoError(t, os.MkdirAll(dir, 0o755)) + yaml := fmt.Sprintf("workspace: %s\n", workspace) + if project != "" { + yaml += fmt.Sprintf("project: %s\n", project) + } + require.NoError(t, os.WriteFile(filepath.Join(dir, ".gortex.yaml"), []byte(yaml), 0o644)) + require.NoError(t, os.WriteFile(filepath.Join(dir, "main.go"), + []byte("package main\n\nfunc "+symbol+"() {}\n"), 0o644)) + return dir +} + +// sharedWSOptions holds the server + repo roots for a two-repo single-workspace +// fixture where both repos share the same project slug "backend". +type sharedWSOptions struct { + srv *Server + repoA, repoB string // absolute paths (= session CWD anchors) +} + +// newSharedWorkspaceServer creates two repos in workspace "shared-ws", +// project "backend". It returns a *Server whose scopeIntentDefaults +// is controlled by the flagOn argument. +func newSharedWorkspaceServer(t *testing.T, flagOn bool) sharedWSOptions { + t.Helper() + + repoA := wsRepoFull(t, "repo-a", "shared-ws", "backend", "RepoAThing") + repoB := wsRepoFull(t, "repo-b", "shared-ws", "backend", "RepoBThing") + + tmpCfg := filepath.Join(t.TempDir(), "config.yaml") + gc := &config.GlobalConfig{ + Repos: []config.RepoEntry{ + {Path: repoA, Name: "repo-a", Project: "backend"}, + {Path: repoB, Name: "repo-b", Project: "backend"}, + }, + } + gc.SetConfigPath(tmpCfg) + require.NoError(t, gc.Save()) + + cm, err := config.NewConfigManager(tmpCfg) + require.NoError(t, err) + + g := graph.New() + reg := parser.NewRegistry() + languages.RegisterAll(reg) + bm := search.NewBM25() + mi := indexer.NewMultiIndexer(g, reg, bm, cm, zap.NewNop()) + _, err = mi.IndexScoped("", "") + require.NoError(t, err) + + eng := query.NewEngine(g) + eng.SetSearch(bm) + + flagVal := flagOn + srv := NewServer(eng, g, nil, nil, zap.NewNop(), nil, MultiRepoOptions{ + MultiIndexer: mi, + ConfigManager: cm, + ScopeIntentDefaults: &flagVal, + }) + return sharedWSOptions{srv: srv, repoA: repoA, repoB: repoB} +} + +func newSplitProjectWorkspaceServer(t *testing.T, flagOn bool) sharedWSOptions { + t.Helper() + + repoA := wsRepoFull(t, "repo-a", "shared-ws", "frontend", "RepoAThing") + repoB := wsRepoFull(t, "repo-b", "shared-ws", "backend", "RepoBThing") + + tmpCfg := filepath.Join(t.TempDir(), "config.yaml") + gc := &config.GlobalConfig{ + Repos: []config.RepoEntry{ + {Path: repoA, Name: "repo-a", Project: "frontend"}, + {Path: repoB, Name: "repo-b", Project: "backend"}, + }, + } + gc.SetConfigPath(tmpCfg) + require.NoError(t, gc.Save()) + + cm, err := config.NewConfigManager(tmpCfg) + require.NoError(t, err) + + g := graph.New() + reg := parser.NewRegistry() + languages.RegisterAll(reg) + bm := search.NewBM25() + mi := indexer.NewMultiIndexer(g, reg, bm, cm, zap.NewNop()) + _, err = mi.IndexScoped("", "") + require.NoError(t, err) + + eng := query.NewEngine(g) + eng.SetSearch(bm) + + flagVal := flagOn + srv := NewServer(eng, g, nil, nil, zap.NewNop(), nil, MultiRepoOptions{ + MultiIndexer: mi, + ConfigManager: cm, + ScopeIntentDefaults: &flagVal, + }) + return sharedWSOptions{srv: srv, repoA: repoA, repoB: repoB} +} + +// makeReq builds a minimal CallToolRequest carrying the given +// argument map, with the tool name set. +func makeReq(toolName string, args map[string]any) mcplib.CallToolRequest { + req := mcplib.CallToolRequest{} + req.Params.Name = toolName + if args == nil { + args = map[string]any{} + } + req.Params.Arguments = args + return req +} + +// ─── toolIntentForName table ──────────────────────────────────────────────── + +func TestToolIntentForName_Locate(t *testing.T) { + locate := []string{"search_symbols", "search_text", "find_files"} + for _, name := range locate { + if got := toolIntentForName(name); got != IntentLocate { + t.Errorf("toolIntentForName(%q) = %v, want IntentLocate", name, got) + } + } +} + +func TestToolIntentForName_Reach(t *testing.T) { + reach := []string{ + "find_usages", "get_callers", "get_call_chain", + "get_dependencies", "get_dependents", + "find_implementations", "find_overrides", + "contracts", + } + for _, name := range reach { + if got := toolIntentForName(name); got != IntentReach { + t.Errorf("toolIntentForName(%q) = %v, want IntentReach", name, got) + } + } +} + +func TestToolIntentForName_Analyze(t *testing.T) { + analyze := []string{"analyze", "review", "sast", "hotspots", "dead_code"} + for _, name := range analyze { + if got := toolIntentForName(name); got != IntentAnalyze { + t.Errorf("toolIntentForName(%q) = %v, want IntentAnalyze", name, got) + } + } +} + +func TestToolIntentForName_Unknown_DefaultsToAnalyze(t *testing.T) { + // Any unregistered tool name must fall through to IntentAnalyze — + // the safest default (workspace-breadth, not narrowed). + for _, name := range []string{"unknown_tool", "", "get_symbol", "edit_file"} { + got := toolIntentForName(name) + if got != IntentAnalyze { + t.Errorf("toolIntentForName(%q) = %v, want IntentAnalyze (safe default)", name, got) + } + } +} + +// ─── resolveScope — intent defaults ON (Layer B) ──────────────────────────── + +// TestResolveScope_IntentDefaultsON_Locate_ReturnsHomeRepo verifies that +// a bound session with intent=locate and no explicit narrowing yields a +// RepoAllow set containing ONLY the home repo. +func TestResolveScope_IntentDefaultsON_Locate_ReturnsHomeRepo(t *testing.T) { + fx := newSharedWorkspaceServer(t, true) + ctx := sessionCtx("s-a", fx.repoA) // session home = repo-a + + scope, errRes := fx.srv.resolveScope(ctx, makeReq("search_symbols", nil), IntentLocate) + require.Nil(t, errRes, "resolveScope must not error for a normal locate request") + + assert.Equal(t, map[string]bool{"repo-a": true}, scope.RepoAllow, + "locate intent default must narrow to the home repo only") + assert.Equal(t, "repo:repo-a", scope.Applied) +} + +// TestResolveScope_IntentDefaultsON_Reach_ReturnsWorkspace verifies that +// reach-intent tools default to workspace breadth (no repo narrowing). +func TestResolveScope_IntentDefaultsON_Reach_ReturnsWorkspace(t *testing.T) { + fx := newSharedWorkspaceServer(t, true) + ctx := sessionCtx("s-a", fx.repoA) + + scope, errRes := fx.srv.resolveScope(ctx, makeReq("find_usages", nil), IntentReach) + require.Nil(t, errRes) + + assert.Nil(t, scope.RepoAllow, "reach intent must not narrow to a repo") + assert.Equal(t, "workspace", scope.Applied) +} + +// TestResolveScope_IntentDefaultsON_Analyze_ReturnsWorkspace verifies that +// analyze-intent tools also default to workspace breadth. +func TestResolveScope_IntentDefaultsON_Analyze_ReturnsWorkspace(t *testing.T) { + fx := newSharedWorkspaceServer(t, true) + ctx := sessionCtx("s-a", fx.repoA) + + scope, errRes := fx.srv.resolveScope(ctx, makeReq("analyze", nil), IntentAnalyze) + require.Nil(t, errRes) + + assert.Nil(t, scope.RepoAllow, "analyze intent must not narrow to a repo") + assert.Equal(t, "workspace", scope.Applied) +} + +// TestResolveScope_IntentDefaultsON_RepoStar_WidensLocate verifies that +// an explicit repo:"*" clears the home-repo narrowing produced by the +// locate default. +func TestResolveScope_IntentDefaultsON_RepoStar_WidensLocate(t *testing.T) { + fx := newSharedWorkspaceServer(t, true) + ctx := sessionCtx("s-a", fx.repoA) + + req := makeReq("search_symbols", map[string]any{"repo": "*"}) + scope, errRes := fx.srv.resolveScope(ctx, req, IntentLocate) + require.Nil(t, errRes) + + assert.Nil(t, scope.RepoAllow, "repo:* sentinel must clear the repo narrowing") + assert.Equal(t, "workspace", scope.Applied, + "widened locate should be reported as workspace") +} + +// TestResolveScope_IntentDefaultsON_ExplicitProject_NarrowsLocate verifies +// that an explicit project: arg is honoured even under intent defaults ON. +func TestResolveScope_IntentDefaultsON_ExplicitProject_NarrowsLocate(t *testing.T) { + fx := newSharedWorkspaceServer(t, true) + ctx := sessionCtx("s-a", fx.repoA) + + req := makeReq("search_symbols", map[string]any{"project": "backend"}) + scope, errRes := fx.srv.resolveScope(ctx, req, IntentLocate) + require.Nil(t, errRes) + + assert.Equal(t, "backend", scope.ProjectID) + assert.Equal(t, map[string]bool{"repo-a": true, "repo-b": true}, scope.RepoAllow, + "explicit project must resolve to a concrete repo allow-set") + assert.Equal(t, "project:backend", scope.Applied) +} + +func TestResolveScope_ExplicitUnknownProject_Errors(t *testing.T) { + fx := newSharedWorkspaceServer(t, true) + ctx := sessionCtx("s-a", fx.repoA) + + req := makeReq("search_symbols", map[string]any{"project": "does-not-exist"}) + _, errRes := fx.srv.resolveScope(ctx, req, IntentLocate) + require.NotNil(t, errRes, "explicit unknown project must not degrade to an unfiltered scope") + assert.Contains(t, errResultBody(errRes), "does-not-exist") +} + +func TestResolveScope_UnboundExplicitProject_UsesRepoAllow(t *testing.T) { + fx := newSharedWorkspaceServer(t, true) + + req := makeReq("search_symbols", map[string]any{"project": "backend"}) + scope, errRes := fx.srv.resolveScope(context.Background(), req, IntentLocate) + require.Nil(t, errRes) + + assert.Equal(t, "backend", scope.ProjectID) + assert.Equal(t, map[string]bool{"repo-a": true, "repo-b": true}, scope.RepoAllow) + assert.Equal(t, "project:backend", scope.Applied) +} + +// ─── resolveScope — intent defaults OFF (Layer-A / today's behavior) ──────── + +// TestResolveScope_IntentDefaultsOFF_Locate_StaysWorkspace verifies that +// with the flag off, a locate call in a bound session returns ALL workspace +// repos — today's project-or-workspace behavior preserved byte-for-byte. +func TestResolveScope_IntentDefaultsOFF_Locate_StaysWorkspace(t *testing.T) { + fx := newSharedWorkspaceServer(t, false) // flag OFF + ctx := sessionCtx("s-a", fx.repoA) + + scope, errRes := fx.srv.resolveScope(ctx, makeReq("search_symbols", nil), IntentLocate) + require.Nil(t, errRes) + + // With the flag off the resolver returns ALL repos in the workspace + // (the project default = both repos share project "backend", so no + // narrowing beyond the workspace boundary). + wantRepos := map[string]bool{"repo-a": true, "repo-b": true} + assert.Equal(t, wantRepos, scope.RepoAllow, + "flag-off locate must not narrow to home repo; should keep workspace repos") +} + +// TestResolveScope_IntentDefaultsOFF_Reach_StaysWorkspace verifies that +// reach-intent behavior is unchanged when the flag is off. +func TestResolveScope_IntentDefaultsOFF_Reach_StaysWorkspace(t *testing.T) { + fx := newSharedWorkspaceServer(t, false) + ctx := sessionCtx("s-a", fx.repoA) + + scope, errRes := fx.srv.resolveScope(ctx, makeReq("find_usages", nil), IntentReach) + require.Nil(t, errRes) + + // Flag-off: same as flag-on for reach — both keep workspace repos. + wantRepos := map[string]bool{"repo-a": true, "repo-b": true} + assert.Equal(t, wantRepos, scope.RepoAllow, + "flag-off reach must also stay at workspace breadth") +} + +// ─── resolveScope — unbound-session fallback ──────────────────────────────── + +// TestResolveScope_UnboundSession_Locate_NoNarrowing verifies that when +// there is no session CWD (unbound session, no home repo), a locate intent +// falls back to workspace-breadth rather than erroring or incorrectly +// narrowing. +func TestResolveScope_UnboundSession_Locate_NoNarrowing(t *testing.T) { + fx := newSharedWorkspaceServer(t, true) // flag ON + // context.Background() = unbound: no session CWD, no home repo + ctx := context.Background() + + scope, errRes := fx.srv.resolveScope(ctx, makeReq("search_symbols", nil), IntentLocate) + require.Nil(t, errRes, "unbound session locate must not error") + + // Unbound session: no home repo → applyIntentDefault returns workspace. + assert.Nil(t, scope.RepoAllow, + "locate with no home repo must fall back to workspace (no narrowing)") + assert.Equal(t, "workspace", scope.Applied) +} + +// ─── resolveScope — cross-workspace rejection ──────────────────────────────── + +// TestResolveScope_CrossWorkspace_Rejected verifies that a `workspace:` arg +// naming a different workspace than the session's is a hard error — the +// workspace boundary is never escapable. +func TestResolveScope_CrossWorkspace_Rejected(t *testing.T) { + fx := newSharedWorkspaceServer(t, true) + ctx := sessionCtx("s-a", fx.repoA) // session workspace = "shared-ws" + + req := makeReq("search_symbols", map[string]any{"workspace": "other-ws"}) + _, errRes := fx.srv.resolveScope(ctx, req, IntentLocate) + require.NotNil(t, errRes, "cross-workspace arg must produce an error result") + + // The error text must mention "cross-workspace" so the agent understands why. + body := errResultBody(errRes) + assert.Contains(t, body, "cross-workspace") +} + +// ─── resolveScope — git-diff scope values are stripped ────────────────────── + +// TestResolveScope_GitDiffScope_Cleared verifies that values in +// gitDiffScopes ("staged", "unstaged", "all", "compare") do NOT trigger +// the saved-scope lookup — they are stripped early so diff-family tools +// can reuse the `scope` arg for a different purpose. +func TestResolveScope_GitDiffScope_Cleared(t *testing.T) { + fx := newSharedWorkspaceServer(t, true) + ctx := sessionCtx("s-a", fx.repoA) + + for _, diffScope := range []string{"staged", "unstaged", "all", "compare"} { + req := makeReq("search_symbols", map[string]any{"scope": diffScope}) + // Must NOT error with "unknown scope" — the value is cleared before + // the saved-scope lookup runs. + _, errRes := fx.srv.resolveScope(ctx, req, IntentLocate) + assert.Nilf(t, errRes, + "git-diff scope value %q must be stripped, not looked up as a saved scope", diffScope) + } +} + +// ─── resolveScope — explicit repo / project intersect-and-clamp ───────────── + +// TestResolveScope_ExplicitRepo_Clamps verifies that an explicit repo: arg +// inside the workspace is honoured and clamped to the session workspace. +func TestResolveScope_ExplicitRepo_Clamps(t *testing.T) { + fx := newSharedWorkspaceServer(t, true) + ctx := sessionCtx("s-a", fx.repoA) + + req := makeReq("search_symbols", map[string]any{"repo": "repo-b"}) + scope, errRes := fx.srv.resolveScope(ctx, req, IntentLocate) + require.Nil(t, errRes) + + assert.Equal(t, map[string]bool{"repo-b": true}, scope.RepoAllow, + "explicit repo arg must override the intent default") +} + +// TestResolveScope_ExplicitRepo_OutsideWorkspace_Rejected verifies that a +// repo: arg not in the session's workspace is rejected as a cross-workspace +// escape attempt. +func TestResolveScope_ExplicitRepo_OutsideWorkspace_Rejected(t *testing.T) { + // Use the isolation server which has repo-a in "alpha" and repo-b in "beta". + srv, repoA, _ := newIsolationServer(t) + ctx := sessionCtx("s-alpha", repoA) // session workspace = "alpha" + + // "repo-b" is in workspace "beta" — must be rejected. + req := makeReq("search_symbols", map[string]any{"repo": "repo-b"}) + _, errRes := srv.resolveScope(ctx, req, IntentLocate) + require.NotNil(t, errRes, "repo from another workspace must be rejected") +} + +// ─── workspace hard-isolation invariant ───────────────────────────────────── + +// TestWorkspaceHardBoundary_ScopedNodesNeverNarrowToRepo asserts that +// scopedNodes and nodeInSessionScope enforce workspace isolation only — +// they are NOT affected by the intent-defaults repo narrowing. +// This proves the hard isolation boundary is never narrowed by Layer B. +func TestWorkspaceHardBoundary_ScopedNodesNeverNarrowToRepo(t *testing.T) { + fx := newSharedWorkspaceServer(t, true) // flag ON + + // Session bound to repo-a (workspace "shared-ws"). + ctxA := sessionCtx("s-a", fx.repoA) + + // scopedNodes must return nodes from BOTH repos in the workspace — + // not just repo-a — because the hard boundary is workspace-shaped, + // not repo-shaped. + nodes := fx.srv.scopedNodes(ctxA) + require.NotEmpty(t, nodes, "scopedNodes must not be empty for a bound session") + + hasRepoA, hasRepoB := false, false + for _, n := range nodes { + if n.RepoPrefix == "repo-a" { + hasRepoA = true + } + if n.RepoPrefix == "repo-b" { + hasRepoB = true + } + } + assert.True(t, hasRepoA, "scopedNodes must include repo-a nodes") + assert.True(t, hasRepoB, + "scopedNodes must include repo-b nodes — workspace hard boundary is NOT narrowed to home repo") + + // nodeInSessionScope must see both repos' nodes. + var repoBNode *graph.Node + for _, n := range nodes { + if n.Name == "RepoBThing" { + repoBNode = n + break + } + } + require.NotNil(t, repoBNode, "RepoBThing must be in the workspace graph") + assert.True(t, fx.srv.nodeInSessionScope(ctxA, repoBNode), + "nodeInSessionScope must NOT narrow to repo — it must pass all nodes in the workspace") +} + +// ─── bound-session integration: the proposal scenario ─────────────────────── + +// TestIntentDefaults_SearchSymbols_NarrowsToHomeRepo tests the core +// proposal scenario: with the flag ON, search_symbols from a session +// bound to repo-a returns ONLY repo-a symbols — even though repo-b is in +// the same workspace and project. +func TestIntentDefaults_SearchSymbols_NarrowsToHomeRepo(t *testing.T) { + fx := newSharedWorkspaceServer(t, true) // flag ON + ctx := sessionCtx("s-a", fx.repoA) + + req := makeReq("search_symbols", map[string]any{"query": "Thing"}) + res, err := fx.srv.handleSearchSymbols(ctx, req) + require.NoError(t, err) + require.False(t, res.IsError, "search_symbols must not error") + + var resp map[string]any + require.NoError(t, json.Unmarshal([]byte(res.Content[0].(mcplib.TextContent).Text), &resp)) + ids := resultIDs(resp) + require.NotEmpty(t, ids, "search_symbols must find symbols in repo-a") + + for _, id := range ids { + assert.NotContains(t, id, "repo-b", + "flag-ON search_symbols must not return repo-b results: %s", id) + } + // Sanity: repo-a result is present. + foundA := false + for _, id := range ids { + if containsStr(id, "RepoAThing") { + foundA = true + } + } + assert.True(t, foundA, "RepoAThing from repo-a must be in results") +} + +func TestSearchSymbols_InlineRepoClauseOverridesLocateDefault(t *testing.T) { + fx := newSharedWorkspaceServer(t, true) + ctx := sessionCtx("s-a", fx.repoA) + + req := makeReq("search_symbols", map[string]any{"query": "repo:repo-b Thing"}) + res, err := fx.srv.handleSearchSymbols(ctx, req) + require.NoError(t, err) + require.False(t, res.IsError) + + var resp map[string]any + require.NoError(t, json.Unmarshal([]byte(res.Content[0].(mcplib.TextContent).Text), &resp)) + ids := resultIDs(resp) + require.NotEmpty(t, ids) + + foundA, foundB := false, false + for _, id := range ids { + if containsStr(id, "RepoAThing") { + foundA = true + } + if containsStr(id, "RepoBThing") { + foundB = true + } + } + assert.False(t, foundA, "inline repo:repo-b must not fall back to the home repo") + assert.True(t, foundB, "inline repo:repo-b must search repo-b") + require.NotNil(t, res.Meta) + assert.Equal(t, "repo:repo-b", res.Meta.AdditionalFields["scope_applied"]) +} + +func TestSearchSymbols_InlineProjectClauseOverridesLocateDefault(t *testing.T) { + fx := newSplitProjectWorkspaceServer(t, true) + ctx := sessionCtx("s-a", fx.repoA) + + req := makeReq("search_symbols", map[string]any{"query": "project:backend Thing"}) + res, err := fx.srv.handleSearchSymbols(ctx, req) + require.NoError(t, err) + require.False(t, res.IsError) + + var resp map[string]any + require.NoError(t, json.Unmarshal([]byte(res.Content[0].(mcplib.TextContent).Text), &resp)) + ids := resultIDs(resp) + require.NotEmpty(t, ids) + + foundA, foundB := false, false + for _, id := range ids { + if containsStr(id, "RepoAThing") { + foundA = true + } + if containsStr(id, "RepoBThing") { + foundB = true + } + } + assert.False(t, foundA, "inline project:backend must not stay clamped to the frontend home repo") + assert.True(t, foundB, "inline project:backend must search the backend project") + require.NotNil(t, res.Meta) + assert.Equal(t, "project:backend", res.Meta.AdditionalFields["scope_applied"]) +} + +// TestIntentDefaults_FindUsages_SpansWorkspace verifies that find_usages +// (IntentReach) is NOT narrowed to the home repo — it spans the whole +// workspace so cross-repo usage of shared-lib symbols is visible. +func TestIntentDefaults_FindUsages_SpansWorkspace(t *testing.T) { + fx := newSharedWorkspaceServer(t, true) // flag ON + ctx := sessionCtx("s-a", fx.repoA) + + // resolveScope for find_usages must give workspace breadth. + scope, errRes := fx.srv.resolveScope(ctx, makeReq("find_usages", nil), IntentReach) + require.Nil(t, errRes) + assert.Nil(t, scope.RepoAllow, + "find_usages (reach) must span whole workspace, not narrow to home repo") +} + +// TestIntentDefaults_RepoStarWidens verifies that repo:"*" on a locate +// tool widens back to the whole workspace. +func TestIntentDefaults_RepoStarWidens(t *testing.T) { + fx := newSharedWorkspaceServer(t, true) // flag ON + ctx := sessionCtx("s-a", fx.repoA) + + req := makeReq("search_symbols", map[string]any{"query": "Thing", "repo": "*"}) + res, err := fx.srv.handleSearchSymbols(ctx, req) + require.NoError(t, err) + require.False(t, res.IsError) + + var resp map[string]any + require.NoError(t, json.Unmarshal([]byte(res.Content[0].(mcplib.TextContent).Text), &resp)) + ids := resultIDs(resp) + require.NotEmpty(t, ids) + + // After widening, results from BOTH repos should be present. + foundA, foundB := false, false + for _, id := range ids { + if containsStr(id, "RepoAThing") { + foundA = true + } + if containsStr(id, "RepoBThing") { + foundB = true + } + } + assert.True(t, foundA, "widened search must include repo-a results") + assert.True(t, foundB, "widened search must include repo-b results") +} + +func TestSearchSymbols_InlineRepoStarWidens(t *testing.T) { + fx := newSharedWorkspaceServer(t, true) + ctx := sessionCtx("s-a", fx.repoA) + + req := makeReq("search_symbols", map[string]any{"query": "repo:* Thing"}) + res, err := fx.srv.handleSearchSymbols(ctx, req) + require.NoError(t, err) + require.False(t, res.IsError) + + var resp map[string]any + require.NoError(t, json.Unmarshal([]byte(res.Content[0].(mcplib.TextContent).Text), &resp)) + ids := resultIDs(resp) + require.NotEmpty(t, ids) + + foundA, foundB := false, false + for _, id := range ids { + if containsStr(id, "RepoAThing") { + foundA = true + } + if containsStr(id, "RepoBThing") { + foundB = true + } + } + assert.True(t, foundA, "inline repo:* must keep home-repo results") + assert.True(t, foundB, "inline repo:* must widen to workspace results") + require.NotNil(t, res.Meta) + assert.Equal(t, "workspace", res.Meta.AdditionalFields["scope_applied"]) + require.Nil(t, resp["filters_relaxed"], "repo:* is a scope sentinel, not a fuzzy post-filter") +} + +// TestIntentDefaults_FlagOff_ReproducesTodaysBehavior verifies that with +// the flag OFF, search_symbols returns symbols from ALL repos in the +// workspace/project — matching today's project-scoped behavior. +func TestIntentDefaults_FlagOff_ReproducesTodaysBehavior(t *testing.T) { + fx := newSharedWorkspaceServer(t, false) // flag OFF + ctx := sessionCtx("s-a", fx.repoA) + + req := makeReq("search_symbols", map[string]any{"query": "Thing"}) + res, err := fx.srv.handleSearchSymbols(ctx, req) + require.NoError(t, err) + require.False(t, res.IsError) + + var resp map[string]any + require.NoError(t, json.Unmarshal([]byte(res.Content[0].(mcplib.TextContent).Text), &resp)) + ids := resultIDs(resp) + require.NotEmpty(t, ids, "flag-off search must find symbols") + + // With the flag off, the resolver returns ALL workspace repos. + // Both repo-a and repo-b results must be present. + foundA, foundB := false, false + for _, id := range ids { + if containsStr(id, "RepoAThing") { + foundA = true + } + if containsStr(id, "RepoBThing") { + foundB = true + } + } + assert.True(t, foundA, "flag-off: repo-a symbols must be found") + assert.True(t, foundB, "flag-off: repo-b symbols must also be found (no home-repo narrowing)") +} + +// ─── misc helper ──────────────────────────────────────────────────────────── + +// containsStr reports whether sub is a substring of s. +// Named to avoid collision with tools_dataflow_test.go's contains(). +func containsStr(s, sub string) bool { + if len(sub) == 0 { + return true + } + for i := 0; i <= len(s)-len(sub); i++ { + if s[i:i+len(sub)] == sub { + return true + } + } + return false +} diff --git a/internal/mcp/server.go b/internal/mcp/server.go index 1455b38e..27c2035e 100644 --- a/internal/mcp/server.go +++ b/internal/mcp/server.go @@ -107,7 +107,10 @@ type Server struct { // than reading the fields directly. scopeWorkspace string scopeProject string - logger *zap.Logger + // scopeIntentDefaults gates Layer-B intent defaults: locate tools + // start at the session repo, reach/analyze tools at the workspace. + scopeIntentDefaults bool + logger *zap.Logger // recorder counts allow-listed, consent-gated usage telemetry. nil or // disabled when telemetry is off; Record is nil-safe and fail-silent so // the dispatch hot path never branches on it. @@ -982,6 +985,9 @@ type MultiRepoOptions struct { // preset / allow-deny set (the `mcp.tools` config block). Nil leaves // the full surface; GORTEX_TOOLS / GORTEX_TOOLS_MODE still override. ToolPolicy *ToolPolicyConfig + // ScopeIntentDefaults overrides the default-on intent scoping flag + // from `.gortex.yaml::scope.intent_defaults`. + ScopeIntentDefaults *bool } // serverInstructions is the server-level `instructions` field returned @@ -1126,12 +1132,13 @@ func (s *Server) activeProjectName(cwd string) string { // NewServer creates an MCP server with all Gortex tools registered. func NewServer(engine *query.Engine, g graph.Store, idx *indexer.Indexer, watcher *indexer.Watcher, logger *zap.Logger, guardRules []config.GuardRule, opts ...MultiRepoOptions) *Server { s := &Server{ - engine: engine, - graph: g, - indexer: idx, - logger: logger, - session: newSessionState(), - tokenStats: &tokenStats{}, + engine: engine, + graph: g, + indexer: idx, + logger: logger, + session: newSessionState(), + scopeIntentDefaults: true, + tokenStats: &tokenStats{}, symHistory: &symbolHistory{ entries: make(map[string][]SymbolModification), }, @@ -1198,6 +1205,9 @@ func NewServer(engine *query.Engine, g graph.Store, idx *indexer.Indexer, watche s.activeProject = o.ActiveProject s.scopeWorkspace = o.ScopeWorkspace s.scopeProject = o.ScopeProject + if o.ScopeIntentDefaults != nil { + s.scopeIntentDefaults = *o.ScopeIntentDefaults + } } // Proactive-notification broadcasters. Constructed up-front so @@ -1593,10 +1603,14 @@ func (s *Server) nodeInSessionScope(ctx context.Context, n *graph.Node) bool { func (s *Server) scopedNodes(ctx context.Context) []*graph.Node { all := s.graph.AllNodes() sessWS, _, bound := s.sessionScope(ctx) - if !bound { + repoAllow := repoAllowFromContext(ctx) + if !bound && len(repoAllow) == 0 { return all } - opts := query.QueryOptions{WorkspaceID: sessWS} + opts := query.QueryOptions{RepoAllow: repoAllow} + if bound { + opts.WorkspaceID = sessWS + } out := make([]*graph.Node, 0, len(all)) for _, n := range all { if opts.ScopeAllows(n) { @@ -1646,10 +1660,14 @@ func (s *Server) scopedNodesByKinds(ctx context.Context, kinds []graph.NodeKind) } } sessWS, _, bound := s.sessionScope(ctx) - if !bound { + repoAllow := repoAllowFromContext(ctx) + if !bound && len(repoAllow) == 0 { return nodes } - opts := query.QueryOptions{WorkspaceID: sessWS} + opts := query.QueryOptions{RepoAllow: repoAllow} + if bound { + opts.WorkspaceID = sessWS + } out := make([]*graph.Node, 0, len(nodes)) for _, n := range nodes { if opts.ScopeAllows(n) { @@ -1664,10 +1682,14 @@ func (s *Server) scopedNodesByKinds(ctx context.Context, kinds []graph.NodeKind) // (engine list methods that don't take QueryOptions). func (s *Server) scopedNodeSlice(ctx context.Context, nodes []*graph.Node) []*graph.Node { sessWS, _, bound := s.sessionScope(ctx) - if !bound { + repoAllow := repoAllowFromContext(ctx) + if !bound && len(repoAllow) == 0 { return nodes } - opts := query.QueryOptions{WorkspaceID: sessWS} + opts := query.QueryOptions{RepoAllow: repoAllow} + if bound { + opts.WorkspaceID = sessWS + } out := make([]*graph.Node, 0, len(nodes)) for _, n := range nodes { if opts.ScopeAllows(n) { @@ -1677,48 +1699,6 @@ func (s *Server) scopedNodeSlice(ctx context.Context, nodes []*graph.Node) []*gr return out } -// resolveQueryScope resolves the (workspace, project) scope for a -// query. For a workspace-bound session the session's workspace is an -// immovable ceiling: a `workspace` arg can never widen it (cross- -// workspace values are rejected up front in resolveRepoFilter), while -// a `project` arg may narrow within it. For an unbound session it -// merges the server-level default scope with caller-supplied arg -// overrides — the legacy `gortex server --workspace` behaviour. -func (s *Server) resolveQueryScope(ctx context.Context, argWorkspace, argProject string) (workspace, project string) { - if sessWS, sessProj, bound := s.sessionScope(ctx); bound { - workspace = sessWS - project = sessProj - if argProject != "" { - project = argProject - } - return - } - workspace = s.scopeWorkspace - if argWorkspace != "" { - workspace = argWorkspace - } - project = s.scopeProject - if argProject != "" { - project = argProject - } - return -} - -// scopeFromRequest pulls `workspace` / `project` arg overrides off -// the MCP request and resolves them against the session boundary. -// Convenience wrapper around resolveQueryScope for handlers that take -// the request directly. -func (s *Server) scopeFromRequest(ctx context.Context, req scopeArgGetter) (workspace, project string) { - return s.resolveQueryScope(ctx, req.GetString("workspace", ""), req.GetString("project", "")) -} - -// scopeArgGetter is the minimum interface for reading MCP string -// args; mirrors mcp.CallToolRequest's GetString without forcing -// every caller to import the mcp pkg here. -type scopeArgGetter interface { - GetString(key, fallback string) string -} - // InitCombo initializes the query→symbol combo tracker. Persists per-repo, // same cache directory as feedback; zero-effect no-op when either argument // is empty. mode selects the max-age reap schedule (AI: 7 days, human: 30). diff --git a/internal/mcp/session_ctx.go b/internal/mcp/session_ctx.go index e4172290..256323ba 100644 --- a/internal/mcp/session_ctx.go +++ b/internal/mcp/session_ctx.go @@ -78,6 +78,38 @@ func SessionCWDFromContext(ctx context.Context) string { return "" } +// repoAllowCtxKey carries the per-request repo allow-set resolved by +// handleAnalyze (resolveScope → ResolvedScope.RepoAllow). The scoped- +// node accessors (scopedNodes / scopedNodesByKinds / scopedNodeSlice) +// read it to narrow within the workspace ceiling without threading a +// param through their ~40 call sites. Unexported: only handleAnalyze +// ever sets it, on the per-request ctx — use withRepoAllow / +// repoAllowFromContext. +type repoAllowCtxKey struct{} + +// withRepoAllow returns a context carrying the per-request repo +// allow-set resolved by handleAnalyze. An empty/nil allow-set returns +// ctx unchanged (the common no-narrowing case), so non-analyze callers +// and unnarrowed analyze calls are byte-for-byte unaffected. +func withRepoAllow(ctx context.Context, allow map[string]bool) context.Context { + if len(allow) == 0 { + return ctx + } + return context.WithValue(ctx, repoAllowCtxKey{}, allow) +} + +// repoAllowFromContext returns the repo allow-set attached via +// withRepoAllow, or nil when none is present. +func repoAllowFromContext(ctx context.Context) map[string]bool { + if ctx == nil { + return nil + } + if a, ok := ctx.Value(repoAllowCtxKey{}).(map[string]bool); ok { + return a + } + return nil +} + // sessionLocal bundles the per-client state that should not aggregate // across sessions: recent agent activity (viewed/modified files and // symbols), and session-scoped token-savings counters. Shared pieces — diff --git a/internal/mcp/tools_analysis.go b/internal/mcp/tools_analysis.go index 68d847d7..64d57347 100644 --- a/internal/mcp/tools_analysis.go +++ b/internal/mcp/tools_analysis.go @@ -59,7 +59,10 @@ func (s *Server) registerAnalysisTools() { mcp.WithBoolean("regexp", mcp.Description("Treat query as a regular expression instead of a literal substring. An invalid pattern is returned as a tool error. Default false.")), mcp.WithNumber("limit", mcp.Description("Max matching lines to return (default 100, capped at 1000).")), mcp.WithString("path", mcp.Description("Restrict matches to one or more sub-paths (comma-separated) -- a monorepo-service slice. Anchored, slash-segment-boundary prefixes relative to the repo root.")), - mcp.WithString("scope", mcp.Description("Name of a saved scope (see save_scope) -- when the scope carries sub-paths, they narrow the matches.")), + mcp.WithString("repo", mcp.Description("Restrict matches to a single repository prefix.")), + mcp.WithString("project", mcp.Description("Restrict matches to repositories in a specific project.")), + mcp.WithString("workspace", mcp.Description("Restrict matches to the active workspace slug; daemon sessions may only name their own workspace.")), + mcp.WithString("scope", mcp.Description("Name of a saved scope (see save_scope) -- its repositories and paths narrow the matches. Ignored for repositories when an explicit repo / project / ref is also given.")), mcp.WithString("format", mcp.Description("Output format: json (default), gcx (GCX1 compact wire format), or toon")), ), s.handleSearchText, diff --git a/internal/mcp/tools_analyze_components.go b/internal/mcp/tools_analyze_components.go index bcb2b2f9..e04285da 100644 --- a/internal/mcp/tools_analyze_components.go +++ b/internal/mcp/tools_analyze_components.go @@ -72,6 +72,35 @@ func (s *Server) handleAnalyzeConnectedComponents( } results := s.runComponents(directed, analysis.ComponentOptions{MinSize: minSize}) + + // Narrow to the session workspace + optional repo allow-set when the + // request scopes below the global graph: prune each component's + // members to visible nodes, recompute Size, and drop components left + // empty. wcc/scc read s.graph directly, so without this the components + // would span every workspace. The row ID is an opaque component index + // (not a node ID), so it is left untouched. Filter before the limit + // cap and the per-component member-limit truncation so both land on + // the visible set. Strict no-op for an unbound session with no + // RepoAllow. + if s.scopeFiltersActive(ctx) { + kept := make([]analysis.ComponentResult, 0, len(results)) + for _, r := range results { + visMembers := make([]string, 0, len(r.Members)) + for _, id := range r.Members { + if s.analyzeNodeVisible(ctx, s.graph.GetNode(id)) { + visMembers = append(visMembers, id) + } + } + if len(visMembers) == 0 { + continue + } + r.Members = visMembers + r.Size = len(visMembers) + kept = append(kept, r) + } + results = kept + } + if limit > 0 && limit < len(results) { results = results[:limit] } diff --git a/internal/mcp/tools_analyze_concurrency.go b/internal/mcp/tools_analyze_concurrency.go index 14a9de44..f9c20ccc 100644 --- a/internal/mcp/tools_analyze_concurrency.go +++ b/internal/mcp/tools_analyze_concurrency.go @@ -101,6 +101,21 @@ func (s *Server) handleAnalyzeRaceWrites(ctx context.Context, req mcp.CallToolRe }) } + // Scope filter: when the request narrows below the global graph + // (workspace-bound session or repo allow-set), keep only races + // whose field AND writer node are both visible. total/truncated + // below recompute over the kept rows. No-op for an unbound request. + if s.scopeFiltersActive(ctx) { + kept := make([]raceRow, 0, len(rows)) + for _, r := range rows { + if s.analyzeNodeVisible(ctx, s.graph.GetNode(r.Field)) && + s.analyzeNodeVisible(ctx, s.graph.GetNode(r.Writer)) { + kept = append(kept, r) + } + } + rows = kept + } + sort.Slice(rows, func(i, j int) bool { if rows[i].Field != rows[j].Field { return rows[i].Field < rows[j].Field @@ -367,6 +382,20 @@ func (s *Server) handleAnalyzeUnclosedChannels(ctx context.Context, req mcp.Call }) } + // Scope filter: keep only channels whose channel node is visible to + // the current request. Senders/Sends/Recvs are counts (not node IDs) + // so they need no pruning. total/truncated recompute below. No-op for + // an unbound request. + if s.scopeFiltersActive(ctx) { + kept := make([]unclosedRow, 0, len(rows)) + for _, r := range rows { + if s.analyzeNodeVisible(ctx, s.graph.GetNode(r.Channel)) { + kept = append(kept, r) + } + } + rows = kept + } + sort.Slice(rows, func(i, j int) bool { if rowRiskRank(rows[i].Risk) != rowRiskRank(rows[j].Risk) { return rowRiskRank(rows[i].Risk) > rowRiskRank(rows[j].Risk) diff --git a/internal/mcp/tools_analyze_doc_staleness.go b/internal/mcp/tools_analyze_doc_staleness.go index 83e3ef7d..ba57d439 100644 --- a/internal/mcp/tools_analyze_doc_staleness.go +++ b/internal/mcp/tools_analyze_doc_staleness.go @@ -116,5 +116,58 @@ func analyzeDocStaleness(g graph.Store, limit int) docStalenessResult { func (s *Server) handleAnalyzeDocStaleness(ctx context.Context, req mcp.CallToolRequest) (*mcp.CallToolResult, error) { res := analyzeDocStaleness(s.graph, req.GetInt("limit", 50)) + if s.scopeFiltersActive(ctx) { + // Narrow to the session workspace + optional repo allow-set. + // Keep a row iff its Source (the keyed knowledge node) is visible. + // Within a kept row, prune only RESOLVED ("live") links whose + // target falls out of scope: dangling/pending links point at + // genuinely-absent nodes (GetNode==nil ⇒ analyzeNodeVisible==false) + // and carry the analyzer's entire signal, so they are NEVER + // dropped. Per-row counts and the global assessed-links tally + // recompute from the in-scope data. Unbound requests skip this + // branch — a byte-for-byte no-op. + kept := make([]docStalenessRow, 0, len(res.Stale)) + for _, row := range res.Stale { + if !s.analyzeNodeVisible(ctx, s.graph.GetNode(row.Source)) { + continue + } + if len(row.Links) > 0 { + links := make([]docStalenessLink, 0, len(row.Links)) + dangling, pending, total := 0, 0, 0 + for _, l := range row.Links { + if l.State == "live" && !s.analyzeNodeVisible(ctx, s.graph.GetNode(l.Symbol)) { + continue + } + links = append(links, l) + total++ + switch l.State { + case "dangling": + dangling++ + case "pending": + pending++ + } + } + row.Links = links + row.Dangling = dangling + row.Pending = pending + row.TotalRefs = total + } + kept = append(kept, row) + } + res.Stale = kept + + // Recompute assessed_links as the in-scope motivates edges — + // those whose knowledge source node is visible. + assessed := 0 + for _, e := range s.graph.AllEdges() { + if e == nil || e.Kind != graph.EdgeMotivates { + continue + } + if s.analyzeNodeVisible(ctx, s.graph.GetNode(e.From)) { + assessed++ + } + } + res.AssessedLinks = assessed + } return s.respondJSONOrTOON(ctx, req, res) } diff --git a/internal/mcp/tools_analyze_edge_audit.go b/internal/mcp/tools_analyze_edge_audit.go index e58a0d05..81e3b8b1 100644 --- a/internal/mcp/tools_analyze_edge_audit.go +++ b/internal/mcp/tools_analyze_edge_audit.go @@ -52,7 +52,16 @@ func (s *Server) handleAnalyzeEdgeAudit(ctx context.Context, req mcp.CallToolReq implemented := map[string]bool{} // interface ID → has an implementor var weakCalls []string // text-matched "from -> to" + // When the request narrows scope (workspace-bound session or repo + // allow-set), drop edges/nodes outside it so every count map and + // diagnostic array recomputes from the in-scope subgraph. Unbound + // requests skip the gate entirely — a byte-for-byte no-op. + scoped := s.scopeFiltersActive(ctx) + for _, e := range s.graph.AllEdges() { + if scoped && (!s.analyzeNodeVisible(ctx, s.graph.GetNode(e.From)) || !s.analyzeNodeVisible(ctx, s.graph.GetNode(e.To))) { + continue + } tier := edgeTierLabel(e.Origin) edgeTiers[tier]++ switch e.Kind { @@ -73,6 +82,9 @@ func (s *Server) handleAnalyzeEdgeAudit(ctx context.Context, req mcp.CallToolReq // positives once test callers are policy-excluded. var testOnly []string for _, n := range s.graph.AllNodes() { + if scoped && !s.analyzeNodeVisible(ctx, n) { + continue + } switch n.Kind { case graph.KindInterface: if !implemented[n.ID] { diff --git a/internal/mcp/tools_analyze_edges.go b/internal/mcp/tools_analyze_edges.go index abcc1529..37144084 100644 --- a/internal/mcp/tools_analyze_edges.go +++ b/internal/mcp/tools_analyze_edges.go @@ -92,6 +92,34 @@ func (s *Server) handleAnalyzeChannelOps(ctx context.Context, req mcp.CallToolRe sort.Strings(r.Receivers) rows = append(rows, r) } + // Scope filter: keep a channel row iff its target node is visible to + // the request (session workspace ceiling + optional repo allow-set), + // and prune sender/receiver lists to visible nodes only. No-op for an + // unbound, un-narrowed request. Sends/Recvs are edge counts, left as-is. + if s.scopeFiltersActive(ctx) { + kept := make([]*channelRow, 0, len(rows)) + for _, r := range rows { + if !s.analyzeNodeVisible(ctx, s.graph.GetNode(r.Channel)) { + continue + } + senders := make([]string, 0, len(r.Senders)) + for _, id := range r.Senders { + if s.analyzeNodeVisible(ctx, s.graph.GetNode(id)) { + senders = append(senders, id) + } + } + r.Senders = senders + receivers := make([]string, 0, len(r.Receivers)) + for _, id := range r.Receivers { + if s.analyzeNodeVisible(ctx, s.graph.GetNode(id)) { + receivers = append(receivers, id) + } + } + r.Receivers = receivers + kept = append(kept, r) + } + rows = kept + } sort.Slice(rows, func(i, j int) bool { // Total op count desc; tie-break by channel id for stability. ti := rows[i].Sends + rows[i].Recvs @@ -178,6 +206,25 @@ func (s *Server) handleAnalyzeGoroutineSpawns(ctx context.Context, req mcp.CallT } rows = append(rows, r) } + // Scope filter: keep a spawn row iff its spawned target is visible, + // and prune spawners to visible nodes only. No-op when unbound. + if s.scopeFiltersActive(ctx) { + kept := make([]*spawnRow, 0, len(rows)) + for _, r := range rows { + if !s.analyzeNodeVisible(ctx, s.graph.GetNode(r.Target)) { + continue + } + spawners := make([]string, 0, len(r.Spawners)) + for _, id := range r.Spawners { + if s.analyzeNodeVisible(ctx, s.graph.GetNode(id)) { + spawners = append(spawners, id) + } + } + r.Spawners = spawners + kept = append(kept, r) + } + rows = kept + } sort.Slice(rows, func(i, j int) bool { if rows[i].Spawns != rows[j].Spawns { return rows[i].Spawns > rows[j].Spawns @@ -298,6 +345,25 @@ func (s *Server) handleAnalyzeFieldWriters(ctx context.Context, req mcp.CallTool sort.Strings(r.Writers) rows = append(rows, r) } + // Scope filter: keep a field row iff the field node is visible, and + // prune writers to visible nodes only. No-op when unbound. + if s.scopeFiltersActive(ctx) { + kept := make([]*writerRow, 0, len(rows)) + for _, r := range rows { + if !s.analyzeNodeVisible(ctx, s.graph.GetNode(r.Field)) { + continue + } + writers := make([]string, 0, len(r.Writers)) + for _, id := range r.Writers { + if s.analyzeNodeVisible(ctx, s.graph.GetNode(id)) { + writers = append(writers, id) + } + } + r.Writers = writers + kept = append(kept, r) + } + rows = kept + } sort.Slice(rows, func(i, j int) bool { if rows[i].Writes != rows[j].Writes { return rows[i].Writes > rows[j].Writes @@ -394,6 +460,26 @@ func (s *Server) handleAnalyzeIndirectMutations(ctx context.Context, req mcp.Cal sort.Slice(r.Mutators, func(i, j int) bool { return r.Mutators[i].Function < r.Mutators[j].Function }) rows = append(rows, r) } + // Scope filter: keep a field row iff the field node is visible, and + // prune mutators to those whose function node is visible. The `via` + // method name is not a node ID, so it is left intact. No-op when unbound. + if s.scopeFiltersActive(ctx) { + kept := make([]*fieldRow, 0, len(rows)) + for _, r := range rows { + if !s.analyzeNodeVisible(ctx, s.graph.GetNode(r.Field)) { + continue + } + mutators := make([]mutator, 0, len(r.Mutators)) + for _, m := range r.Mutators { + if s.analyzeNodeVisible(ctx, s.graph.GetNode(m.Function)) { + mutators = append(mutators, m) + } + } + r.Mutators = mutators + kept = append(kept, r) + } + rows = kept + } sort.Slice(rows, func(i, j int) bool { if rows[i].Mutations != rows[j].Mutations { return rows[i].Mutations > rows[j].Mutations @@ -432,10 +518,17 @@ func (s *Server) handleAnalyzeSpeculative(ctx context.Context, req mcp.CallToolR } byShape := map[string]*shapeRow{} total := 0 + scoped := s.scopeFiltersActive(ctx) for e := range edgesByKinds(s.graph, graph.EdgeCalls) { if !e.IsSpeculative() { continue } + // Scope filter: a speculative edge is a from->to peer pair; drop it + // unless both endpoints are visible. Gating the loop recomputes + // Edges, total, and Samples together. No-op when unbound. + if scoped && (!s.analyzeNodeVisible(ctx, s.graph.GetNode(e.From)) || !s.analyzeNodeVisible(ctx, s.graph.GetNode(e.To))) { + continue + } total++ shape, _ := e.Meta["via"].(string) shape = strings.TrimPrefix(shape, "speculative.") @@ -509,10 +602,16 @@ func (s *Server) handleAnalyzeRefFacts(ctx context.Context, req mcp.CallToolRequ var facts []fact truncated := false + scoped := s.scopeFiltersActive(ctx) for _, n := range nodes { if n == nil { continue } + // Scope filter: drop reference facts whose origin node is out of + // scope. No-op when unbound. + if scoped && !s.analyzeNodeVisible(ctx, n) { + continue + } for _, e := range s.graph.GetOutEdges(n.ID) { if e == nil || !graph.IsResolvableRefEdge(e.Kind) { continue @@ -520,6 +619,11 @@ func (s *Server) handleAnalyzeRefFacts(ctx context.Context, req mcp.CallToolRequ if e.To == "" || graph.IsUnresolvedTarget(e.To) || graph.IsStub(e.To) { continue } + // Scope filter: also drop facts whose resolved target is out + // of scope, so no cross-workspace target id leaks into a row. + if scoped && !s.analyzeNodeVisible(ctx, s.graph.GetNode(e.To)) { + continue + } refName := "" if t := s.graph.GetNode(e.To); t != nil { refName = t.Name @@ -593,6 +697,17 @@ func (s *Server) handleAnalyzeAnnotationUsers(ctx context.Context, req mcp.CallT Args: argsStr, }) } + // Scope filter: keep an annotated-symbol row iff the annotated + // symbol node is visible. No-op when unbound. + if s.scopeFiltersActive(ctx) { + kept := make([]annotatedRow, 0, len(rows)) + for _, r := range rows { + if s.analyzeNodeVisible(ctx, s.graph.GetNode(r.Symbol)) { + kept = append(kept, r) + } + } + rows = kept + } sort.Slice(rows, func(i, j int) bool { if rows[i].File != rows[j].File { return rows[i].File < rows[j].File @@ -655,6 +770,17 @@ func (s *Server) handleAnalyzeAnnotationUsers(ctx context.Context, req mcp.CallT for _, r := range byID { rows = append(rows, r) } + // Scope filter: keep an annotation row iff the annotation node is + // visible. No-op when unbound. Users is a count, left as-is. + if s.scopeFiltersActive(ctx) { + kept := make([]*annoRow, 0, len(rows)) + for _, r := range rows { + if s.analyzeNodeVisible(ctx, s.graph.GetNode(r.ID)) { + kept = append(kept, r) + } + } + rows = kept + } sort.Slice(rows, func(i, j int) bool { if rows[i].Users != rows[j].Users { return rows[i].Users > rows[j].Users @@ -747,6 +873,25 @@ func (s *Server) handleAnalyzeConfigReaders(ctx context.Context, req mcp.CallToo sort.Strings(r.Readers) rows = append(rows, r) } + // Scope filter: keep a config-key row iff the key node is visible, and + // prune readers to visible nodes only. No-op when unbound. + if s.scopeFiltersActive(ctx) { + kept := make([]*configRow, 0, len(rows)) + for _, r := range rows { + if !s.analyzeNodeVisible(ctx, s.graph.GetNode(r.ID)) { + continue + } + readers := make([]string, 0, len(r.Readers)) + for _, id := range r.Readers { + if s.analyzeNodeVisible(ctx, s.graph.GetNode(id)) { + readers = append(readers, id) + } + } + r.Readers = readers + kept = append(kept, r) + } + rows = kept + } sort.Slice(rows, func(i, j int) bool { if rows[i].Reads != rows[j].Reads { return rows[i].Reads > rows[j].Reads @@ -854,6 +999,25 @@ func (s *Server) handleAnalyzeEnvVarUsers(ctx context.Context, req mcp.CallToolR sort.Strings(r.Readers) rows = append(rows, r) } + // Scope filter: keep an env-var row iff the key node is visible, and + // prune readers to visible nodes only. No-op when unbound. + if s.scopeFiltersActive(ctx) { + kept := make([]*envRow, 0, len(rows)) + for _, r := range rows { + if !s.analyzeNodeVisible(ctx, s.graph.GetNode(r.ID)) { + continue + } + readers := make([]string, 0, len(r.Readers)) + for _, id := range r.Readers { + if s.analyzeNodeVisible(ctx, s.graph.GetNode(id)) { + readers = append(readers, id) + } + } + r.Readers = readers + kept = append(kept, r) + } + rows = kept + } sort.Slice(rows, func(i, j int) bool { if rows[i].Reads != rows[j].Reads { return rows[i].Reads > rows[j].Reads @@ -961,6 +1125,25 @@ func (s *Server) handleAnalyzeEventEmitters(ctx context.Context, req mcp.CallToo sort.Strings(r.Emitters) rows = append(rows, r) } + // Scope filter: keep an event row iff the event node is visible, and + // prune emitters to visible nodes only. No-op when unbound. + if s.scopeFiltersActive(ctx) { + kept := make([]*eventRow, 0, len(rows)) + for _, r := range rows { + if !s.analyzeNodeVisible(ctx, s.graph.GetNode(r.ID)) { + continue + } + emitters := make([]string, 0, len(r.Emitters)) + for _, id := range r.Emitters { + if s.analyzeNodeVisible(ctx, s.graph.GetNode(id)) { + emitters = append(emitters, id) + } + } + r.Emitters = emitters + kept = append(kept, r) + } + rows = kept + } sort.Slice(rows, func(i, j int) bool { if rows[i].Emits != rows[j].Emits { return rows[i].Emits > rows[j].Emits @@ -1105,6 +1288,33 @@ func (s *Server) handleAnalyzePubsub(ctx context.Context, req mcp.CallToolReques sort.Strings(r.Subscribers) rows = append(rows, r) } + // Scope filter: keep a topic row iff the topic node is visible, and + // prune publisher/subscriber lists to visible nodes only. No-op when + // unbound. Publishes/Subscribes are edge counts, left as-is. + if s.scopeFiltersActive(ctx) { + kept := make([]*pubsubRow, 0, len(rows)) + for _, r := range rows { + if !s.analyzeNodeVisible(ctx, s.graph.GetNode(r.ID)) { + continue + } + publishers := make([]string, 0, len(r.Publishers)) + for _, id := range r.Publishers { + if s.analyzeNodeVisible(ctx, s.graph.GetNode(id)) { + publishers = append(publishers, id) + } + } + r.Publishers = publishers + subscribers := make([]string, 0, len(r.Subscribers)) + for _, id := range r.Subscribers { + if s.analyzeNodeVisible(ctx, s.graph.GetNode(id)) { + subscribers = append(subscribers, id) + } + } + r.Subscribers = subscribers + kept = append(kept, r) + } + rows = kept + } sort.Slice(rows, func(i, j int) bool { ti := rows[i].Publishes + rows[i].Subscribes tj := rows[j].Publishes + rows[j].Subscribes @@ -1246,6 +1456,28 @@ func (s *Server) handleAnalyzeErrorSurface(ctx context.Context, req mcp.CallTool rows = append(rows, r) } } + // Scope filter: keep a thrower row iff the throwing symbol is visible, + // and prune the error-target list to visible nodes only. Error message + // literals (ErrorMsgs) are string values, not node ids, left intact. + // Applies to both the ThrowerErrorSurfacer and fallback build paths. + // No-op when unbound. + if s.scopeFiltersActive(ctx) { + kept := make([]*throwerRow, 0, len(rows)) + for _, r := range rows { + if !s.analyzeNodeVisible(ctx, s.graph.GetNode(r.Symbol)) { + continue + } + errs := make([]string, 0, len(r.Errors)) + for _, id := range r.Errors { + if s.analyzeNodeVisible(ctx, s.graph.GetNode(id)) { + errs = append(errs, id) + } + } + r.Errors = errs + kept = append(kept, r) + } + rows = kept + } sort.Slice(rows, func(i, j int) bool { // Throwers with the most distinct error targets surface // first — those are the highest-leverage refactor candidates. diff --git a/internal/mcp/tools_analyze_framework.go b/internal/mcp/tools_analyze_framework.go index a5f829e7..b008cbee 100644 --- a/internal/mcp/tools_analyze_framework.go +++ b/internal/mcp/tools_analyze_framework.go @@ -66,6 +66,21 @@ func (s *Server) handleAnalyzeRoutes(ctx context.Context, req mcp.CallToolReques Line: e.Line, }) } + // routes reads EdgeHandlesRoute directly off s.graph; narrow each row to + // the session workspace + optional repo allow-set. Keep a row only when + // BOTH endpoints — the handler (e.From) and the route contract (e.To) — + // are visible. Unbound sessions see every row (analyzeNodeVisible passes), + // so this is a strict no-op there. total recomputes after this block. + if s.scopeFiltersActive(ctx) { + kept := make([]*routeRow, 0, len(rows)) + for _, r := range rows { + if s.analyzeNodeVisible(ctx, s.graph.GetNode(r.Handler)) && + s.analyzeNodeVisible(ctx, s.graph.GetNode(r.Route)) { + kept = append(kept, r) + } + } + rows = kept + } sort.Slice(rows, func(i, j int) bool { if rows[i].Kind != rows[j].Kind { return rows[i].Kind < rows[j].Kind @@ -109,10 +124,19 @@ func (s *Server) handleAnalyzeRoutes(ctx context.Context, req mcp.CallToolReques // registry. func (s *Server) handleAnalyzeRouteFrameworks(ctx context.Context, req mcp.CallToolRequest) (*mcp.CallToolResult, error) { frameworkCounts := map[string]int{} + // route_frameworks tallies route contract nodes per framework straight off + // s.graph.AllNodes(). Gate the contributing loop on visibility so the + // per-framework counts (and total_passes) reflect only the session + // workspace + optional repo allow-set. Unbound sessions count every + // contract node, so the gate is a strict no-op there. + scoped := s.scopeFiltersActive(ctx) for _, n := range s.graph.AllNodes() { if n == nil || n.Kind != graph.KindContract || n.Meta == nil { continue } + if scoped && !s.analyzeNodeVisible(ctx, n) { + continue + } if fw := routeFramework(n); fw != "" { frameworkCounts[fw]++ } @@ -166,6 +190,12 @@ func routeFramework(n *graph.Node) string { func (s *Server) handleAnalyzeSwiftUIViews(ctx context.Context, req mcp.CallToolRequest) (*mcp.CallToolResult, error) { roleFilter := strings.TrimSpace(stringArg(req.GetArguments(), "role")) byRole := map[string][]string{} + // swiftui_views groups SwiftUI types straight off s.graph.AllNodes(). Gate + // the contributing loop on visibility so each role's member list (and its + // recomputed Count) covers only the session workspace + optional repo + // allow-set; a role with no in-scope members never gets a map key, so it + // drops out naturally. Unbound sessions keep every type (no-op gate). + scoped := s.scopeFiltersActive(ctx) for _, n := range s.graph.AllNodes() { if n == nil || n.Meta == nil { continue @@ -174,6 +204,9 @@ func (s *Server) handleAnalyzeSwiftUIViews(ctx context.Context, req mcp.CallTool if role == "" || (roleFilter != "" && role != roleFilter) { continue } + if scoped && !s.analyzeNodeVisible(ctx, n) { + continue + } byRole[role] = append(byRole[role], n.ID) } type roleRow struct { @@ -205,6 +238,12 @@ func (s *Server) handleAnalyzeSwiftUIViews(ctx context.Context, req mcp.CallTool func (s *Server) handleAnalyzeUIKitClasses(ctx context.Context, req mcp.CallToolRequest) (*mcp.CallToolResult, error) { roleFilter := strings.TrimSpace(stringArg(req.GetArguments(), "role")) byRole := map[string][]string{} + // uikit_classes groups UIKit types straight off s.graph.AllNodes(). Gate + // the contributing loop on visibility so each role's member list (and its + // recomputed Count) covers only the session workspace + optional repo + // allow-set; a role with no in-scope members never gets a map key, so it + // drops out naturally. Unbound sessions keep every type (no-op gate). + scoped := s.scopeFiltersActive(ctx) for _, n := range s.graph.AllNodes() { if n == nil || n.Meta == nil { continue @@ -213,6 +252,9 @@ func (s *Server) handleAnalyzeUIKitClasses(ctx context.Context, req mcp.CallTool if role == "" || (roleFilter != "" && role != roleFilter) { continue } + if scoped && !s.analyzeNodeVisible(ctx, n) { + continue + } byRole[role] = append(byRole[role], n.ID) } type roleRow struct { @@ -242,6 +284,13 @@ func (s *Server) handleAnalyzeUIKitClasses(ctx context.Context, req mcp.CallTool func (s *Server) handleAnalyzeDrupalHooks(ctx context.Context, req mcp.CallToolRequest) (*mcp.CallToolResult, error) { nameFilter := strings.TrimSpace(stringArg(req.GetArguments(), "name")) hooks := map[string][]string{} + // drupal_hooks groups hook implementations straight off s.graph.AllNodes(). + // Gate the contributing loop on visibility so each hook's implementation + // list (and its recomputed Count) covers only the session workspace + + // optional repo allow-set; a hook with no in-scope implementations never + // gets a map key, so it drops out naturally. Unbound sessions keep every + // implementation (no-op gate). + scoped := s.scopeFiltersActive(ctx) for _, n := range s.graph.AllNodes() { if n == nil || n.Meta == nil { continue @@ -250,6 +299,9 @@ func (s *Server) handleAnalyzeDrupalHooks(ctx context.Context, req mcp.CallToolR if hook == "" || (nameFilter != "" && hook != nameFilter) { continue } + if scoped && !s.analyzeNodeVisible(ctx, n) { + continue + } hooks[hook] = append(hooks[hook], n.ID) } type hookRow struct { @@ -368,6 +420,19 @@ func (s *Server) handleAnalyzeModels(ctx context.Context, req mcp.CallToolReques Line: e.Line, }) } + // models reads EdgeModelsTable directly off s.graph; narrow each row to the + // session workspace + optional repo allow-set. Table is a plain name (not a + // node ID), so visibility hinges on the model node (e.From, r.Model) only. + // Unbound sessions see every model (no-op gate); total recomputes below. + if s.scopeFiltersActive(ctx) { + kept := make([]*modelRow, 0, len(rows)) + for _, r := range rows { + if s.analyzeNodeVisible(ctx, s.graph.GetNode(r.Model)) { + kept = append(kept, r) + } + } + rows = kept + } sort.Slice(rows, func(i, j int) bool { if rows[i].ORM != rows[j].ORM { return rows[i].ORM < rows[j].ORM @@ -448,7 +513,17 @@ func (s *Server) componentsRollup(ctx context.Context, req mcp.CallToolRequest, stats[id] = row return row } + // components reads EdgeRendersChild directly off s.graph. When the request + // narrows scope, gate the edge loop on BOTH endpoints being visible so the + // fan-in / fan-out tallies (and which nodes enter `stats`) cover only the + // session workspace + optional repo allow-set — no out-of-scope neighbor + // inflates a count. Unbound sessions count every edge (no-op gate). + scoped := s.scopeFiltersActive(ctx) for e := range edgesByKinds(s.graph, graph.EdgeRendersChild) { + if scoped && (!s.analyzeNodeVisible(ctx, s.graph.GetNode(e.From)) || + !s.analyzeNodeVisible(ctx, s.graph.GetNode(e.To))) { + continue + } parent := get(e.From) parent.FanOut++ // Skip the child if it never resolved to a real node — leaving @@ -468,6 +543,13 @@ func (s *Server) componentsRollup(ctx context.Context, req mcp.CallToolRequest, if r.FanIn == 0 && r.FanOut == 0 { continue } + // Belt-and-suspenders row gate: keep a row only when its component node + // is itself visible. Redundant given the edge-loop gate above (under + // scope, `stats` only holds visible nodes) but kept explicit per the + // scope contract; a strict no-op for unbound sessions. + if scoped && !s.analyzeNodeVisible(ctx, s.graph.GetNode(r.ID)) { + continue + } rows = append(rows, r) } sort.Slice(rows, func(i, j int) bool { @@ -512,10 +594,19 @@ func (s *Server) componentsForOne(ctx context.Context, req mcp.CallToolRequest, Line int `json:"line"` } var rows []*childRow + // components(id=…) reads the parent's out-edges directly off s.graph. Under + // an active scope, emit no children when the requested parent is itself out + // of scope, and prune children to visible resolved targets (e.To). Unbound + // sessions see the parent and every child (both gates collapse to no-ops). + scoped := s.scopeFiltersActive(ctx) + parentInScope := !scoped || s.analyzeNodeVisible(ctx, s.graph.GetNode(parentID)) for _, e := range s.graph.GetOutEdges(parentID) { if e.Kind != graph.EdgeRendersChild { continue } + if scoped && (!parentInScope || !s.analyzeNodeVisible(ctx, s.graph.GetNode(e.To))) { + continue + } name, _ := e.Meta["child_name"].(string) if name == "" { if strings.HasPrefix(e.To, "unresolved::") { diff --git a/internal/mcp/tools_analyze_kcore.go b/internal/mcp/tools_analyze_kcore.go index 09f97bf4..f55912ca 100644 --- a/internal/mcp/tools_analyze_kcore.go +++ b/internal/mcp/tools_analyze_kcore.go @@ -68,6 +68,23 @@ func (s *Server) handleAnalyzeKCore(ctx context.Context, req mcp.CallToolRequest } hits = filtered } + + // Narrow to the session workspace + optional repo allow-set when the + // request scopes below the global graph. kcore reads s.graph directly, + // so without this the densely-connected core would span every + // workspace. Filter after the min_degree pass and before the limit cap + // so the cap lands on the visible set and count recomputes. Strict + // no-op for an unbound session with no RepoAllow. + if s.scopeFiltersActive(ctx) { + kept := hits[:0] + for _, h := range hits { + if s.analyzeNodeVisible(ctx, s.graph.GetNode(h.NodeID)) { + kept = append(kept, h) + } + } + hits = kept + } + if limit > 0 && limit < len(hits) { hits = hits[:limit] } diff --git a/internal/mcp/tools_analyze_pagerank.go b/internal/mcp/tools_analyze_pagerank.go index c5274d36..5a24b9c0 100644 --- a/internal/mcp/tools_analyze_pagerank.go +++ b/internal/mcp/tools_analyze_pagerank.go @@ -64,14 +64,43 @@ func (s *Server) handleAnalyzePageRank(ctx context.Context, req mcp.CallToolRequ } nodeKinds := parseKindFilter(stringArg(args, "node_kinds")) + // runPageRank applies the limit internally on BOTH the engine-native + // and in-process paths. When the request narrows scope we must rank the + // full graph, drop out-of-scope hits, and only THEN cap — capping first + // would spend the limit on rows the caller can't see and return fewer + // than `limit` visible symbols. So request unbounded under scope and + // re-apply the limit after the visibility filter below. + prLimit := limit + if s.scopeFiltersActive(ctx) { + prLimit = 0 + } + hits := s.runPageRank(graph.PageRankOpts{ NodeKinds: nodeKinds, DampingFactor: damping, MaxIterations: maxIter, Tolerance: tolerance, - Limit: limit, + Limit: prLimit, }) + // Narrow to the session workspace + optional repo allow-set, then + // re-apply the original limit (runPageRank ran unbounded above under + // scope). pagerank reads s.graph directly, so without this the authority + // ranking would span every workspace. Strict no-op for an unbound + // session with no RepoAllow. + if s.scopeFiltersActive(ctx) { + kept := make([]graph.PageRankHit, 0, len(hits)) + for _, h := range hits { + if s.analyzeNodeVisible(ctx, s.graph.GetNode(h.NodeID)) { + kept = append(kept, h) + } + } + hits = kept + if limit > 0 && limit < len(hits) { + hits = hits[:limit] + } + } + // Batch-materialise hit nodes in one backend round-trip instead // of per-id GetNode. On a disk backend each GetNode is a // round-trip; on the default limit (20) the per-id path issued 20 @@ -212,6 +241,47 @@ func (s *Server) handleAnalyzeLouvain(ctx context.Context, req mcp.CallToolReque } communities := result.Communities + totalCommunities := len(result.Communities) + + // Narrow to the session workspace + optional repo allow-set when the + // request scopes below the global graph: prune each community's members + // to visible nodes, recompute Size/Files, and drop communities left + // empty. louvain reads s.graph directly, so without this the partition + // would span every workspace. Hub is a symbol name (not a node ID) so it + // is left untouched. Filter before the limit cap so the cap and the + // total recompute over the visible set. Strict no-op for an unbound + // session with no RepoAllow. + if s.scopeFiltersActive(ctx) { + kept := make([]analysis.Community, 0, len(communities)) + for _, c := range communities { + visMembers := make([]string, 0, len(c.Members)) + files := make([]string, 0, len(c.Files)) + seenFile := make(map[string]struct{}, len(c.Files)) + for _, id := range c.Members { + n := s.graph.GetNode(id) + if !s.analyzeNodeVisible(ctx, n) { + continue + } + visMembers = append(visMembers, id) + if n.FilePath != "" { + if _, dup := seenFile[n.FilePath]; !dup { + seenFile[n.FilePath] = struct{}{} + files = append(files, n.FilePath) + } + } + } + if len(visMembers) == 0 { + continue + } + c.Members = visMembers + c.Size = len(visMembers) + c.Files = files + kept = append(kept, c) + } + communities = kept + totalCommunities = len(kept) + } + if limit > 0 && limit < len(communities) { communities = communities[:limit] } @@ -224,7 +294,7 @@ func (s *Server) handleAnalyzeLouvain(ctx context.Context, req mcp.CallToolReque } if isCompact(req) { var b strings.Builder - fmt.Fprintf(&b, "modularity=%.4f communities=%d\n", result.Modularity, len(result.Communities)) + fmt.Fprintf(&b, "modularity=%.4f communities=%d\n", result.Modularity, totalCommunities) for _, c := range communities { fmt.Fprintf(&b, " %s size=%d cohesion=%.3f label=%s hub=%s\n", c.ID, c.Size, c.Cohesion, c.Label, c.Hub) @@ -234,7 +304,7 @@ func (s *Server) handleAnalyzeLouvain(ctx context.Context, req mcp.CallToolReque return s.respondJSONOrTOON(ctx, req, map[string]any{ "communities": communities, "modularity": result.Modularity, - "total": len(result.Communities), + "total": totalCommunities, }) } diff --git a/internal/mcp/tools_analyze_string_downstream.go b/internal/mcp/tools_analyze_string_downstream.go index faf96bc3..5f704226 100644 --- a/internal/mcp/tools_analyze_string_downstream.go +++ b/internal/mcp/tools_analyze_string_downstream.go @@ -90,6 +90,27 @@ func (s *Server) handleAnalyzeLogEvents(ctx context.Context, req mcp.CallToolReq sort.Strings(r.Emitters) rows = append(rows, r) } + // Scope filter: keep a row iff its string node (subject) is visible to + // the current request, and prune its emitter (actor) list to visible + // emitters. Emits is an edge count (not a node ID) so it is left + // intact; total recomputes below. No-op for an unbound request. + if s.scopeFiltersActive(ctx) { + kept := make([]*logRow, 0, len(rows)) + for _, r := range rows { + if !s.analyzeNodeVisible(ctx, s.graph.GetNode(r.ID)) { + continue + } + emitters := make([]string, 0, len(r.Emitters)) + for _, em := range r.Emitters { + if s.analyzeNodeVisible(ctx, s.graph.GetNode(em)) { + emitters = append(emitters, em) + } + } + r.Emitters = emitters + kept = append(kept, r) + } + rows = kept + } sort.Slice(rows, func(i, j int) bool { if rows[i].Emits != rows[j].Emits { return rows[i].Emits > rows[j].Emits @@ -250,6 +271,20 @@ func (s *Server) handleAnalyzeSQLCallSites(ctx context.Context, req mcp.CallTool sort.Strings(r.Tables) rows = append(rows, r) } + // Scope filter: keep only call sites whose calling symbol is visible + // to the current request. Tables are names (not node IDs) so they need + // no pruning. total/truncated recompute below. No-op for an unbound + // request. (The RebuildTablesFromStringRegistry call above is left + // untouched — it is an idempotent graph mutation, not a row source.) + if s.scopeFiltersActive(ctx) { + kept := make([]*sqlCallSite, 0, len(rows)) + for _, r := range rows { + if s.analyzeNodeVisible(ctx, s.graph.GetNode(r.Symbol)) { + kept = append(kept, r) + } + } + rows = kept + } sort.Slice(rows, func(i, j int) bool { if rows[i].Queries != rows[j].Queries { return rows[i].Queries > rows[j].Queries diff --git a/internal/mcp/tools_analyze_string_emitters.go b/internal/mcp/tools_analyze_string_emitters.go index 6b51087d..447e5497 100644 --- a/internal/mcp/tools_analyze_string_emitters.go +++ b/internal/mcp/tools_analyze_string_emitters.go @@ -63,6 +63,27 @@ func (s *Server) handleAnalyzeStringEmitters(ctx context.Context, req mcp.CallTo sort.Strings(r.Emitters) rows = append(rows, r) } + // Scope filter: keep a row iff its string node (subject) is visible to + // the current request, and prune its emitter (actor) list to visible + // emitters. Emits is an edge count (not a node ID) so it is left + // intact; total recomputes below. No-op for an unbound request. + if s.scopeFiltersActive(ctx) { + kept := make([]*stringRow, 0, len(rows)) + for _, r := range rows { + if !s.analyzeNodeVisible(ctx, s.graph.GetNode(r.ID)) { + continue + } + emitters := make([]string, 0, len(r.Emitters)) + for _, em := range r.Emitters { + if s.analyzeNodeVisible(ctx, s.graph.GetNode(em)) { + emitters = append(emitters, em) + } + } + r.Emitters = emitters + kept = append(kept, r) + } + rows = kept + } sort.Slice(rows, func(i, j int) bool { if rows[i].Emits != rows[j].Emits { return rows[i].Emits > rows[j].Emits diff --git a/internal/mcp/tools_analyze_temporal.go b/internal/mcp/tools_analyze_temporal.go index 2a63ce51..a69b3f19 100644 --- a/internal/mcp/tools_analyze_temporal.go +++ b/internal/mcp/tools_analyze_temporal.go @@ -15,6 +15,39 @@ import ( // starts. Exposed as `analyze kind=temporal_orphans`. func (s *Server) handleAnalyzeTemporalOrphans(ctx context.Context, req mcp.CallToolRequest) (*mcp.CallToolResult, error) { rep := resolver.DetectTemporalOrphans(s.graph) + if s.scopeFiltersActive(ctx) { + // Narrow each integrity-gap list to entries whose subject node is + // visible to the request (workspace ceiling + optional repo + // allow-set); the inline response below then recomputes totals + // from the filtered lengths. TemporalOrphan.From and the + // OrphanActivity / OrphanWorkflow strings are the node IDs; Name + // is a Temporal contract name (not a node), so it never leaks. + // Filtered at the MCP layer only — the resolver stays + // scope-agnostic. Unbound requests skip this branch — a no-op. + keepOrphans := func(in []resolver.TemporalOrphan) []resolver.TemporalOrphan { + out := make([]resolver.TemporalOrphan, 0, len(in)) + for _, o := range in { + if s.analyzeNodeVisible(ctx, s.graph.GetNode(o.From)) { + out = append(out, o) + } + } + return out + } + keepIDs := func(in []string) []string { + out := make([]string, 0, len(in)) + for _, id := range in { + if s.analyzeNodeVisible(ctx, s.graph.GetNode(id)) { + out = append(out, id) + } + } + return out + } + rep.BrokenDispatch = keepOrphans(rep.BrokenDispatch) + rep.SignalNoHandler = keepOrphans(rep.SignalNoHandler) + rep.QueryNoHandler = keepOrphans(rep.QueryNoHandler) + rep.OrphanActivity = keepIDs(rep.OrphanActivity) + rep.OrphanWorkflow = keepIDs(rep.OrphanWorkflow) + } return s.respondJSONOrTOON(ctx, req, map[string]any{ "broken_dispatch": rep.BrokenDispatch, "signal_no_handler": rep.SignalNoHandler, diff --git a/internal/mcp/tools_analyze_tests.go b/internal/mcp/tools_analyze_tests.go index fbce7c63..786788d8 100644 --- a/internal/mcp/tools_analyze_tests.go +++ b/internal/mcp/tools_analyze_tests.go @@ -120,6 +120,50 @@ func (s *Server) handleAnalyzeTestsAsEdges(ctx context.Context, req mcp.CallTool }) } + // Scope narrowing: keep a row iff its primary symbol is visible to + // the request (workspace ceiling + optional repo allow-set), prune + // its related peers to the visible set, recompute Count, and rebuild + // the summary from the in-scope rows. Unbound requests skip this + // branch — a byte-for-byte no-op. + testEdges := edgeCount + testedSymbols := len(testsBySymbol) + testFunctions := len(symbolsByTest) + if s.scopeFiltersActive(ctx) { + kept := make([]testEdgeRow, 0, len(rows)) + for _, r := range rows { + if !s.analyzeNodeVisible(ctx, nodeByID[r.ID]) { + continue + } + related := make([]testEdgeRef, 0, len(r.Related)) + for _, ref := range r.Related { + if s.analyzeNodeVisible(ctx, nodeByID[ref.ID]) { + related = append(related, ref) + } + } + r.Related = related + r.Count = len(related) + kept = append(kept, r) + } + rows = kept + + edges := 0 + relatedSet := make(map[string]struct{}) + for _, r := range rows { + edges += r.Count + for _, ref := range r.Related { + relatedSet[ref.ID] = struct{}{} + } + } + testEdges = edges + if groupBy == "symbol" { + testedSymbols = len(rows) + testFunctions = len(relatedSet) + } else { + testFunctions = len(rows) + testedSymbols = len(relatedSet) + } + } + // Most-covered first — the symbols with the deepest test backing, // or the tests exercising the most code. sort.Slice(rows, func(i, j int) bool { @@ -170,9 +214,9 @@ func (s *Server) handleAnalyzeTestsAsEdges(ctx context.Context, req mcp.CallTool "rows": projected, "total": total, "summary": map[string]any{ - "test_edges": edgeCount, - "tested_symbols": len(testsBySymbol), - "test_functions": len(symbolsByTest), + "test_edges": testEdges, + "tested_symbols": testedSymbols, + "test_functions": testFunctions, }, "truncated": truncated, } diff --git a/internal/mcp/tools_closure.go b/internal/mcp/tools_closure.go index 272e3d43..a842f1b1 100644 --- a/internal/mcp/tools_closure.go +++ b/internal/mcp/tools_closure.go @@ -40,6 +40,8 @@ func (s *Server) registerContextClosureTool() { mcp.WithString("repo", mcp.Description("Filter results to a specific repository prefix.")), mcp.WithString("project", mcp.Description("Filter results to repositories in a specific project.")), mcp.WithString("ref", mcp.Description("Filter results to repositories with a specific reference tag.")), + mcp.WithString("workspace", mcp.Description("Workspace override. In workspace-bound sessions this must match the active workspace.")), + mcp.WithString("scope", mcp.Description("Saved scope name. Ignored for git diff scopes; explicit repo/project/ref filters take precedence.")), ), s.handleContextClosure, ) @@ -54,7 +56,10 @@ func (s *Server) handleContextClosure(ctx context.Context, req mcp.CallToolReque return mcp.NewToolResultError("graph engine unavailable"), nil } - scopeWS, scopeProj := s.scopeFromRequest(ctx, &req) + resolved, errResult := s.resolveScope(ctx, req, IntentReach) + if errResult != nil { + return errResult, nil + } // Resolve the seed set: every symbol defined in each seed file, // plus every explicit seed symbol ID. Files are resolved through @@ -109,18 +114,14 @@ func (s *Server) handleContextClosure(ctx context.Context, req mcp.CallToolReque EdgeKinds: edgeKinds, MaxDepth: req.GetInt("max_depth", 0), MaxNodes: req.GetInt("max_nodes", 0), - WorkspaceID: scopeWS, - ProjectID: scopeProj, + WorkspaceID: resolved.WorkspaceID, + ProjectID: resolved.ProjectID, }) // Apply the repo filter (defence in depth alongside the scope the // closure already enforced) before ranking, so the manifest and the // summary agree on the member set. - allowed, filterErr := s.resolveRepoFilter(ctx, req) - if filterErr != nil { - return mcp.NewToolResultError(filterErr.Error()), nil - } - members := filterClosureNodes(closure.Nodes, allowed) + members := filterClosureNodes(closure.Nodes, resolved.RepoAllow) rankMode := strings.ToLower(strings.TrimSpace(req.GetString("rank", "distance"))) if rankMode != "proximity" { @@ -183,7 +184,7 @@ func (s *Server) handleContextClosure(ctx context.Context, req mcp.CallToolReque result["missing_symbols"] = missingSymbols } - return s.respondJSONOrTOON(ctx, req, result) + return s.respondScopedJSONOrTOON(ctx, req, result, resolved) } // closureProximity returns a per-node seeded random-walk-with-restart diff --git a/internal/mcp/tools_core.go b/internal/mcp/tools_core.go index af7edb94..257dcb47 100644 --- a/internal/mcp/tools_core.go +++ b/internal/mcp/tools_core.go @@ -362,23 +362,17 @@ var gitDiffScopes = map[string]bool{"unstaged": true, "staged": true, "all": tru // the active-project default applied. A nil result there still means // "no filter — all repos". func (s *Server) resolveRepoFilter(ctx context.Context, req mcp.CallToolRequest) (map[string]bool, error) { - repo := req.GetString("repo", "") - // The selector may be a filesystem path (the CLI defaults to the - // caller's working directory) — normalize to the tracked prefix so the - // filter matches what the workspace knows the repo as. - if p := s.resolveRepoPrefix(repo); p != "" { - repo = p - } - project := req.GetString("project", "") - ref := req.GetString("ref", "") - workspaceArg := req.GetString("workspace", "") - - // A named saved-scope supplies the repo allow-set when no explicit - // repo/project/ref narrows the call (see scopes.go). The review-family - // tools overload `scope` for git diff selection — those reserved values - // are never saved-scope names, otherwise every repo-less review call - // would fail with "unknown scope". - scopeArg := req.GetString("scope", "") + repo := strings.TrimSpace(req.GetString("repo", "")) + if repo != "*" { + if p := s.resolveRepoPrefix(repo); p != "" { + repo = p + } + } + project := strings.TrimSpace(req.GetString("project", "")) + ref := strings.TrimSpace(req.GetString("ref", "")) + workspaceArg := strings.TrimSpace(req.GetString("workspace", "")) + + scopeArg := strings.TrimSpace(req.GetString("scope", "")) if gitDiffScopes[scopeArg] { scopeArg = "" } @@ -394,74 +388,54 @@ func (s *Server) resolveRepoFilter(ctx context.Context, req mcp.CallToolRequest) } } - sessWS, _, bound := s.sessionScope(ctx) - if !bound { - // Unbound — legacy behaviour, incl. the active-project default. - if scopeRepos != nil { - return scopeRepos, nil + if sessWS, _, bound := s.sessionScope(ctx); bound { + if workspaceArg != "" && workspaceArg != sessWS { + return nil, fmt.Errorf( + "workspace %q is outside the active workspace %q; cross-workspace queries are not permitted", + workspaceArg, sessWS) } - return s.resolveRepoFilterArgs(repo, project, ref, true) - } - - // A `workspace` arg may only name the session's own workspace. Any - // other value is a cross-workspace escape attempt — reject it - // outright rather than silently honouring the boundary and - // returning a confusing empty result. - if workspaceArg != "" && workspaceArg != sessWS { - return nil, fmt.Errorf( - "workspace %q is outside the active workspace %q; cross-workspace queries are not permitted", - workspaceArg, sessWS) - } - - wsRepos := map[string]bool{} - if s.multiIndexer != nil { - wsRepos = s.multiIndexer.ReposInWorkspace(sessWS) - } - - // A named scope, intersected with the workspace so it can only ever - // narrow — a scope is a convenience, never a clamp escape. - if scopeRepos != nil { - intersected := make(map[string]bool) - for p := range scopeRepos { - if wsRepos[p] { - intersected[p] = true + wsRepos := map[string]bool{} + if s.multiIndexer != nil { + wsRepos = s.multiIndexer.ReposInWorkspace(sessWS) + } + if scopeRepos != nil { + intersected := intersectRepoSets(scopeRepos, wsRepos) + if len(intersected) == 0 { + return nil, fmt.Errorf( + "saved scope %q resolves to nothing inside the active workspace %q", + scopeArg, sessWS) } + return intersected, nil + } + if repo == "*" { + repo = "" + } + if repo == "" && project == "" && ref == "" { + return wsRepos, nil + } + narrowed, err := s.resolveRepoFilterArgs(repo, project, ref, false) + if err != nil { + return nil, err + } + if narrowed == nil { + return wsRepos, nil } + intersected := intersectRepoSets(narrowed, wsRepos) if len(intersected) == 0 { return nil, fmt.Errorf( - "saved scope %q resolves to nothing inside the active workspace %q", - scopeArg, sessWS) + "repo/project/ref filter resolves to nothing inside the active workspace %q; cross-workspace queries are not permitted", + sessWS) } return intersected, nil } - // No explicit narrowing — the allow-set is the whole workspace. - if repo == "" && project == "" && ref == "" { - return wsRepos, nil - } - - // Explicit narrowing: resolve the args, then intersect with the - // workspace so a repo/project/ref arg can never escape it. - narrowed, err := s.resolveRepoFilterArgs(repo, project, ref, false) - if err != nil { - return nil, err - } - if narrowed == nil { - // Args resolved to "all" — clamp to the workspace. - return wsRepos, nil - } - intersected := make(map[string]bool) - for p := range narrowed { - if wsRepos[p] { - intersected[p] = true - } + if scopeRepos != nil { + return scopeRepos, nil } - if len(intersected) == 0 { - return nil, fmt.Errorf( - "repo/project/ref filter resolves to nothing inside the active workspace %q; cross-workspace queries are not permitted", - sessWS) + if repo == "*" { + repo = "" } - return intersected, nil + return s.resolveRepoFilterArgs(repo, project, ref, true) } // resolveRepoFilterArgs folds explicit repo/project/ref args into a @@ -553,6 +527,94 @@ func filterNodes(nodes []*graph.Node, allowed map[string]bool) []*graph.Node { return out } +func filterNodesByResolvedScope(nodes []*graph.Node, resolved ResolvedScope) []*graph.Node { + if !resolvedScopeHasFilter(resolved) { + return nodes + } + opts := queryOptionsForResolvedScope(resolved) + out := make([]*graph.Node, 0, len(nodes)) + for _, n := range nodes { + if n != nil && opts.ScopeAllows(n) { + out = append(out, n) + } + } + return out +} + +func queryOptionsForResolvedScope(resolved ResolvedScope) query.QueryOptions { + return query.QueryOptions{ + WorkspaceID: resolved.WorkspaceID, + ProjectID: resolved.ProjectID, + RepoAllow: resolved.RepoAllow, + } +} + +func resolvedScopeHasFilter(resolved ResolvedScope) bool { + return resolved.WorkspaceID != "" || resolved.ProjectID != "" || len(resolved.RepoAllow) > 0 +} + +func resolvedScopeAllowsNode(resolved ResolvedScope, n *graph.Node) bool { + if n == nil { + return false + } + return queryOptionsForResolvedScope(resolved).ScopeAllows(n) +} + +func filterSubGraphByResolvedScope(sg *query.SubGraph, resolved ResolvedScope) *query.SubGraph { + if sg == nil || !resolvedScopeHasFilter(resolved) { + return sg + } + opts := queryOptionsForResolvedScope(resolved) + nodeIDs := make(map[string]bool, len(sg.Nodes)) + nodes := make([]*graph.Node, 0, len(sg.Nodes)) + for _, n := range sg.Nodes { + if n != nil && opts.ScopeAllows(n) { + nodes = append(nodes, n) + nodeIDs[n.ID] = true + } + } + edges := make([]*graph.Edge, 0, len(sg.Edges)) + for _, e := range sg.Edges { + if e != nil && nodeIDs[e.From] && nodeIDs[e.To] { + edges = append(edges, e) + } + } + out := *sg + out.Nodes = nodes + out.Edges = edges + out.TotalNodes = len(nodes) + out.TotalEdges = len(edges) + if sg.CallerNotes != nil { + out.CallerNotes = make(map[string]*graph.ConcurrencyAnnotation, len(sg.CallerNotes)) + for id, ann := range sg.CallerNotes { + if nodeIDs[id] { + out.CallerNotes[id] = ann + } + } + if len(out.CallerNotes) == 0 { + out.CallerNotes = nil + } + } + return &out +} + +func filterEdgesByResolvedScope(eng *query.Engine, edges []*graph.Edge, resolved ResolvedScope) []*graph.Edge { + if eng == nil || !resolvedScopeHasFilter(resolved) { + return edges + } + out := make([]*graph.Edge, 0, len(edges)) + for _, e := range edges { + if e == nil { + continue + } + if resolvedScopeAllowsNode(resolved, eng.GetSymbol(e.From)) && + resolvedScopeAllowsNode(resolved, eng.GetSymbol(e.To)) { + out = append(out, e) + } + } + return out +} + // filterNodesByKind keeps only nodes whose Kind is in the comma- // separated list. Empty / unknown kinds in the input are ignored // (treated as "no constraint of this name") so a typo is graceful @@ -821,6 +883,8 @@ func (s *Server) registerCoreTools() { mcp.WithString("repo", mcp.Description("Filter results to a specific repository prefix")), mcp.WithString("project", mcp.Description("Filter results to repositories in a specific project")), mcp.WithString("ref", mcp.Description("Filter results to repositories with a specific reference tag")), + mcp.WithString("workspace", mcp.Description("Restrict results to the active workspace slug; daemon sessions may only name their own workspace.")), + mcp.WithString("scope", mcp.Description("Name of a saved scope (see save_scope) — restricts results to that scope's repositories. Ignored when an explicit repo / project / ref is also given.")), ), s.handleGetSymbol, ) @@ -839,6 +903,7 @@ func (s *Server) registerCoreTools() { mcp.WithString("repo", mcp.Description("Filter results to a specific repository prefix")), mcp.WithString("project", mcp.Description("Filter results to repositories in a specific project")), mcp.WithString("ref", mcp.Description("Filter results to repositories with a specific reference tag")), + mcp.WithString("workspace", mcp.Description("Restrict results to the active workspace slug; daemon sessions may only name their own workspace.")), mcp.WithString("scope", mcp.Description("Name of a saved scope (see save_scope) — restricts results to that scope's repositories. Ignored when an explicit repo / project / ref is also given.")), mcp.WithString("path", mcp.Description("Restrict results to one or more sub-paths (comma-separated) -- the monorepo-service slice (e.g. \"services/billing,libs/auth\"). Anchored, slash-segment-boundary prefixes relative to the repo root: \"services/billing\" matches services/billing/x.go, not other/services/billingX. Unions with any inline path: clause and a scope's saved paths.")), mcp.WithString("kind", mcp.Description("Filter to one or more node kinds (comma-separated). Standard kinds: function, method, type, interface, variable, constant, field, file, package, import, contract. Coverage kinds: param, closure, enum_member, generic_param, module, table, column, config_key, flag, event, migration, fixture, todo, team, license, release, doc (Markdown prose section).")), @@ -865,6 +930,8 @@ func (s *Server) registerCoreTools() { mcp.WithString("repo", mcp.Description("Filter results to a specific repository prefix")), mcp.WithString("project", mcp.Description("Filter results to repositories in a specific project")), mcp.WithString("ref", mcp.Description("Filter results to repositories with a specific reference tag")), + mcp.WithString("workspace", mcp.Description("Restrict results to the active workspace slug; daemon sessions may only name their own workspace.")), + mcp.WithString("scope", mcp.Description("Name of a saved scope (see save_scope) — restricts results to that scope's repositories. Ignored when an explicit repo / project / ref is also given.")), mcp.WithString("if_none_match", mcp.Description("ETag from a previous response — returns not_modified if content unchanged")), ), s.handleGetFileSummary, @@ -879,6 +946,11 @@ func (s *Server) registerCoreTools() { mcp.WithBoolean("compact", mcp.Description("One-line-per-symbol text output (saves 50-70% tokens)")), mcp.WithString("format", mcp.Description("Output format: json (default), gcx (GCX1 compact wire format), or toon")), mcp.WithNumber("max_bytes", mcp.Description("Cap the marshaled response at this many bytes. The longest list is trimmed; truncation metadata rides on the response. Omit for no cap.")), + mcp.WithString("repo", mcp.Description("Filter results to a specific repository prefix")), + mcp.WithString("project", mcp.Description("Filter results to repositories in a specific project")), + mcp.WithString("ref", mcp.Description("Filter results to repositories with a specific reference tag")), + mcp.WithString("workspace", mcp.Description("Restrict results to the active workspace slug; daemon sessions may only name their own workspace.")), + mcp.WithString("scope", mcp.Description("Name of a saved scope (see save_scope) — restricts results to that scope's repositories. Ignored when an explicit repo / project / ref is also given.")), mcp.WithString("min_tier", mcp.Description(minTierParamDescription)), mcp.WithBoolean("include_speculative", mcp.Description(includeSpeculativeParamDescription)), ), @@ -894,6 +966,11 @@ func (s *Server) registerCoreTools() { mcp.WithBoolean("compact", mcp.Description("One-line-per-symbol text output (saves 50-70% tokens)")), mcp.WithString("format", mcp.Description("Output format: json (default), gcx (GCX1 compact wire format), or toon")), mcp.WithNumber("max_bytes", mcp.Description("Cap the marshaled response at this many bytes. The longest list is trimmed; truncation metadata rides on the response. Omit for no cap.")), + mcp.WithString("repo", mcp.Description("Filter results to a specific repository prefix")), + mcp.WithString("project", mcp.Description("Filter results to repositories in a specific project")), + mcp.WithString("ref", mcp.Description("Filter results to repositories with a specific reference tag")), + mcp.WithString("workspace", mcp.Description("Restrict results to the active workspace slug; daemon sessions may only name their own workspace.")), + mcp.WithString("scope", mcp.Description("Name of a saved scope (see save_scope) — restricts results to that scope's repositories. Ignored when an explicit repo / project / ref is also given.")), mcp.WithString("min_tier", mcp.Description(minTierParamDescription)), mcp.WithBoolean("include_speculative", mcp.Description(includeSpeculativeParamDescription)), ), @@ -913,6 +990,8 @@ func (s *Server) registerCoreTools() { mcp.WithString("repo", mcp.Description("Filter results to a specific repository prefix")), mcp.WithString("project", mcp.Description("Filter results to repositories in a specific project")), mcp.WithString("ref", mcp.Description("Filter results to repositories with a specific reference tag")), + mcp.WithString("workspace", mcp.Description("Restrict results to the active workspace slug; daemon sessions may only name their own workspace.")), + mcp.WithString("scope", mcp.Description("Name of a saved scope (see save_scope) — restricts results to that scope's repositories. Ignored when an explicit repo / project / ref is also given.")), mcp.WithString("min_tier", mcp.Description(minTierParamDescription)), mcp.WithBoolean("include_speculative", mcp.Description(includeSpeculativeParamDescription)), ), @@ -928,6 +1007,11 @@ func (s *Server) registerCoreTools() { mcp.WithBoolean("compact", mcp.Description("One-line-per-symbol text output (saves 50-70% tokens)")), mcp.WithString("format", mcp.Description("Output format: json (default), gcx (GCX1 compact wire format), or toon")), mcp.WithNumber("max_bytes", mcp.Description("Cap the marshaled response at this many bytes. The longest list is trimmed; truncation metadata rides on the response. Omit for no cap.")), + mcp.WithString("repo", mcp.Description("Filter results to a specific repository prefix")), + mcp.WithString("project", mcp.Description("Filter results to repositories in a specific project")), + mcp.WithString("ref", mcp.Description("Filter results to repositories with a specific reference tag")), + mcp.WithString("workspace", mcp.Description("Restrict results to the active workspace slug; daemon sessions may only name their own workspace.")), + mcp.WithString("scope", mcp.Description("Name of a saved scope (see save_scope) — restricts results to that scope's repositories. Ignored when an explicit repo / project / ref is also given.")), mcp.WithString("min_tier", mcp.Description(minTierParamDescription)), mcp.WithBoolean("include_speculative", mcp.Description(includeSpeculativeParamDescription)), mcp.WithBoolean("exclude_tests", mcp.Description("Drop callers originating in test functions (set true when you want production callers only)")), @@ -941,6 +1025,11 @@ func (s *Server) registerCoreTools() { mcp.WithString("id", mcp.Required(), mcp.Description("Interface node ID")), mcp.WithString("format", mcp.Description("Output format: json (default), gcx (GCX1 compact wire format), or toon")), mcp.WithNumber("max_bytes", mcp.Description("Cap the marshaled response at this many bytes. The longest list is trimmed; truncation metadata rides on the response. Omit for no cap.")), + mcp.WithString("repo", mcp.Description("Filter results to a specific repository prefix")), + mcp.WithString("project", mcp.Description("Filter results to repositories in a specific project")), + mcp.WithString("ref", mcp.Description("Filter results to repositories with a specific reference tag")), + mcp.WithString("workspace", mcp.Description("Restrict results to the active workspace slug; daemon sessions may only name their own workspace.")), + mcp.WithString("scope", mcp.Description("Name of a saved scope (see save_scope) — restricts results to that scope's repositories. Ignored when an explicit repo / project / ref is also given.")), mcp.WithString("min_tier", mcp.Description(minTierParamDescription)), mcp.WithBoolean("include_speculative", mcp.Description(includeSpeculativeParamDescription)), ), @@ -954,6 +1043,11 @@ func (s *Server) registerCoreTools() { mcp.WithString("direction", mcp.Description("'children' (default — overriders) or 'parents' (overridden methods)")), mcp.WithString("format", mcp.Description("Output format: json (default), gcx (GCX1 compact wire format), or toon")), mcp.WithNumber("max_bytes", mcp.Description("Cap the marshaled response at this many bytes. The longest list is trimmed; truncation metadata rides on the response. Omit for no cap.")), + mcp.WithString("repo", mcp.Description("Filter results to a specific repository prefix")), + mcp.WithString("project", mcp.Description("Filter results to repositories in a specific project")), + mcp.WithString("ref", mcp.Description("Filter results to repositories with a specific reference tag")), + mcp.WithString("workspace", mcp.Description("Restrict results to the active workspace slug; daemon sessions may only name their own workspace.")), + mcp.WithString("scope", mcp.Description("Name of a saved scope (see save_scope) — restricts results to that scope's repositories. Ignored when an explicit repo / project / ref is also given.")), mcp.WithString("min_tier", mcp.Description(minTierParamDescription)), mcp.WithBoolean("include_speculative", mcp.Description(includeSpeculativeParamDescription)), ), @@ -969,6 +1063,11 @@ func (s *Server) registerCoreTools() { mcp.WithBoolean("include_methods", mcp.Description("When true and the seed/visited node is a type or interface, also include its methods (via EdgeMemberOf) and walk the EdgeOverrides chain rooted at each method.")), mcp.WithString("format", mcp.Description("Output format: json (default), gcx (GCX1 compact wire format), or toon")), mcp.WithNumber("max_bytes", mcp.Description("Cap the marshaled response at this many bytes. The longest list is trimmed; truncation metadata rides on the response. Omit for no cap.")), + mcp.WithString("repo", mcp.Description("Filter results to a specific repository prefix")), + mcp.WithString("project", mcp.Description("Filter results to repositories in a specific project")), + mcp.WithString("ref", mcp.Description("Filter results to repositories with a specific reference tag")), + mcp.WithString("workspace", mcp.Description("Restrict results to the active workspace slug; daemon sessions may only name their own workspace.")), + mcp.WithString("scope", mcp.Description("Name of a saved scope (see save_scope) — restricts results to that scope's repositories. Ignored when an explicit repo / project / ref is also given.")), mcp.WithString("min_tier", mcp.Description(minTierParamDescription)), mcp.WithBoolean("include_speculative", mcp.Description(includeSpeculativeParamDescription)), ), @@ -987,6 +1086,8 @@ func (s *Server) registerCoreTools() { mcp.WithString("repo", mcp.Description("Filter results to a specific repository prefix")), mcp.WithString("project", mcp.Description("Filter results to repositories in a specific project")), mcp.WithString("ref", mcp.Description("Filter results to repositories with a specific reference tag")), + mcp.WithString("workspace", mcp.Description("Restrict results to the active workspace slug; daemon sessions may only name their own workspace.")), + mcp.WithString("scope", mcp.Description("Name of a saved scope (see save_scope) — restricts results to that scope's repositories. Ignored when an explicit repo / project / ref is also given.")), mcp.WithString("min_tier", mcp.Description(minTierParamDescription)), mcp.WithBoolean("include_speculative", mcp.Description(includeSpeculativeParamDescription)), mcp.WithBoolean("exclude_tests", mcp.Description("Drop references originating in test functions (set true to see only production usages)")), @@ -1007,6 +1108,11 @@ func (s *Server) registerCoreTools() { mcp.WithBoolean("compact", mcp.Description("One-line-per-symbol text output (saves 50-70% tokens)")), mcp.WithString("format", mcp.Description("Output format: json (default), gcx (GCX1 compact wire format), or toon")), mcp.WithNumber("max_bytes", mcp.Description("Cap the marshaled response at this many bytes. The longest list is trimmed; truncation metadata rides on the response. Omit for no cap.")), + mcp.WithString("repo", mcp.Description("Filter results to a specific repository prefix")), + mcp.WithString("project", mcp.Description("Filter results to repositories in a specific project")), + mcp.WithString("ref", mcp.Description("Filter results to repositories with a specific reference tag")), + mcp.WithString("workspace", mcp.Description("Restrict results to the active workspace slug; daemon sessions may only name their own workspace.")), + mcp.WithString("scope", mcp.Description("Name of a saved scope (see save_scope) — restricts results to that scope's repositories. Ignored when an explicit repo / project / ref is also given.")), ), s.handleGetCluster, ) @@ -1199,12 +1305,11 @@ func (s *Server) handleGetSymbol(ctx context.Context, req mcp.CallToolRequest) ( return symbolNotFoundGuidance(id), nil } - // Apply repo/project/ref filter. - allowed, filterErr := s.resolveRepoFilter(ctx, req) - if filterErr != nil { - return mcp.NewToolResultError(filterErr.Error()), nil + resolved, errResult := s.resolveScope(ctx, req, toolIntentForName(req.Params.Name)) + if errResult != nil { + return errResult, nil } - if allowed != nil && node.RepoPrefix != "" && !allowed[node.RepoPrefix] { + if !resolvedScopeAllowsNode(resolved, node) { return symbolNotFoundGuidance(id), nil } @@ -1212,17 +1317,18 @@ func (s *Server) handleGetSymbol(ctx context.Context, req mcp.CallToolRequest) ( detail := req.GetString("detail", "brief") if detail == "brief" { - return s.respondJSONOrTOON(ctx, req, s.withAbsPath(node).Brief()) + return s.respondScopedJSONOrTOON(ctx, req, s.withAbsPath(node).Brief(), resolved) } // Full: include node + direct edges. - out := s.engineFor(ctx).GetOutEdges(node.ID) - in := s.engineFor(ctx).GetInEdges(node.ID) - return s.respondJSONOrTOON(ctx, req, map[string]any{ + eng := s.engineFor(ctx) + out := filterEdgesByResolvedScope(eng, eng.GetOutEdges(node.ID), resolved) + in := filterEdgesByResolvedScope(eng, eng.GetInEdges(node.ID), resolved) + return s.respondScopedJSONOrTOON(ctx, req, map[string]any{ "node": s.withAbsPath(node), "out_edges": out, "in_edges": in, - }) + }, resolved) } func (s *Server) handleSearchSymbols(ctx context.Context, req mcp.CallToolRequest) (*mcp.CallToolResult, error) { @@ -1243,6 +1349,7 @@ func (s *Server) handleSearchSymbols(ctx context.Context, req mcp.CallToolReques // explicit kind / repo / project arguments. fq := parseFieldQuery(q) q = fq.Text + scopeReq := requestWithInlineScopeClauses(req, fq) // Apply server-default scope merged with caller args. `workspace` // / `project` args win per-field; empty falls through to the @@ -1250,12 +1357,11 @@ func (s *Server) handleSearchSymbols(ctx context.Context, req mcp.CallToolReques // post-filters, so ranking is preserved while results stay inside // the boundary. With pagination we over-fetch to (offset + limit // + 10) so the post-filter slack still leaves a full page. - workspaceArg := req.GetString("workspace", "") - projectArg := req.GetString("project", "") - if projectArg == "" { - projectArg = fq.Project + resolved, errResult := s.resolveScope(ctx, scopeReq, IntentLocate) + if errResult != nil { + return errResult, nil } - scopeWS, scopeProj := s.resolveQueryScope(ctx, workspaceArg, projectArg) + scopeWS, scopeProj := resolved.WorkspaceID, resolved.ProjectID // Per-phase timing for the search hot path. The struct is populated // across the engine boundary (BM25 backend call wall-clock attributes // to BM25*MS in fetchAndMergeBM25Timed; GetNodes / FindName / Fallback @@ -1264,7 +1370,7 @@ func (s *Server) handleSearchSymbols(ctx context.Context, req mcp.CallToolReques // debug logging pay zero overhead. timings := &query.SearchTimings{} phaseStart := time.Now() - scope := query.QueryOptions{WorkspaceID: scopeWS, ProjectID: scopeProj, SearchTimings: timings} + scope := query.QueryOptions{WorkspaceID: scopeWS, ProjectID: scopeProj, RepoAllow: resolved.RepoAllow, SearchTimings: timings} // Keyword-soup defense: a degenerate boolean / OR-list query // ("A OR B OR 'no access'") defeats ordinary retrieval. Detect it @@ -1438,11 +1544,7 @@ func (s *Server) handleSearchSymbols(ctx context.Context, req mcp.CallToolReques candsAfterGather := len(nodes) mergedCount := len(nodes) // pre-filter; comparable to primaryCount - // Apply repo/project/ref filter. - allowed, filterErr := s.resolveRepoFilter(ctx, req) - if filterErr != nil { - return mcp.NewToolResultError(filterErr.Error()), nil - } + allowed := resolved.RepoAllow // kind filter so callers can scope to a single new node kind // (todo, license, team, module, …). Comma-separated list — // case-insensitive — applied post-search so BM25 ranking is @@ -1684,11 +1786,12 @@ func (s *Server) handleSearchSymbols(ctx context.Context, req mcp.CallToolReques } if isCompact(req) { - return mcp.NewToolResultText(compactNodes(page)), nil + return decorateResultWithScope(mcp.NewToolResultText(compactNodes(page)), resolved), nil } if s.isGCX(ctx, req) { - return s.gcxResponseWithBudget(req)(encodeSearchSymbols(page, total, len(page))) + res, err := s.gcxResponseWithBudget(req)(encodeSearchSymbols(page, total, len(page))) + return withScopeResult(res, err, resolved) } if s.isTOON(ctx, req) { @@ -1699,7 +1802,7 @@ func (s *Server) handleSearchSymbols(ctx context.Context, req mcp.CallToolReques } data, err := toon.Marshal(result) if err == nil { - return mcp.NewToolResultText(string(data)), nil + return decorateResultWithScope(mcp.NewToolResultText(string(data)), resolved), nil } } @@ -1820,7 +1923,7 @@ func (s *Server) handleSearchSymbols(ctx context.Context, req mcp.CallToolReques ) } - return s.respondJSONOrTOON(ctx, req, resp) + return s.respondScopedJSONOrTOON(ctx, req, resp, resolved) } // encodeRerankBreakdown converts a candidate slice into a JSON-ready @@ -2001,20 +2104,29 @@ func (s *Server) handleGetDependencies(ctx context.Context, req mcp.CallToolRequ return mcp.NewToolResultError("id is required"), nil } minTier := req.GetString("min_tier", "") - scopeWS, scopeProj := s.scopeFromRequest(ctx, &req) + resolved, errResult := s.resolveScope(ctx, req, IntentReach) + if errResult != nil { + return errResult, nil + } + eng := s.engineFor(ctx) + if node := eng.GetSymbol(id); node != nil && !resolvedScopeAllowsNode(resolved, node) { + return symbolNotFoundGuidance(id), nil + } opts := query.QueryOptions{ Depth: req.GetInt("depth", 2), Limit: req.GetInt("limit", 50), Detail: "brief", MinTier: minTier, - WorkspaceID: scopeWS, - ProjectID: scopeProj, + WorkspaceID: resolved.WorkspaceID, + ProjectID: resolved.ProjectID, + RepoAllow: resolved.RepoAllow, } - sg := s.engineFor(ctx).GetDependencies(id, opts) + sg := eng.GetDependencies(id, opts) + sg = filterSubGraphByResolvedScope(sg, resolved) sg.FilterByMinTier(minTier) sg.FilterSpeculative(req.GetBool("include_speculative", false)) enrichSubGraphEdges(sg) - return s.returnSubGraph(ctx, req, sg) + return s.returnScopedSubGraph(ctx, req, sg, resolved) } func (s *Server) handleGetDependents(ctx context.Context, req mcp.CallToolRequest) (*mcp.CallToolResult, error) { @@ -2023,20 +2135,29 @@ func (s *Server) handleGetDependents(ctx context.Context, req mcp.CallToolReques return mcp.NewToolResultError("id is required"), nil } minTier := req.GetString("min_tier", "") - scopeWS, scopeProj := s.scopeFromRequest(ctx, &req) + resolved, errResult := s.resolveScope(ctx, req, IntentReach) + if errResult != nil { + return errResult, nil + } + eng := s.engineFor(ctx) + if node := eng.GetSymbol(id); node != nil && !resolvedScopeAllowsNode(resolved, node) { + return symbolNotFoundGuidance(id), nil + } opts := query.QueryOptions{ Depth: req.GetInt("depth", 3), Limit: req.GetInt("limit", 50), Detail: "brief", MinTier: minTier, - WorkspaceID: scopeWS, - ProjectID: scopeProj, + WorkspaceID: resolved.WorkspaceID, + ProjectID: resolved.ProjectID, + RepoAllow: resolved.RepoAllow, } - sg := s.engineFor(ctx).GetDependents(id, opts) + sg := eng.GetDependents(id, opts) + sg = filterSubGraphByResolvedScope(sg, resolved) sg.FilterByMinTier(minTier) sg.FilterSpeculative(req.GetBool("include_speculative", false)) enrichSubGraphEdges(sg) - return s.returnSubGraph(ctx, req, sg) + return s.returnScopedSubGraph(ctx, req, sg, resolved) } func (s *Server) handleGetCallChain(ctx context.Context, req mcp.CallToolRequest) (*mcp.CallToolResult, error) { @@ -2045,29 +2166,32 @@ func (s *Server) handleGetCallChain(ctx context.Context, req mcp.CallToolRequest return mcp.NewToolResultError("id is required"), nil } minTier := req.GetString("min_tier", "") - scopeWS, scopeProj := s.scopeFromRequest(ctx, &req) + resolved, errResult := s.resolveScope(ctx, req, IntentReach) + if errResult != nil { + return errResult, nil + } + eng := s.engineFor(ctx) + if node := eng.GetSymbol(id); node != nil && !resolvedScopeAllowsNode(resolved, node) { + return symbolNotFoundGuidance(id), nil + } opts := query.QueryOptions{ Depth: req.GetInt("depth", 4), Limit: req.GetInt("limit", 50), Detail: "brief", MinTier: minTier, - WorkspaceID: scopeWS, - ProjectID: scopeProj, + WorkspaceID: resolved.WorkspaceID, + ProjectID: resolved.ProjectID, + RepoAllow: resolved.RepoAllow, } s.hydrateProxyTargets(ctx, id) - sg := s.engineFor(ctx).GetCallChain(id, opts) + sg := eng.GetCallChain(id, opts) - // Apply repo/project/ref filter. - allowed, filterErr := s.resolveRepoFilter(ctx, req) - if filterErr != nil { - return mcp.NewToolResultError(filterErr.Error()), nil - } - sg = filterSubGraph(sg, allowed) + sg = filterSubGraphByResolvedScope(sg, resolved) sg.FilterByMinTier(minTier) sg.FilterSpeculative(req.GetBool("include_speculative", false)) enrichSubGraphEdges(sg) annotateProxyFreshness(sg) - return s.returnSubGraph(ctx, req, sg) + return s.returnScopedSubGraph(ctx, req, sg, resolved) } func (s *Server) handleGetCallers(ctx context.Context, req mcp.CallToolRequest) (*mcp.CallToolResult, error) { @@ -2080,22 +2204,31 @@ func (s *Server) handleGetCallers(ctx context.Context, req mcp.CallToolRequest) return s.symbolDisambiguationResult(ctx, req, "get_callers", target, candidates) } minTier := req.GetString("min_tier", "") - scopeWS, scopeProj := s.scopeFromRequest(ctx, &req) + resolved, errResult := s.resolveScope(ctx, req, IntentReach) + if errResult != nil { + return errResult, nil + } + eng := s.engineFor(ctx) + if node := eng.GetSymbol(id); node != nil && !resolvedScopeAllowsNode(resolved, node) { + return symbolNotFoundGuidance(id), nil + } opts := query.QueryOptions{ Depth: req.GetInt("depth", 2), Limit: req.GetInt("limit", 50), Detail: "brief", MinTier: minTier, - WorkspaceID: scopeWS, - ProjectID: scopeProj, + WorkspaceID: resolved.WorkspaceID, + ProjectID: resolved.ProjectID, + RepoAllow: resolved.RepoAllow, ExcludeTests: req.GetBool("exclude_tests", false), } s.hydrateProxyTargets(ctx, id) - sg := s.engineFor(ctx).GetCallers(id, opts) + sg := eng.GetCallers(id, opts) + sg = filterSubGraphByResolvedScope(sg, resolved) sg.FilterByMinTier(minTier) sg.FilterSpeculative(req.GetBool("include_speculative", false)) enrichSubGraphEdges(sg) - annotateCallerConcurrency(s.engineFor(ctx).Reader(), sg, id) + annotateCallerConcurrency(eng.Reader(), sg, id) if len(sg.Edges) == 0 { sg.Caveat = graph.CaveatForZeroEdge(s.graph, id) } @@ -2113,7 +2246,7 @@ func (s *Server) handleGetCallers(ctx context.Context, req mcp.CallToolRequest) } } annotateProxyFreshness(sg) - return s.returnSubGraph(ctx, req, sg) + return s.returnScopedSubGraph(ctx, req, sg, resolved) } // annotateCallerConcurrency attaches a ConcurrencyAnnotation to every @@ -2153,6 +2286,10 @@ func (s *Server) handleFindOverrides(ctx context.Context, req mcp.CallToolReques } direction := req.GetString("direction", "children") minTier := req.GetString("min_tier", "") + resolved, errResult := s.resolveScope(ctx, req, IntentReach) + if errResult != nil { + return errResult, nil + } var nodes []*graph.Node switch direction { case "parents", "overridden": @@ -2164,11 +2301,12 @@ func (s *Server) handleFindOverrides(ctx context.Context, req mcp.CallToolReques // methods don't take QueryOptions, so the boundary is enforced // here. nodes = s.scopedNodeSlice(ctx, nodes) + nodes = filterNodesByResolvedScope(nodes, resolved) nodes = s.withAbsPaths(nodes) if s.isGCX(ctx, req) { sg := &query.SubGraph{Nodes: nodes, TotalNodes: len(nodes)} - return s.returnSubGraph(ctx, req, sg) + return s.returnScopedSubGraph(ctx, req, sg, resolved) } if s.isTOON(ctx, req) { result := struct { @@ -2179,18 +2317,18 @@ func (s *Server) handleFindOverrides(ctx context.Context, req mcp.CallToolReques Total: len(nodes), } if data, err := toon.Marshal(result); err == nil { - return mcp.NewToolResultText(string(data)), nil + return decorateResultWithScope(mcp.NewToolResultText(string(data)), resolved), nil } } results := make([]map[string]any, 0, len(nodes)) for _, n := range nodes { results = append(results, n.Brief()) } - return s.respondJSONOrTOON(ctx, req, map[string]any{ + return s.respondScopedJSONOrTOON(ctx, req, map[string]any{ "overrides": results, "total": len(results), "direction": direction, - }) + }, resolved) } func (s *Server) handleFindImplementations(ctx context.Context, req mcp.CallToolRequest) (*mcp.CallToolResult, error) { @@ -2199,15 +2337,20 @@ func (s *Server) handleFindImplementations(ctx context.Context, req mcp.CallTool return mcp.NewToolResultError("id is required"), nil } minTier := req.GetString("min_tier", "") + resolved, errResult := s.resolveScope(ctx, req, IntentReach) + if errResult != nil { + return errResult, nil + } impls := s.engineFor(ctx).FindImplementationsMinTier(id, minTier) // Confine results to the session's workspace — FindImplementations // doesn't take QueryOptions, so the boundary is enforced here. impls = s.scopedNodeSlice(ctx, impls) + impls = filterNodesByResolvedScope(impls, resolved) impls = s.withAbsPaths(impls) if s.isGCX(ctx, req) { sg := &query.SubGraph{Nodes: impls, TotalNodes: len(impls)} - return s.returnSubGraph(ctx, req, sg) + return s.returnScopedSubGraph(ctx, req, sg, resolved) } if s.isTOON(ctx, req) { @@ -2219,7 +2362,7 @@ func (s *Server) handleFindImplementations(ctx context.Context, req mcp.CallTool Total: len(impls), } if data, err := toon.Marshal(result); err == nil { - return mcp.NewToolResultText(string(data)), nil + return decorateResultWithScope(mcp.NewToolResultText(string(data)), resolved), nil } } @@ -2227,10 +2370,10 @@ func (s *Server) handleFindImplementations(ctx context.Context, req mcp.CallTool for _, n := range impls { results = append(results, n.Brief()) } - return s.respondJSONOrTOON(ctx, req, map[string]any{ + return s.respondScopedJSONOrTOON(ctx, req, map[string]any{ "implementations": results, "total": len(results), - }) + }, resolved) } func (s *Server) handleGetClassHierarchy(ctx context.Context, req mcp.CallToolRequest) (*mcp.CallToolResult, error) { @@ -2249,15 +2392,24 @@ func (s *Server) handleGetClassHierarchy(ctx context.Context, req mcp.CallToolRe includeMethods := req.GetBool("include_methods", false) minTier := req.GetString("min_tier", "") - scopeWS, scopeProj := s.scopeFromRequest(ctx, &req) + resolved, errResult := s.resolveScope(ctx, req, IntentReach) + if errResult != nil { + return errResult, nil + } + eng := s.engineFor(ctx) + if node := eng.GetSymbol(id); node != nil && !resolvedScopeAllowsNode(resolved, node) { + return symbolNotFoundGuidance(id), nil + } opts := query.QueryOptions{ - WorkspaceID: scopeWS, - ProjectID: scopeProj, + WorkspaceID: resolved.WorkspaceID, + ProjectID: resolved.ProjectID, + RepoAllow: resolved.RepoAllow, MinTier: minTier, } - sg := s.engineFor(ctx).ClassHierarchy(id, direction, depth, includeMethods, opts) + sg := eng.ClassHierarchy(id, direction, depth, includeMethods, opts) + sg = filterSubGraphByResolvedScope(sg, resolved) enrichSubGraphEdges(sg) - return s.returnSubGraph(ctx, req, sg) + return s.returnScopedSubGraph(ctx, req, sg, resolved) } func (s *Server) handleFindUsages(ctx context.Context, req mcp.CallToolRequest) (*mcp.CallToolResult, error) { @@ -2270,21 +2422,22 @@ func (s *Server) handleFindUsages(ctx context.Context, req mcp.CallToolRequest) // find_usages on a tuck symbol returns hits only from tuck. // Server-level --workspace + caller `workspace` arg compose the // same way as on search_symbols. - workspaceArg := req.GetString("workspace", "") - projectArg := req.GetString("project", "") - scopeWS, scopeProj := s.resolveQueryScope(ctx, workspaceArg, projectArg) - sg := s.engineFor(ctx).FindUsagesScoped(id, query.QueryOptions{ - WorkspaceID: scopeWS, - ProjectID: scopeProj, + resolved, errResult := s.resolveScope(ctx, req, IntentReach) + if errResult != nil { + return errResult, nil + } + eng := s.engineFor(ctx) + if node := eng.GetSymbol(id); node != nil && !resolvedScopeAllowsNode(resolved, node) { + return symbolNotFoundGuidance(id), nil + } + sg := eng.FindUsagesScoped(id, query.QueryOptions{ + WorkspaceID: resolved.WorkspaceID, + ProjectID: resolved.ProjectID, + RepoAllow: resolved.RepoAllow, ExcludeTests: req.GetBool("exclude_tests", false), }) - // Apply repo/project/ref filter. - allowed, filterErr := s.resolveRepoFilter(ctx, req) - if filterErr != nil { - return mcp.NewToolResultError(filterErr.Error()), nil - } - sg = filterSubGraph(sg, allowed) + sg = filterSubGraphByResolvedScope(sg, resolved) sg.FilterByMinTier(minTier) sg.FilterSpeculative(req.GetBool("include_speculative", false)) enrichSubGraphEdges(sg) @@ -2309,10 +2462,11 @@ func (s *Server) handleFindUsages(ctx context.Context, req mcp.CallToolRequest) // per-file rollup. The flat SubGraph stays the default so // existing consumers are unaffected. if gb := strings.ToLower(strings.TrimSpace(req.GetString("group_by", ""))); gb == "file" { - return s.respondJSONOrTOON(ctx, req, groupUsagesByFile(sg)) + return s.respondScopedJSONOrTOON(ctx, req, groupUsagesByFile(sg), resolved) } if s.isGCX(ctx, req) { - return s.gcxResponseWithBudget(req)(encodeFindUsages(sg, s.graph)) + res, err := s.gcxResponseWithBudget(req)(encodeFindUsages(sg, s.graph)) + return withScopeResult(res, err, resolved) } // Plain JSON gets curated usage rows that promote the resolved // from_type_flavor / from_ui_component to top-level node fields, so @@ -2321,9 +2475,9 @@ func (s *Server) handleFindUsages(ctx context.Context, req mcp.CallToolRequest) format := req.GetString("format", "") if !s.isTOON(ctx, req) && !isCompact(req) && format != "mermaid" && format != "dot" { sg.Nodes = s.withAbsPaths(sg.Nodes) - return s.respondJSONOrTOON(ctx, req, newUsageResponse(sg, s.graph)) + return s.respondScopedJSONOrTOON(ctx, req, newUsageResponse(sg, s.graph), resolved) } - return s.returnSubGraph(ctx, req, sg) + return s.returnScopedSubGraph(ctx, req, sg, resolved) } // usageNode wraps a find_usages node with the resolved from_* fields @@ -2554,17 +2708,21 @@ func (s *Server) handleGetCluster(ctx context.Context, req mcp.CallToolRequest) if err != nil { return mcp.NewToolResultError("id is required"), nil } - scopeWS, scopeProj := s.scopeFromRequest(ctx, &req) + resolved, errResult := s.resolveScope(ctx, req, IntentReach) + if errResult != nil { + return errResult, nil + } opts := query.QueryOptions{ Depth: req.GetInt("radius", 2), Limit: req.GetInt("limit", 50), Detail: "brief", - WorkspaceID: scopeWS, - ProjectID: scopeProj, + WorkspaceID: resolved.WorkspaceID, + ProjectID: resolved.ProjectID, + RepoAllow: resolved.RepoAllow, } sg := s.engineFor(ctx).GetCluster(id, opts) enrichSubGraphEdges(sg) - return s.returnSubGraph(ctx, req, sg) + return s.returnScopedSubGraph(ctx, req, sg, resolved) } func (s *Server) handleGraphStats(ctx context.Context, req mcp.CallToolRequest) (*mcp.CallToolResult, error) { diff --git a/internal/mcp/tools_enhancements.go b/internal/mcp/tools_enhancements.go index 7aa4d2a3..41d0a619 100644 --- a/internal/mcp/tools_enhancements.go +++ b/internal/mcp/tools_enhancements.go @@ -154,7 +154,7 @@ func (s *Server) registerEnhancementTools() { // analyze — unified graph analysis tool (dead_code, hotspots, cycles, would_create_cycle) s.addTool( mcp.NewTool("analyze", - mcp.WithDescription("Unified graph analysis. kind=dead_code: symbols with zero incoming edges. kind=hotspots: high-complexity symbols by fan-in/out. kind=cycles: circular dependency chains. kind=would_create_cycle: check if a new edge would form a cycle (requires from_id, to_id). kind=todos: list KindTodo nodes with optional tag/assignee/ticket/has_assignee filters. kind=blame: run `git blame` against the indexed repo and stamp meta.last_authored on every symbol-level node. kind=coverage: parse a Go cover.out profile (path via `profile` arg) and stamp meta.coverage_pct on every executable symbol. kind=stale_code: list symbols whose meta.last_authored is older than the threshold (requires blame-enriched graph). kind=ownership: group blame metadata by author email — symbol count, files touched, oldest/newest timestamps; supports path_prefix scoping (requires blame-enriched graph). kind=coverage_gaps: list symbols whose meta.coverage_pct falls in [min_pct, max_pct) — sorted ascending so the most undertested code surfaces first (requires coverage-enriched graph). kind=unsafe_patterns: bundled scan for panic-prone / undefined-behaviour primitives across every supported language — Go panic, Rust .unwrap/.expect/panic!/todo!/unimplemented!/unreachable!/assert!/unsafe blocks, Python assert, JS/TS throw — aggregated into one row-per-site response with a per-detector summary. Filters: language, detector, severity, path_prefix, limit, exclude_tests. kind=sast / kind=hygiene: Bandit-parity SAST rule library — 190+ structural rules across Python / Go / JS+TS / Java / Ruby / PHP / Rust, each carrying CWE + OWASP + tags metadata. Per-detector summary + per-CWE rollup. Filters: language, detector, severity, cwe, tag, path_prefix, limit, exclude_tests, kinds_only. kind=review: idiomatic / correctness rule library for Go + Python (nil-deref-prone type assertions, inverted error checks, check-then-act races, query-in-loop N+1) carrying error/warning severity. Undecidable rows (N+1, check-then-act) are refined by a graph-grounding post-pass that drops sites the resolved call / loop metadata refutes. Same row shape + filters as sast. kind=health_score: composite per-symbol health value (0..100) + A..F grade aggregated from coverage_pct, complexity (fan-in/out + community-crossings), recency (last_authored), and session churn. Per-axis breakdown surfaced on every row; missing axes are skipped (not zero-imputed). Always returns a population distribution (mean / median / std_dev / Gini coefficient over inequality of risk + per-grade counts). Pass roll_up='file' or 'repo' for per-file / per-repo averages with min/max bands and per-grade counts. Filters: path_prefix, kinds, grade, min_score, max_score, min_axes, limit, roll_up. Sorted ascending so worst symbols surface first. kind=impact: composite per-symbol change-impact score (0..100, higher = more impactful) plus a risk label, ranking symbols by blast radius from five axes — PageRank centrality, transitive reach, cyclomatic complexity, git co-change coupling, and community span. Per-axis breakdown on every row. Filters: ids, path_prefix, kinds, min_score, max_score, limit. kind=bottlenecks: rank functions by computation-bottleneck risk from index-time per-function metrics (cyclomatic + cognitive complexity, max loop depth) plus interprocedural signals computed over the call graph — transitive_loop_depth (deepest nested-loop chain across calls, a hidden-O(n^k) detector) and recursion (unguarded when recursive with no branching base case). Each row carries a score and human-readable reasons. Filters: path_prefix, kinds, min_score, limit. kind=named: run a named query bundle — a reusable, named selection of structural detectors. Pass name= to fan every selected detector across the codebase and aggregate matches; omit name to list every bundle. Ten bundles ship built-in (sql-injection, command-injection, hardcoded-secrets, weak-crypto, xss, unsafe-deserialization, path-traversal, ssrf, xxe, debug-leftovers); a repo defines its own in .gortex.yaml::queries. Filters: name, language, severity, path_prefix, limit, exclude_tests. kind=tests_as_edges: a first-class view over the EdgeTests test→code edge layer. group_by=symbol (default) lists each tested symbol with the tests covering it; group_by=test inverts it to each test with the symbols it exercises. Always carries a summary of the edge layer's size. Filters: group_by, path_prefix, limit. kind=connectivity_health: a graph-EXTRACTION quality diagnostic — reports isolated nodes (zero edges of ANY kind, structural edges included), leaf / source-only / sink-only counts, effective-vs-nominal graph size and ratio, plus a per-node-kind breakdown and a dead-weight-by-file ranking that localises extraction gaps. Distinct from kind=dead_code: dead_code finds unreachable CODE (zero incoming usage edges, safe to delete); connectivity_health finds mis-EXTRACTED nodes (a normally indexed symbol always carries a structural edge, so an isolated node means the indexer failed, not that the code is unused). Filter: limit (caps dead_weight_by_file). kind=retrieval_log: mine the append-only retrieval query log (every search_symbols / smart_context / find_usages / search_text … call: question, corpus, nodes_returned, duration_ms, zero-result signal) for offline recall tuning — surfaces the top zero-result queries (the highest-signal candidates for synonym expansion or index gaps) plus per-tool latency (p50/p95) and result-size rollups. Filters: limit, tool, zero_only, since, top, include_recent. Gated by GORTEX_QUERY_LOG_DISABLE."), + mcp.WithDescription("Unified graph analysis. kind=dead_code: symbols with zero incoming edges. kind=hotspots: high-complexity symbols by fan-in/out. kind=cycles: circular dependency chains. kind=would_create_cycle: check if a new edge would form a cycle (requires from_id, to_id). kind=todos: list KindTodo nodes with optional tag/assignee/ticket/has_assignee filters. kind=blame: run `git blame` against the indexed repo and stamp meta.last_authored on every symbol-level node. kind=coverage: parse a Go cover.out profile (path via `profile` arg) and stamp meta.coverage_pct on every executable symbol. kind=stale_code: list symbols whose meta.last_authored is older than the threshold (requires blame-enriched graph). kind=ownership: group blame metadata by author email — symbol count, files touched, oldest/newest timestamps; supports path_prefix scoping (requires blame-enriched graph). kind=coverage_gaps: list symbols whose meta.coverage_pct falls in [min_pct, max_pct) — sorted ascending so the most undertested code surfaces first (requires coverage-enriched graph). kind=unsafe_patterns: bundled scan for panic-prone / undefined-behaviour primitives across every supported language — Go panic, Rust .unwrap/.expect/panic!/todo!/unimplemented!/unreachable!/assert!/unsafe blocks, Python assert, JS/TS throw — aggregated into one row-per-site response with a per-detector summary. Filters: language, detector, severity, path_prefix, limit, exclude_tests. kind=sast / kind=hygiene: Bandit-parity SAST rule library — 190+ structural rules across Python / Go / JS+TS / Java / Ruby / PHP / Rust, each carrying CWE + OWASP + tags metadata. Per-detector summary + per-CWE rollup. Filters: language, detector, severity, cwe, tag, path_prefix, limit, exclude_tests, kinds_only. kind=review: idiomatic / correctness rule library for Go + Python (nil-deref-prone type assertions, inverted error checks, check-then-act races, query-in-loop N+1) carrying error/warning severity. Undecidable rows (N+1, check-then-act) are refined by a graph-grounding post-pass that drops sites the resolved call / loop metadata refutes. Same row shape + filters as sast. kind=health_score: composite per-symbol health value (0..100) + A..F grade aggregated from coverage_pct, complexity (fan-in/out + community-crossings), recency (last_authored), and session churn. Per-axis breakdown surfaced on every row; missing axes are skipped (not zero-imputed). Always returns a population distribution (mean / median / std_dev / Gini coefficient over inequality of risk + per-grade counts). Pass roll_up='file' or 'repo' for per-file / per-repo averages with min/max bands and per-grade counts. Filters: path_prefix, kinds, grade, min_score, max_score, min_axes, limit, roll_up. Sorted ascending so worst symbols surface first. kind=impact: composite per-symbol change-impact score (0..100, higher = more impactful) plus a risk label, ranking symbols by blast radius from five axes — PageRank centrality, transitive reach, cyclomatic complexity, git co-change coupling, and community span. Per-axis breakdown on every row. Filters: ids, path_prefix, kinds, min_score, max_score, limit. kind=bottlenecks: rank functions by computation-bottleneck risk from index-time per-function metrics (cyclomatic + cognitive complexity, max loop depth) plus interprocedural signals computed over the call graph — transitive_loop_depth (deepest nested-loop chain across calls, a hidden-O(n^k) detector) and recursion (unguarded when recursive with no branching base case). Each row carries a score and human-readable reasons. Filters: path_prefix, kinds, min_score, limit. kind=named: run a named query bundle — a reusable, named selection of structural detectors. Pass name= to fan every selected detector across the codebase and aggregate matches; omit name to list every bundle. Ten bundles ship built-in (sql-injection, command-injection, hardcoded-secrets, weak-crypto, xss, unsafe-deserialization, path-traversal, ssrf, xxe, debug-leftovers); a repo defines its own in .gortex.yaml::queries. Filters: name, language, severity, path_prefix, limit, exclude_tests. kind=tests_as_edges: a first-class view over the EdgeTests test→code edge layer. group_by=symbol (default) lists each tested symbol with the tests covering it; group_by=test inverts it to each test with the symbols it exercises. Always carries a summary of the edge layer's size. Filters: group_by, path_prefix, limit. kind=connectivity_health: a graph-EXTRACTION quality diagnostic — reports isolated nodes (zero edges of ANY kind, structural edges included), leaf / source-only / sink-only counts, effective-vs-nominal graph size and ratio, plus a per-node-kind breakdown and a dead-weight-by-file ranking that localises extraction gaps. Distinct from kind=dead_code: dead_code finds unreachable CODE (zero incoming usage edges, safe to delete); connectivity_health finds mis-EXTRACTED nodes (a normally indexed symbol always carries a structural edge, so an isolated node means the indexer failed, not that the code is unused). Filter: limit (caps dead_weight_by_file). kind=retrieval_log: mine the append-only retrieval query log (every search_symbols / smart_context / find_usages / search_text … call: question, corpus, nodes_returned, duration_ms, zero-result signal) for offline recall tuning — surfaces the top zero-result queries (the highest-signal candidates for synonym expansion or index gaps) plus per-tool latency (p50/p95) and result-size rollups. Filters: limit, tool, zero_only, since, top, include_recent. Gated by GORTEX_QUERY_LOG_DISABLE.\n\nSCOPE: accepts the uniform repo / project / workspace / scope narrowing (clamped to the session workspace) for graph-node, edge-walk, graph-algorithm, framework, and file/AST-scan kinds (dead_code, hotspots, cycles, health_score, todos, ownership, impact, bottlenecks, k8s_resources, channel_ops, pubsub, routes, models, pagerank, sast, …); the response carries a scope_applied meta field. The remaining long-tail kinds — community detection (clusters, concepts, suggest_boundaries), git/disk-mining (blame, coverage, fixes_history, retrieval_log, temporal_verify), per-id (would_create_cycle, def_use), synthesizers / resolution_outcomes, and sql_rebuild — are workspace-bound but not repo-narrowed in v1 — a scope_note flags this when a narrowing arg is passed."), mcp.WithString("kind", mcp.Required(), mcp.Description(fmt.Sprintf("Analysis kind, one of: %s", analyzeKindsCSV()))), mcp.WithString("framework", mcp.Description("(dbt_models) Filter to one transformation framework — dbt or sqlmesh")), mcp.WithString("materialized", mcp.Description("(dbt_models) Substring match on the model materialization — table, view, incremental, …")), @@ -168,7 +168,10 @@ func (s *Server) registerEnhancementTools() { mcp.WithBoolean("include_linkname_targets", mcp.Description("(dead_code) Include //go:linkname targets — default false; they are linked by name from outside the package")), mcp.WithBoolean("skip_cross_repo_nodes", mcp.Description("(dead_code) Drop nodes whose RepoPrefix is set — useful when cross-repo linking is incomplete")), mcp.WithNumber("threshold", mcp.Description("(hotspots) Complexity score threshold (default: mean + 2σ)")), - mcp.WithString("scope", mcp.Description("(cycles) File path or package prefix to limit scope")), + mcp.WithString("repo", mcp.Description("Narrow this analysis to a single repository prefix, clamped to the session workspace. Applies to graph-node, edge-walk, graph-algorithm, framework, and file/AST-scan kinds (dead_code, hotspots, cycles, health_score, todos, stale_code, ownership, coverage_gaps, impact, bottlenecks, k8s_resources, dbt_models, external_calls, channel_ops, pubsub, routes, models, pagerank, sast, …). Community / git-mining / per-id / synthesizer kinds are workspace-bound but not repo-narrowed in v1. NOTE: for kind=cross_repo this names the repo whose cross-repo boundary dependencies to report (its existing meaning), not a result narrow.")), + mcp.WithString("project", mcp.Description("Narrow this analysis to the repositories in a project, clamped to the session workspace. Applies to graph-node kinds (see `repo`).")), + mcp.WithString("workspace", mcp.Description("Restrict the analysis to the active workspace slug; daemon sessions may only name their own workspace.")), + mcp.WithString("scope", mcp.Description("Name of a saved scope (see save_scope) — its repositories narrow graph-node analyses, clamped to the session workspace. NOTE: for kind=cycles this is instead a file-path / package prefix that limits the cycle search (its existing meaning), not a saved-scope name. Community / git-mining / per-id / synthesizer kinds (clusters, concepts, suggest_boundaries, blame, coverage, fixes_history, retrieval_log, temporal_verify, would_create_cycle, def_use, synthesizers, resolution_outcomes, sql_rebuild) are workspace-bound but not repo-narrowed in v1.")), mcp.WithString("from_id", mcp.Description("(would_create_cycle) Source symbol ID")), mcp.WithString("to_id", mcp.Description("(would_create_cycle) Target symbol ID")), mcp.WithString("profile", mcp.Description("(coverage) Path to a Go cover.out profile, absolute or relative to the indexed repo root")), @@ -184,7 +187,6 @@ func (s *Server) registerEnhancementTools() { mcp.WithString("assignee", mcp.Description("(todos) Filter by exact assignee — case-sensitive")), mcp.WithString("ticket", mcp.Description("(todos) Filter by exact ticket reference — e.g. PROJ-42")), mcp.WithBoolean("has_assignee", mcp.Description("(todos) Keep only TODOs that have an assignee set")), - mcp.WithString("repo", mcp.Description("(cross_repo) Scope to repo-boundary dependencies touching this repository prefix on either side")), mcp.WithString("base_kind", mcp.Description("(cross_repo) Scope to one base relation — calls, implements, or extends")), mcp.WithNumber("limit", mcp.Description("(cross_repo, error_surface, unsafe_patterns) Cap the number of rows returned — default 200")), mcp.WithString("language", mcp.Description("(unsafe_patterns, sast, hygiene) Comma-separated subset of languages to keep — rust, python, javascript, typescript, go, java, ruby, php")), @@ -762,164 +764,278 @@ func (s *Server) handleAnalyze(ctx context.Context, req mcp.CallToolRequest) (*m if err != nil { return mcp.NewToolResultError("kind is required (one of: " + analyzeKindsCSV() + ")"), nil } + + // Uniform repo/project/workspace/scope narrowing, clamped to the + // session workspace. A few kinds own one of the uniform arg names as + // a kind-specific filter, so strip those from the scope-resolution + // view (the handlers still read their own arg off the untouched req): + // - cycles owns `scope` as a path/package prefix — left in, it would + // be looked up as a saved scope and hard-error. + // - cross_repo owns `repo` as a boundary filter it reads directly. + // - images owns `ref` as the image reference (e.g. "ghcr.io/acme") — + // left in, resolveScope mis-reads it as a git ref / scope dimension + // and errors ("configuration manager is not available") or narrows + // to nothing. + resolveReq := req switch kind { - case "dead_code": - return s.handleFindDeadCode(ctx, req) - case "hotspots": - return s.handleFindHotspots(ctx, req) case "cycles": - return s.handleFindCycles(ctx, req) - case "would_create_cycle": - return s.handleWouldCreateCycle(ctx, req) - case "todos": - return s.handleAnalyzeTodos(ctx, req) - case "blame": - return s.handleAnalyzeBlame(ctx, req) - case "coverage": - return s.handleAnalyzeCoverage(ctx, req) - case "stale_code": - return s.handleAnalyzeStaleCode(ctx, req) - case "ownership": - return s.handleAnalyzeOwnership(ctx, req) - case "coverage_gaps": - return s.handleAnalyzeCoverageGaps(ctx, req) - case "stale_flags": - return s.handleAnalyzeStaleFlags(ctx, req) - case "doc_staleness": - return s.handleAnalyzeDocStaleness(ctx, req) - case "releases": - return s.handleAnalyzeReleases(ctx, req) - case "cgo_users": - return s.handleAnalyzeInteropUsers(ctx, req, "uses_cgo", "cgo_users") - case "wasm_users": - return s.handleAnalyzeInteropUsers(ctx, req, "uses_wasm_bindgen", "wasm_users") - case "orphan_tables": - return s.handleAnalyzeOrphanTables(ctx, req) - case "unreferenced_tables": - return s.handleAnalyzeUnreferencedTables(ctx, req) - case "coverage_summary": - return s.handleAnalyzeCoverageSummary(ctx, req) - case "channel_ops": - return s.handleAnalyzeChannelOps(ctx, req) - case "def_use": - return s.handleAnalyzeDefUse(ctx, req) - case "goroutine_spawns": - return s.handleAnalyzeGoroutineSpawns(ctx, req) - case "field_writers": - return s.handleAnalyzeFieldWriters(ctx, req) - case "indirect_mutations": - return s.handleAnalyzeIndirectMutations(ctx, req) - case "speculative": - return s.handleAnalyzeSpeculative(ctx, req) - case "ref_facts": - return s.handleAnalyzeRefFacts(ctx, req) - case "race_writes": - return s.handleAnalyzeRaceWrites(ctx, req) - case "unclosed_channels": - return s.handleAnalyzeUnclosedChannels(ctx, req) - case "unsafe_patterns": - return s.handleAnalyzeUnsafePatterns(ctx, req) - case "sast", "hygiene": - return s.handleAnalyzeSAST(ctx, req, kind) - case "review": - return s.handleAnalyzeSAST(ctx, req, "review") - case "domain": - return s.handleAnalyzeSAST(ctx, req, "domain") - case "health_score": - return s.handleAnalyzeHealthScore(ctx, req) - case "annotation_users": - return s.handleAnalyzeAnnotationUsers(ctx, req) - case "config_readers": - return s.handleAnalyzeConfigReaders(ctx, req) - case "env_var_users": - return s.handleAnalyzeEnvVarUsers(ctx, req) - case "sql_call_sites": - return s.handleAnalyzeSQLCallSites(ctx, req) - case "fixes_history": - return s.handleAnalyzeFixesHistory(ctx, req) - case "edge_audit": - return s.handleAnalyzeEdgeAudit(ctx, req) - case "event_emitters": - return s.handleAnalyzeEventEmitters(ctx, req) - case "pubsub": - return s.handleAnalyzePubsub(ctx, req) - case "string_emitters": - return s.handleAnalyzeStringEmitters(ctx, req) - case "error_surface": - return s.handleAnalyzeErrorSurface(ctx, req) - case "log_events": - return s.handleAnalyzeLogEvents(ctx, req) - case "sql_rebuild": - return s.handleAnalyzeSQLRebuild(ctx, req) - case "external_calls": - return s.handleAnalyzeExternalCalls(ctx, req) - case "synthesizers": - return s.handleAnalyzeSynthesizers(ctx, req) - case "temporal_orphans": - return s.handleAnalyzeTemporalOrphans(ctx, req) - case "resolution_outcomes": - return s.handleAnalyzeResolutionOutcomes(ctx, req) - case "temporal_verify": - return s.handleAnalyzeTemporalVerify(ctx, req) - case "retrieval_log": - return s.handleAnalyzeRetrievalLog(ctx, req) - case "routes": - return s.handleAnalyzeRoutes(ctx, req) - case "route_frameworks": - return s.handleAnalyzeRouteFrameworks(ctx, req) - case "drupal_hooks": - return s.handleAnalyzeDrupalHooks(ctx, req) - case "swiftui_views": - return s.handleAnalyzeSwiftUIViews(ctx, req) - case "uikit_classes": - return s.handleAnalyzeUIKitClasses(ctx, req) - case "models": - return s.handleAnalyzeModels(ctx, req) - case "components": - return s.handleAnalyzeComponents(ctx, req) - case "k8s_resources": - return s.handleAnalyzeK8sResources(ctx, req) - case "images": - return s.handleAnalyzeImages(ctx, req) - case "kustomize": - return s.handleAnalyzeKustomize(ctx, req) + resolveReq = requestWithoutArgs(req, "scope") case "cross_repo": - return s.handleAnalyzeCrossRepo(ctx, req) - case "dbt_models": - return s.handleAnalyzeDbtModels(ctx, req) - case "role": - return s.handleAnalyzeRole(ctx, req) - case "constructors_missing_fields": - return s.handleAnalyzeConstructorsMissingFields(ctx, req) - case "clusters": - return s.handleAnalyzeClusters(ctx, req) - case "suggest_boundaries": - return s.handleSuggestBoundaries(ctx, req) - case "concepts": - return s.handleAnalyzeConcepts(ctx, req) - case "impact": - return s.handleAnalyzeImpactComposite(ctx, req) - case "bottlenecks": - return s.handleAnalyzeBottlenecks(ctx, req) - case "named": - return s.handleAnalyzeNamed(ctx, req) - case "tests_as_edges": - return s.handleAnalyzeTestsAsEdges(ctx, req) - case "connectivity_health": - return s.handleAnalyzeConnectivityHealth(ctx, req) - case "pagerank": - return s.handleAnalyzePageRank(ctx, req) - case "louvain": - return s.handleAnalyzeLouvain(ctx, req) - case "wcc": - return s.handleAnalyzeConnectedComponents(ctx, req, false) - case "scc": - return s.handleAnalyzeConnectedComponents(ctx, req, true) - case "kcore": - return s.handleAnalyzeKCore(ctx, req) - default: - return mcp.NewToolResultError("unknown analyze kind: " + kind + " (expected: " + analyzeKindsCSV() + ")"), nil + resolveReq = requestWithoutArgs(req, "repo") + case "images": + resolveReq = requestWithoutArgs(req, "ref") + } + resolved, errResult := s.resolveScope(ctx, resolveReq, IntentAnalyze) + if errResult != nil { + return errResult, nil + } + ctx = withRepoAllow(ctx, resolved.RepoAllow) + + // The dispatch switch is wrapped in a closure so every analyze + // response is uniformly decorated with scope_applied below. It stays + // inline in handleAnalyze on purpose — the AST anti-drift test + // (TestAnalyzeKinds_MatchesSwitch) requires the kind switch to live + // in this method's body. + res, err := func() (*mcp.CallToolResult, error) { + switch kind { + case "dead_code": + return s.handleFindDeadCode(ctx, req) + case "hotspots": + return s.handleFindHotspots(ctx, req) + case "cycles": + return s.handleFindCycles(ctx, req) + case "would_create_cycle": + return s.handleWouldCreateCycle(ctx, req) + case "todos": + return s.handleAnalyzeTodos(ctx, req) + case "blame": + return s.handleAnalyzeBlame(ctx, req) + case "coverage": + return s.handleAnalyzeCoverage(ctx, req) + case "stale_code": + return s.handleAnalyzeStaleCode(ctx, req) + case "ownership": + return s.handleAnalyzeOwnership(ctx, req) + case "coverage_gaps": + return s.handleAnalyzeCoverageGaps(ctx, req) + case "stale_flags": + return s.handleAnalyzeStaleFlags(ctx, req) + case "doc_staleness": + return s.handleAnalyzeDocStaleness(ctx, req) + case "releases": + return s.handleAnalyzeReleases(ctx, req) + case "cgo_users": + return s.handleAnalyzeInteropUsers(ctx, req, "uses_cgo", "cgo_users") + case "wasm_users": + return s.handleAnalyzeInteropUsers(ctx, req, "uses_wasm_bindgen", "wasm_users") + case "orphan_tables": + return s.handleAnalyzeOrphanTables(ctx, req) + case "unreferenced_tables": + return s.handleAnalyzeUnreferencedTables(ctx, req) + case "coverage_summary": + return s.handleAnalyzeCoverageSummary(ctx, req) + case "channel_ops": + return s.handleAnalyzeChannelOps(ctx, req) + case "def_use": + return s.handleAnalyzeDefUse(ctx, req) + case "goroutine_spawns": + return s.handleAnalyzeGoroutineSpawns(ctx, req) + case "field_writers": + return s.handleAnalyzeFieldWriters(ctx, req) + case "indirect_mutations": + return s.handleAnalyzeIndirectMutations(ctx, req) + case "speculative": + return s.handleAnalyzeSpeculative(ctx, req) + case "ref_facts": + return s.handleAnalyzeRefFacts(ctx, req) + case "race_writes": + return s.handleAnalyzeRaceWrites(ctx, req) + case "unclosed_channels": + return s.handleAnalyzeUnclosedChannels(ctx, req) + case "unsafe_patterns": + return s.handleAnalyzeUnsafePatterns(ctx, req) + case "sast", "hygiene": + return s.handleAnalyzeSAST(ctx, req, kind) + case "review": + return s.handleAnalyzeSAST(ctx, req, "review") + case "domain": + return s.handleAnalyzeSAST(ctx, req, "domain") + case "health_score": + return s.handleAnalyzeHealthScore(ctx, req) + case "annotation_users": + return s.handleAnalyzeAnnotationUsers(ctx, req) + case "config_readers": + return s.handleAnalyzeConfigReaders(ctx, req) + case "env_var_users": + return s.handleAnalyzeEnvVarUsers(ctx, req) + case "sql_call_sites": + return s.handleAnalyzeSQLCallSites(ctx, req) + case "fixes_history": + return s.handleAnalyzeFixesHistory(ctx, req) + case "edge_audit": + return s.handleAnalyzeEdgeAudit(ctx, req) + case "event_emitters": + return s.handleAnalyzeEventEmitters(ctx, req) + case "pubsub": + return s.handleAnalyzePubsub(ctx, req) + case "string_emitters": + return s.handleAnalyzeStringEmitters(ctx, req) + case "error_surface": + return s.handleAnalyzeErrorSurface(ctx, req) + case "log_events": + return s.handleAnalyzeLogEvents(ctx, req) + case "sql_rebuild": + return s.handleAnalyzeSQLRebuild(ctx, req) + case "external_calls": + return s.handleAnalyzeExternalCalls(ctx, req) + case "synthesizers": + return s.handleAnalyzeSynthesizers(ctx, req) + case "temporal_orphans": + return s.handleAnalyzeTemporalOrphans(ctx, req) + case "resolution_outcomes": + return s.handleAnalyzeResolutionOutcomes(ctx, req) + case "temporal_verify": + return s.handleAnalyzeTemporalVerify(ctx, req) + case "retrieval_log": + return s.handleAnalyzeRetrievalLog(ctx, req) + case "routes": + return s.handleAnalyzeRoutes(ctx, req) + case "route_frameworks": + return s.handleAnalyzeRouteFrameworks(ctx, req) + case "drupal_hooks": + return s.handleAnalyzeDrupalHooks(ctx, req) + case "swiftui_views": + return s.handleAnalyzeSwiftUIViews(ctx, req) + case "uikit_classes": + return s.handleAnalyzeUIKitClasses(ctx, req) + case "models": + return s.handleAnalyzeModels(ctx, req) + case "components": + return s.handleAnalyzeComponents(ctx, req) + case "k8s_resources": + return s.handleAnalyzeK8sResources(ctx, req) + case "images": + return s.handleAnalyzeImages(ctx, req) + case "kustomize": + return s.handleAnalyzeKustomize(ctx, req) + case "cross_repo": + return s.handleAnalyzeCrossRepo(ctx, req) + case "dbt_models": + return s.handleAnalyzeDbtModels(ctx, req) + case "role": + return s.handleAnalyzeRole(ctx, req) + case "constructors_missing_fields": + return s.handleAnalyzeConstructorsMissingFields(ctx, req) + case "clusters": + return s.handleAnalyzeClusters(ctx, req) + case "suggest_boundaries": + return s.handleSuggestBoundaries(ctx, req) + case "concepts": + return s.handleAnalyzeConcepts(ctx, req) + case "impact": + return s.handleAnalyzeImpactComposite(ctx, req) + case "bottlenecks": + return s.handleAnalyzeBottlenecks(ctx, req) + case "named": + return s.handleAnalyzeNamed(ctx, req) + case "tests_as_edges": + return s.handleAnalyzeTestsAsEdges(ctx, req) + case "connectivity_health": + return s.handleAnalyzeConnectivityHealth(ctx, req) + case "pagerank": + return s.handleAnalyzePageRank(ctx, req) + case "louvain": + return s.handleAnalyzeLouvain(ctx, req) + case "wcc": + return s.handleAnalyzeConnectedComponents(ctx, req, false) + case "scc": + return s.handleAnalyzeConnectedComponents(ctx, req, true) + case "kcore": + return s.handleAnalyzeKCore(ctx, req) + default: + return mcp.NewToolResultError("unknown analyze kind: " + kind + " (expected: " + analyzeKindsCSV() + ")"), nil + } + }() + + // Disclose when the caller asked to narrow (repo/project/scope) but + // the chosen kind does not repo-narrow its rows in v1 — it enumerates + // edges / scans files / mines git. scope_applied stays uniform and + // truthful about the resolved scope; scope_note prevents it from + // misleading a caller whose kind ignored the narrowing ("no silent + // no-ops"). + if err == nil && res != nil && resolved.RepoAllow != nil && !analyzeScopeAwareKinds[kind] { + stampScopeNote(res, kind) + } + return withScopeResult(res, err, resolved) +} + +// requestWithoutArgs returns a shallow copy of req with the named +// argument keys removed, so resolveScope doesn't mistake a kind-specific +// arg (cross_repo's `repo`, cycles' `scope`) for the uniform scope +// dimension. The original req is untouched. +func requestWithoutArgs(req mcp.CallToolRequest, keys ...string) mcp.CallToolRequest { + src := req.GetArguments() + dst := make(map[string]any, len(src)) + for k, v := range src { + dst[k] = v + } + for _, k := range keys { + delete(dst, k) + } + out := req + out.Params.Arguments = dst + return out +} + +// stampScopeNote records on the response that the caller asked to narrow +// but the chosen kind does not repo-narrow its rows in v1, keeping +// scope_applied uniform while disclosing the no-op. +func stampScopeNote(res *mcp.CallToolResult, kind string) { + if res == nil { + return + } + note := "kind '" + kind + "' is not scope-narrowed in v1 (a community / git-mining / per-id / synthesizer kind); it reads the full graph directly, so results may span the entire index / all workspaces — not just the session workspace" + if res.Meta == nil { + res.Meta = mcp.NewMetaFromMap(map[string]any{"scope_note": note}) + return + } + if res.Meta.AdditionalFields == nil { + res.Meta.AdditionalFields = map[string]any{} + } + res.Meta.AdditionalFields["scope_note"] = note +} + +// analyzeNodeVisible reports whether a node may appear in an analyze +// result for the current request: inside the session workspace ceiling +// AND inside the optional ctx RepoAllow narrowing. It reuses the same +// gates as the scoped-node accessors so the bypass kinds that filter +// through it (dead_code, hotspots, cycles) match the AUTO kinds and +// simultaneously honour the workspace boundary they would otherwise +// ignore (those kinds read s.graph directly). Returns true for an +// unbound session with no RepoAllow, so an unconditional filter is a +// strict no-op in that case. +func (s *Server) analyzeNodeVisible(ctx context.Context, n *graph.Node) bool { + if n == nil { + return false + } + if !s.nodeInSessionScope(ctx, n) { + return false + } + if allow := repoAllowFromContext(ctx); len(allow) > 0 && !allow[n.RepoPrefix] { + return false + } + return true +} + +// scopeFiltersActive reports whether the current request narrows analyze +// output below the global graph — a workspace-bound session or a ctx +// RepoAllow. When false, analyzeNodeVisible passes every node, so the +// Tier-2 filters skip the work and preserve byte-for-byte output. +func (s *Server) scopeFiltersActive(ctx context.Context) bool { + if _, _, bound := s.sessionScope(ctx); bound { + return true } + return len(repoAllowFromContext(ctx)) > 0 } // --------------------------------------------------------------------------- @@ -1981,6 +2097,12 @@ func (s *Server) handleAnalyzeReleases(ctx context.Context, req mcp.CallToolRequ if n.Kind != graph.KindRelease { continue } + // Gate every release node on the session workspace ceiling + + // resolved RepoAllow narrowing (stamped into ctx by handleAnalyze). + // Strict no-op for an unbound session with no RepoAllow. + if !s.analyzeNodeVisible(ctx, n) { + continue + } if repoFilter != "" && n.RepoPrefix != repoFilter { continue } @@ -2027,6 +2149,9 @@ func (s *Server) handleAnalyzeReleases(ctx context.Context, req mcp.CallToolRequ if n.Kind != graph.KindFile || n.FilePath == "" { continue } + if !s.analyzeNodeVisible(ctx, n) { + continue + } if repoFilter != "" && n.RepoPrefix != repoFilter { continue } @@ -2058,6 +2183,9 @@ func (s *Server) handleAnalyzeReleases(ctx context.Context, req mcp.CallToolRequ hasAnyAddedIn = true } else { for _, n := range s.graph.AllNodes() { + if !s.analyzeNodeVisible(ctx, n) { + continue + } if n.Kind == graph.KindFile && n.Meta != nil { if _, ok := n.Meta["added_in"].(string); ok { hasAnyAddedIn = true @@ -2191,6 +2319,21 @@ func (s *Server) handleFindDeadCode(ctx context.Context, req mcp.CallToolRequest entries := analysis.FindDeadCode(s.graph, s.getProcesses(), nil, opts) + // dead_code reads s.graph directly, bypassing the scoped-node + // accessors, so narrow its rows to the session workspace + optional + // repo allow-set here. This also closes the latent cross-workspace + // leak for this kind. Strict no-op for an unbound session with no + // RepoAllow. Counts below are computed after this filter. + if s.scopeFiltersActive(ctx) { + kept := make([]analysis.DeadCodeEntry, 0, len(entries)) + for _, e := range entries { + if s.analyzeNodeVisible(ctx, s.graph.GetNode(e.ID)) { + kept = append(kept, e) + } + } + entries = kept + } + // Cap response size — large repos surface thousands of dead-code // candidates and the default JSON encoding spills past the MCP // per-response token cap. Callers that need the full list can @@ -2307,6 +2450,20 @@ func (s *Server) handleFindHotspots(ctx context.Context, req mcp.CallToolRequest entries = rerankHotspots(entries, s.graph, mode, direction, windowDays) } + // hotspots reads s.graph directly, bypassing the scoped-node + // accessors, so narrow its rows to the session workspace + optional + // repo allow-set here (also closing the latent cross-workspace leak). + // Strict no-op for an unbound session with no RepoAllow. + if s.scopeFiltersActive(ctx) { + kept := make([]analysis.HotspotEntry, 0, len(entries)) + for _, e := range entries { + if s.analyzeNodeVisible(ctx, s.graph.GetNode(e.ID)) { + kept = append(kept, e) + } + } + entries = kept + } + // Truncate to top 20 totalCount := len(entries) truncated := false @@ -2431,11 +2588,42 @@ func (s *Server) handleScaffold(ctx context.Context, req mcp.CallToolRequest) (* // 10.7 handleFindCycles and handleWouldCreateCycle // --------------------------------------------------------------------------- +// cycleVisible reports whether every node on a cycle's path is visible +// to the current request (workspace ceiling + optional repo allow-set). +// A cycle is surfaced only when it is entirely in scope, so a chain that +// crosses the boundary is dropped rather than leaking its out-of-scope +// members. +func (s *Server) cycleVisible(ctx context.Context, c analysis.Cycle) bool { + for _, id := range c.Path { + if !s.analyzeNodeVisible(ctx, s.graph.GetNode(id)) { + return false + } + } + return true +} + func (s *Server) handleFindCycles(ctx context.Context, req mcp.CallToolRequest) (*mcp.CallToolResult, error) { scope := req.GetString("scope", "") cycles := analysis.DetectCycles(s.graph, s.getCommunities(), scope) + // cycles reads s.graph directly, bypassing the scoped-node accessors, + // so narrow here to the session workspace + optional repo allow-set. + // A cycle is kept only when EVERY node on its path is visible, so a + // chain that crosses the boundary is dropped rather than leaking its + // cross-repo / cross-workspace members. Strict no-op for an unbound + // session with no RepoAllow. Runs before the GCX / empty / total + // blocks so all of them observe the filtered set. + if s.scopeFiltersActive(ctx) { + kept := make([]analysis.Cycle, 0, len(cycles)) + for _, c := range cycles { + if s.cycleVisible(ctx, c) { + kept = append(kept, c) + } + } + cycles = kept + } + if s.isGCX(ctx, req) { items := make([]cycleItem, 0, len(cycles)) for _, c := range cycles { @@ -3501,16 +3689,15 @@ func (s *Server) handleGetContracts(ctx context.Context, req mcp.CallToolRequest includeDeps = v } - // resolveRepoFilter unifies repo/project/ref into a single allow-set - // and falls back to the active project when no axis is given — same - // default scoping every other query tool uses. all_repos=true opts out. - var allowed map[string]bool + var resolved ResolvedScope + var contractRepoAllow map[string]bool if !allRepos { - var err error - allowed, err = s.resolveRepoFilter(ctx, req) - if err != nil { - return mcp.NewToolResultError(err.Error()), nil + var errResult *mcp.CallToolResult + resolved, errResult = s.resolveScope(ctx, req, IntentReach) + if errResult != nil { + return errResult, nil } + contractRepoAllow = s.contractRepoAllowForRequest(ctx, req, resolved) } all := registry.All() @@ -3522,7 +3709,7 @@ func (s *Server) handleGetContracts(ctx context.Context, req mcp.CallToolRequest for _, c := range all { isDep := c.Type == contracts.ContractDependency || excludes.IsVendored(c.FilePath) - if allowed != nil && c.RepoPrefix != "" && !allowed[c.RepoPrefix] { + if !allRepos && !contractInResolvedScope(c, resolved, contractRepoAllow) { if includeDeps || !isDep { otherRepos[c.RepoPrefix]++ } @@ -3598,7 +3785,11 @@ func (s *Server) handleGetContracts(ctx context.Context, req mcp.CallToolRequest if depsSkipped > 0 { fmt.Fprintf(&b, "dependencies_skipped: %d (pass include_deps=true to include)\n", depsSkipped) } - return mcp.NewToolResultText(b.String()), nil + res := mcp.NewToolResultText(b.String()) + if !allRepos { + res = decorateResultWithScope(res, resolved) + } + return res, nil } if s.isGCX(ctx, req) { @@ -3610,7 +3801,11 @@ func (s *Server) handleGetContracts(ctx context.Context, req mcp.CallToolRequest if depsSkipped > 0 { extra = append(extra, "dependencies_skipped", fmt.Sprintf("%d", depsSkipped)) } - return s.gcxResponseWithBudget(req)(encodeContractsList(filtered, len(filtered), extra...)) + res, err := s.gcxResponseWithBudget(req)(encodeContractsList(filtered, len(filtered), extra...)) + if !allRepos { + return withScopeResult(res, err, resolved) + } + return res, err } // Group by repo, then by type for structured output. @@ -3656,9 +3851,42 @@ func (s *Server) handleGetContracts(ctx context.Context, req mcp.CallToolRequest "hint": "pass include_deps=true to include type=dependency and vendor-pathed contracts", } } + if !allRepos { + return s.respondScopedJSONOrTOON(ctx, req, payload, resolved) + } return s.respondJSONOrTOON(ctx, req, payload) } +func (s *Server) contractRepoAllowForRequest(ctx context.Context, req mcp.CallToolRequest, resolved ResolvedScope) map[string]bool { + if resolved.RepoAllow != nil { + return resolved.RepoAllow + } + if strings.TrimSpace(req.GetString("repo", "")) == "" && + strings.TrimSpace(req.GetString("project", "")) == "" && + strings.TrimSpace(req.GetString("ref", "")) == "" && + strings.TrimSpace(req.GetString("scope", "")) == "" { + return nil + } + allowed, err := s.resolveRepoFilter(ctx, req) + if err != nil { + return nil + } + return allowed +} + +func contractInResolvedScope(c contracts.Contract, resolved ResolvedScope, repoAllow map[string]bool) bool { + if resolved.WorkspaceID != "" && c.EffectiveWorkspace() != resolved.WorkspaceID { + return false + } + if len(repoAllow) > 0 && c.RepoPrefix != "" && !repoAllow[c.RepoPrefix] { + return false + } + if len(repoAllow) == 0 && resolved.ProjectID != "" && c.EffectiveProject() != resolved.ProjectID { + return false + } + return true +} + // --------------------------------------------------------------------------- // handleCheckContracts // --------------------------------------------------------------------------- @@ -3669,23 +3897,15 @@ func (s *Server) handleCheckContracts(ctx context.Context, req mcp.CallToolReque return mcp.NewToolResultError("no contract registry available — index a repository first"), nil } - // resolveRepoFilter folds repo/project/ref into one allow-set so - // `contracts check` can answer "does project X match" without the - // caller having to list its repos by hand. A nil allow-set means - // "all tracked repos" and keeps the original single-registry fast - // path — avoids a pointless copy of the full registry. - allowed, err := s.resolveRepoFilter(ctx, req) - if err != nil { - return mcp.NewToolResultError(err.Error()), nil + resolved, errResult := s.resolveScope(ctx, req, IntentReach) + if errResult != nil { + return errResult, nil } + contractRepoAllow := s.contractRepoAllowForRequest(ctx, req, resolved) - reg := registry - if allowed != nil { - reg = contracts.NewRegistry() - for _, c := range registry.All() { - if c.RepoPrefix != "" && !allowed[c.RepoPrefix] { - continue - } + reg := contracts.NewRegistry() + for _, c := range registry.All() { + if contractInResolvedScope(c, resolved, contractRepoAllow) { reg.Add(c) } } @@ -3730,11 +3950,12 @@ func (s *Server) handleCheckContracts(ctx context.Context, req mcp.CallToolReque } fmt.Fprintf(&b, " [%s] %s %s:%d\n", repoLabel, o.ID, o.FilePath, o.Line) } - return mcp.NewToolResultText(b.String()), nil + return decorateResultWithScope(mcp.NewToolResultText(b.String()), resolved), nil } if s.isGCX(ctx, req) { - return s.gcxResponseWithBudget(req)(encodeContractsCheck(result)) + res, err := s.gcxResponseWithBudget(req)(encodeContractsCheck(result)) + return withScopeResult(res, err, resolved) } payload := map[string]any{ @@ -3747,7 +3968,7 @@ func (s *Server) handleCheckContracts(ctx context.Context, req mcp.CallToolReque "orphan_consumers": len(result.OrphanConsumers), }, } - return s.respondJSONOrTOON(ctx, req, payload) + return s.respondScopedJSONOrTOON(ctx, req, payload, resolved) } // --------------------------------------------------------------------------- @@ -3766,18 +3987,15 @@ func (s *Server) handleValidateContracts(ctx context.Context, req mcp.CallToolRe return mcp.NewToolResultError("no contract registry available — index a repository first"), nil } - allowed, err := s.resolveRepoFilter(ctx, req) - if err != nil { - return mcp.NewToolResultError(err.Error()), nil + resolved, errResult := s.resolveScope(ctx, req, IntentReach) + if errResult != nil { + return errResult, nil } + contractRepoAllow := s.contractRepoAllowForRequest(ctx, req, resolved) - reg := registry - if allowed != nil { - reg = contracts.NewRegistry() - for _, c := range registry.All() { - if c.RepoPrefix != "" && !allowed[c.RepoPrefix] { - continue - } + reg := contracts.NewRegistry() + for _, c := range registry.All() { + if contractInResolvedScope(c, resolved, contractRepoAllow) { reg.Add(c) } } @@ -3826,14 +4044,14 @@ func (s *Server) handleValidateContracts(ctx context.Context, req mcp.CallToolRe fmt.Fprintf(&b, " [%s] %s %s field=%s prov=%s cons=%s %s\n", is.Severity, is.ContractID, is.Kind, field, is.Provider, is.Consumer, is.Details) } - return mcp.NewToolResultText(b.String()), nil + return decorateResultWithScope(mcp.NewToolResultText(b.String()), resolved), nil } payload := map[string]any{ "issues": issues, "summary": summary, } - return s.respondJSONOrTOON(ctx, req, payload) + return s.respondScopedJSONOrTOON(ctx, req, payload, resolved) } // --------------------------------------------------------------------------- diff --git a/internal/mcp/tools_find_files.go b/internal/mcp/tools_find_files.go index 4a506902..e71106d9 100644 --- a/internal/mcp/tools_find_files.go +++ b/internal/mcp/tools_find_files.go @@ -9,6 +9,7 @@ import ( "github.com/mark3labs/mcp-go/mcp" "github.com/zzet/gortex/internal/graph" + querypkg "github.com/zzet/gortex/internal/query" ) // registerFindFilesTool wires `find_files` — the dedicated search-by- @@ -32,6 +33,9 @@ func (s *Server) registerFindFilesTool() { mcp.WithBoolean("fuzzy", mcp.Description("Also accept fuzzy subsequence matches of `query` against the basename (e.g. \"tcgo\" matches \"tools_coding.go\"). Lowest-ranked. Default false.")), mcp.WithString("path", mcp.Description("Restrict to one or more anchored sub-path prefixes (comma-separated), repo-root-relative — the monorepo-service slice.")), mcp.WithString("repo", mcp.Description("Restrict to a single repository prefix.")), + mcp.WithString("project", mcp.Description("Restrict to repositories in a specific project.")), + mcp.WithString("workspace", mcp.Description("Restrict to the active workspace slug; daemon sessions may only name their own workspace.")), + mcp.WithString("scope", mcp.Description("Name of a saved scope (see save_scope) -- its repositories and paths narrow the matches. Ignored for repositories when an explicit repo / project / ref is also given.")), mcp.WithNumber("limit", mcp.Description("Max files to return (default 50, capped at 500).")), mcp.WithString("format", mcp.Description("Output format: json (default), gcx (compact wire format), or toon.")), ), @@ -59,7 +63,10 @@ func (s *Server) handleFindFiles(ctx context.Context, req mcp.CallToolRequest) ( return mcp.NewToolResultError("find_files: pass `query` (a filename/path substring) and/or `glob` (a path glob)"), nil } fuzzy := req.GetBool("fuzzy", false) - repoArg := strings.TrimSpace(req.GetString("repo", "")) + resolved, errResult := s.resolveScope(ctx, req, IntentLocate) + if errResult != nil { + return errResult, nil + } limit := req.GetInt("limit", 50) if limit < 1 { limit = 50 @@ -68,6 +75,11 @@ func (s *Server) handleFindFiles(ctx context.Context, req mcp.CallToolRequest) ( limit = 500 } pathFilter := s.resolvePathFilter(req, fieldQuery{}) + scopeOpts := querypkg.QueryOptions{ + WorkspaceID: resolved.WorkspaceID, + ProjectID: resolved.ProjectID, + RepoAllow: resolved.RepoAllow, + } hits := make([]fileHit, 0, 64) for n := range s.graph.NodesByKind(graph.KindFile) { @@ -77,7 +89,7 @@ func (s *Server) handleFindFiles(ctx context.Context, req mcp.CallToolRequest) ( if !s.nodeInSessionScope(ctx, n) { continue } - if repoArg != "" && n.RepoPrefix != repoArg { + if !scopeOpts.ScopeAllows(n) { continue } rel := repoRelativePath(n) @@ -128,13 +140,13 @@ func (s *Server) handleFindFiles(ctx context.Context, req mcp.CallToolRequest) ( hits = hits[:limit] } - return s.respondJSONOrTOON(ctx, req, map[string]any{ + return s.respondScopedJSONOrTOON(ctx, req, map[string]any{ "query": query, "glob": glob, "files": hits, "count": len(hits), "truncated": total > len(hits), - }) + }, resolved) } // scoreFilenameMatch ranks how well query matches a file's basename or diff --git a/internal/mcp/tools_search_text.go b/internal/mcp/tools_search_text.go index cc2ecc88..1e4067f2 100644 --- a/internal/mcp/tools_search_text.go +++ b/internal/mcp/tools_search_text.go @@ -2,9 +2,11 @@ package mcp import ( "context" + "strings" "github.com/mark3labs/mcp-go/mcp" + "github.com/zzet/gortex/internal/query" "github.com/zzet/gortex/internal/search/trigram" ) @@ -37,6 +39,10 @@ func (s *Server) handleSearchText(ctx context.Context, req mcp.CallToolRequest) if s.indexer == nil && s.multiIndexer == nil { return mcp.NewToolResultError("search_text: no indexer available"), nil } + resolved, errResult := s.resolveScope(ctx, req, IntentLocate) + if errResult != nil { + return errResult, nil + } limit := req.GetInt("limit", 100) if limit < 1 { @@ -59,10 +65,16 @@ func (s *Server) handleSearchText(ctx context.Context, req mcp.CallToolRequest) // the results flow through the identical enclosing-symbol // enrichment so callers get the same shape either way. useRegexp := req.GetBool("regexp", false) + pathFilter := s.resolvePathFilter(req, fieldQuery{}) + scopedMultiGrep := s.multiIndexer != nil && (resolved.RepoAllow != nil || len(pathFilter) > 0) var matches []trigram.Match + needsFinalLimit := false if useRegexp { var err error - if s.multiIndexer != nil { + if scopedMultiGrep { + matches, err = s.multiIndexer.GrepRegexpForRepos(query, "", resolved.RepoAllow, limit) + needsFinalLimit = true + } else if s.multiIndexer != nil { matches, err = s.multiIndexer.GrepRegexp(query, "", limit) } else { matches, err = s.indexer.GrepRegexp(query, "", limit) @@ -70,6 +82,9 @@ func (s *Server) handleSearchText(ctx context.Context, req mcp.CallToolRequest) if err != nil { return mcp.NewToolResultError("search_text: invalid regexp: " + err.Error()), nil } + } else if scopedMultiGrep { + matches = s.multiIndexer.GrepTextForRepos(query, resolved.RepoAllow, limit) + needsFinalLimit = true } else if s.multiIndexer != nil { matches = s.multiIndexer.GrepText(query, limit) } else { @@ -81,20 +96,24 @@ func (s *Server) handleSearchText(ctx context.Context, req mcp.CallToolRequest) // slice. In multi-repo mode MultiIndexer.GrepText stamps a repo // prefix onto every match path, so the repo-relative filter is // expanded with the tracked repo prefixes before the anchored test. - if pathFilter := s.resolvePathFilter(req, fieldQuery{}); len(pathFilter) > 0 { + if len(pathFilter) > 0 { var repoPrefixes []string if s.multiIndexer != nil { repoPrefixes = s.multiIndexer.RepoPrefixes() } matches = filterTextMatchesByPath(matches, pathFilter, repoPrefixes) } + matches = s.filterTextMatchesByResolvedScope(matches, resolved) + if needsFinalLimit { + matches = limitTextMatches(matches, limit) + } enriched := s.enrichTextMatches(matches) - return s.respondJSONOrTOON(ctx, req, map[string]any{ + return s.respondScopedJSONOrTOON(ctx, req, map[string]any{ "query": query, "matches": enriched, "count": len(enriched), - }) + }, resolved) } // filterTextMatchesByPath keeps only the trigram matches whose file @@ -117,6 +136,48 @@ func filterTextMatchesByPath(matches []trigram.Match, paths, repoPrefixes []stri return out } +func limitTextMatches(matches []trigram.Match, limit int) []trigram.Match { + if limit > 0 && len(matches) > limit { + return matches[:limit] + } + return matches +} + +func (s *Server) filterTextMatchesByResolvedScope(matches []trigram.Match, resolved ResolvedScope) []trigram.Match { + if resolved.WorkspaceID == "" && resolved.ProjectID == "" && len(resolved.RepoAllow) == 0 { + return matches + } + opts := query.QueryOptions{ + WorkspaceID: resolved.WorkspaceID, + ProjectID: resolved.ProjectID, + RepoAllow: resolved.RepoAllow, + } + out := make([]trigram.Match, 0, len(matches)) + for _, m := range matches { + repo, _, ok := strings.Cut(m.Path, "/") + // Repo allow-set: a match whose repo prefix is outside the + // allow-set is dropped outright. + if len(resolved.RepoAllow) > 0 && ok && repo != "" && !resolved.RepoAllow[repo] { + continue + } + // Fail CLOSED under active narrowing: keep a match only when it + // can be positively attributed to an in-scope graph node. A match + // whose path resolves to no node (graph unavailable, or a file the + // graph never turned into a node) cannot be proven in-scope, so + // dropping it is the safe choice — keeping it was a latent + // cross-scope leak. + if s.graph == nil { + continue + } + n := s.graph.GetNode(m.Path) + if n == nil || !opts.ScopeAllows(n) { + continue + } + out = append(out, m) + } + return out +} + // enrichTextMatches decorates every trigram match with its enclosing // graph symbol. It builds one per-file symbol index for the set of // matched files, then resolves each match's line through it. diff --git a/internal/mcp/tools_search_text_test.go b/internal/mcp/tools_search_text_test.go index 972364fe..9f7d8f85 100644 --- a/internal/mcp/tools_search_text_test.go +++ b/internal/mcp/tools_search_text_test.go @@ -218,6 +218,127 @@ func TestSearchText_MultiRepoFanout(t *testing.T) { require.True(t, strings.HasPrefix(betaOut.Matches[0].Path, "beta/")) } +func TestSearchText_MultiRepoScopedGrepDoesNotLetOutOfScopeRepoConsumeLimit(t *testing.T) { + repoA := setupSearchTextWorkspaceRepo(t, "alpha", "shared", + "package alpha\n\n// shared_scope_marker alpha one\n// shared_scope_marker alpha two\nfunc A() {}\n") + repoB := setupSearchTextWorkspaceRepo(t, "beta", "shared", + "package beta\n\n// shared_scope_marker beta\nfunc B() {}\n") + + tmpCfg := filepath.Join(t.TempDir(), "config.yaml") + gc := &config.GlobalConfig{ + Repos: []config.RepoEntry{ + {Path: repoA, Name: "alpha", Project: "backend"}, + {Path: repoB, Name: "beta", Project: "backend"}, + }, + } + gc.SetConfigPath(tmpCfg) + require.NoError(t, gc.Save()) + cm, err := config.NewConfigManager(tmpCfg) + require.NoError(t, err) + + reg := parser.NewRegistry() + reg.Register(languages.NewGoExtractor()) + g := graph.New() + mi := indexer.NewMultiIndexer(g, reg, search.NewBM25(), cm, zap.NewNop()) + _, err = mi.IndexAll() + require.NoError(t, err) + + eng := query.NewEngine(g) + flagOn := true + srv := NewServer(eng, g, nil, nil, zap.NewNop(), nil, MultiRepoOptions{ + ConfigManager: cm, + MultiIndexer: mi, + ScopeIntentDefaults: &flagOn, + }) + + req := makeReq("search_text", map[string]any{"query": "shared_scope_marker", "limit": 1}) + res, err := srv.handleSearchText(sessionCtx("s-beta", repoB), req) + require.NoError(t, err) + require.False(t, res.IsError) + + var out struct { + Matches []searchTextMatch `json:"matches"` + Count int `json:"count"` + } + require.NoError(t, json.Unmarshal([]byte(res.Content[0].(mcplib.TextContent).Text), &out)) + require.Equal(t, 1, out.Count) + require.Len(t, out.Matches, 1) + require.True(t, strings.HasPrefix(out.Matches[0].Path, "beta/"), + "out-of-scope alpha matches must not consume the limit before beta is searched, got %q", out.Matches[0].Path) + require.NotNil(t, res.Meta) + require.Equal(t, "repo:beta", res.Meta.AdditionalFields["scope_applied"]) +} + +// TestFilterTextMatchesByResolvedScope_FailsClosed pins the fail-CLOSED +// contract of the scope-narrowing filter. Before the fix it FAILED OPEN: +// a match whose enclosing-node lookup returned nil was kept even under an +// active narrowing — a latent cross-scope leak. Now, under active +// narrowing, a match that cannot be positively attributed to an in-scope +// graph node is dropped; with NO narrowing active the filter passes every +// match through untouched. +func TestFilterTextMatchesByResolvedScope_FailsClosed(t *testing.T) { + repoA := setupSearchTextWorkspaceRepo(t, "alpha", "shared", + "package alpha\n\n// marker\nfunc A() {}\n") + repoB := setupSearchTextWorkspaceRepo(t, "beta", "shared", + "package beta\n\n// marker\nfunc B() {}\n") + + tmpCfg := filepath.Join(t.TempDir(), "config.yaml") + gc := &config.GlobalConfig{ + Repos: []config.RepoEntry{ + {Path: repoA, Name: "alpha", Project: "backend"}, + {Path: repoB, Name: "beta", Project: "backend"}, + }, + } + gc.SetConfigPath(tmpCfg) + require.NoError(t, gc.Save()) + cm, err := config.NewConfigManager(tmpCfg) + require.NoError(t, err) + + reg := parser.NewRegistry() + reg.Register(languages.NewGoExtractor()) + g := graph.New() + mi := indexer.NewMultiIndexer(g, reg, search.NewBM25(), cm, zap.NewNop()) + _, err = mi.IndexAll() + require.NoError(t, err) + + eng := query.NewEngine(g) + srv := NewServer(eng, g, nil, nil, zap.NewNop(), nil, MultiRepoOptions{ + ConfigManager: cm, + MultiIndexer: mi, + }) + + // Fixture invariants: the real beta file node is keyed by its + // repo-prefixed path (so a match positively attributes to it); a ghost + // path under beta maps to NO node at all. + require.NotNil(t, g.GetNode("beta/main.go"), "fixture invariant: beta file node must exist") + require.Nil(t, g.GetNode("beta/ghost.go"), "fixture invariant: ghost path must map to no node") + + realMatch := trigram.Match{Path: "beta/main.go", Line: 3, Text: "// marker"} + ghostMatch := trigram.Match{Path: "beta/ghost.go", Line: 1, Text: "// marker"} + in := []trigram.Match{realMatch, ghostMatch} + + // Active narrowing (workspace ceiling + repo allow-set): the + // unattributable ghost match is dropped; the node-backed real match + // survives. + narrowed := srv.filterTextMatchesByResolvedScope(in, ResolvedScope{ + WorkspaceID: "shared", + RepoAllow: map[string]bool{"beta": true}, + }) + gotPaths := make([]string, 0, len(narrowed)) + for _, m := range narrowed { + gotPaths = append(gotPaths, m.Path) + } + require.Contains(t, gotPaths, "beta/main.go", "a node-backed in-scope match must survive narrowing") + require.NotContains(t, gotPaths, "beta/ghost.go", + "FAIL-CLOSED: under active narrowing a match that maps to no graph node must be dropped") + + // No narrowing active: pass-through unchanged — the ghost match is kept + // (exactly the legacy behaviour the early-return preserves). + passthrough := srv.filterTextMatchesByResolvedScope(in, ResolvedScope{}) + require.Len(t, passthrough, len(in), + "with no narrowing active the filter must pass every match through untouched") +} + // TestGetFileSummary_MultiRepoRelativePath pins the multi-repo path // normalisation: get_file_summary (and get_editing_context, which shares // graphRelPath) must accept a repo-relative path and resolve it to the @@ -316,6 +437,16 @@ func setupMiniRepoNamed(t *testing.T, name, body string) string { return dir } +func setupSearchTextWorkspaceRepo(t *testing.T, name, workspace, body string) string { + t.Helper() + dir := filepath.Join(t.TempDir(), name) + require.NoError(t, os.MkdirAll(dir, 0o755)) + require.NoError(t, os.WriteFile(filepath.Join(dir, ".gortex.yaml"), + []byte("workspace: "+workspace+"\nproject: backend\n"), 0o644)) + require.NoError(t, os.WriteFile(filepath.Join(dir, "main.go"), []byte(body), 0o644)) + return dir +} + // TestSearchText_Regexp covers the regexp mode: the same query runs // through the trigram backbone as a compiled regular expression, an // alternation matches multiple sites a literal never would, and a bad diff --git a/internal/mcp/tools_walk.go b/internal/mcp/tools_walk.go index 6deb652e..3d5a4220 100644 --- a/internal/mcp/tools_walk.go +++ b/internal/mcp/tools_walk.go @@ -29,6 +29,8 @@ func (s *Server) registerWalkGraphTool() { mcp.WithString("repo", mcp.Description("Filter results to a specific repository prefix")), mcp.WithString("project", mcp.Description("Filter results to repositories in a specific project")), mcp.WithString("ref", mcp.Description("Filter results to repositories with a specific reference tag")), + mcp.WithString("workspace", mcp.Description("Workspace override. In workspace-bound sessions this must match the active workspace.")), + mcp.WithString("scope", mcp.Description("Saved scope name. Ignored for git diff scopes; explicit repo/project/ref filters take precedence.")), ), s.handleWalkGraph, ) @@ -70,11 +72,18 @@ func (s *Server) handleWalkGraph(ctx context.Context, req mcp.CallToolRequest) ( } eng := s.engineFor(ctx) - if eng.GetSymbol(id) == nil { + seed := eng.GetSymbol(id) + if seed == nil { return mcp.NewToolResultError(fmt.Sprintf("symbol not found: %s", id)), nil } - scopeWS, scopeProj := s.scopeFromRequest(ctx, &req) + resolved, errResult := s.resolveScope(ctx, req, IntentReach) + if errResult != nil { + return errResult, nil + } + if !resolvedScopeAllowsNode(resolved, seed) { + return symbolNotFoundGuidance(id), nil + } // Optional community constraint: accept either a community ID or // label and resolve it to an ID, then hand the walk the node->comm @@ -87,24 +96,21 @@ func (s *Server) handleWalkGraph(ctx context.Context, req mcp.CallToolRequest) ( Direction: direction, TokenBudget: tokenBudget, MaxDepth: maxDepth, - WorkspaceID: scopeWS, - ProjectID: scopeProj, + WorkspaceID: resolved.WorkspaceID, + ProjectID: resolved.ProjectID, + RepoAllow: resolved.RepoAllow, CommunityID: commID, NodeToComm: nodeToComm, }) - allowed, filterErr := s.resolveRepoFilter(ctx, req) - if filterErr != nil { - return mcp.NewToolResultError(filterErr.Error()), nil - } budgetHit, stoppedAt := sg.BudgetHit, sg.StoppedAtDepth - sg = filterSubGraph(sg, allowed) - // filterSubGraph builds a fresh SubGraph and does not copy the - // budget fields — restore them so the response keeps them. + sg = filterSubGraphByResolvedScope(sg, resolved) + // Keep the traversal budget fields stable after the defensive + // post-filter. sg.BudgetHit = budgetHit sg.StoppedAtDepth = stoppedAt enrichSubGraphEdges(sg) - return s.returnSubGraph(ctx, req, sg) + return s.returnScopedSubGraph(ctx, req, sg, resolved) } // resolveCommunityFilter turns a community ID-or-label request argument diff --git a/internal/mcp/workspace_isolation_test.go b/internal/mcp/workspace_isolation_test.go index a1472ef3..2384f4a0 100644 --- a/internal/mcp/workspace_isolation_test.go +++ b/internal/mcp/workspace_isolation_test.go @@ -2,6 +2,7 @@ package mcp import ( "context" + "encoding/json" "os" "path/filepath" "sync" @@ -149,6 +150,64 @@ func TestWorkspaceIsolation_ScopedNodes(t *testing.T) { assert.True(t, hasSymbol(all, "AlphaThing") && hasSymbol(all, "BetaThing")) } +func TestWorkspaceIsolation_GetSymbolRejectsCrossWorkspaceID(t *testing.T) { + srv, repoA, _ := newIsolationServer(t) + ctxA := sessionCtx("s-alpha", repoA) + betaID := symbolIDByName(t, srv, "BetaThing") + + res, err := srv.handleGetSymbol(ctxA, makeReq("get_symbol", map[string]any{"id": betaID})) + require.NoError(t, err) + assertRecoverableCondition(t, res, ErrCodeSymbolNotFound) +} + +func TestWorkspaceIsolation_GetSymbolFiltersCrossWorkspaceEdges(t *testing.T) { + srv, repoA, _ := newIsolationServer(t) + ctxA := sessionCtx("s-alpha", repoA) + alphaID := symbolIDByName(t, srv, "AlphaThing") + betaID := symbolIDByName(t, srv, "BetaThing") + srv.graph.AddEdge(&graph.Edge{From: alphaID, To: betaID, Kind: graph.EdgeCalls}) + + res, err := srv.handleGetSymbol(ctxA, makeReq("get_symbol", map[string]any{ + "id": alphaID, + "detail": "full", + })) + require.NoError(t, err) + require.False(t, res.IsError, "get_symbol errored: %s", toolResultText(res)) + + var payload map[string]any + require.NoError(t, json.Unmarshal([]byte(toolResultText(res)), &payload)) + assert.Contains(t, payload, "node") + outEdges, err := json.Marshal(payload["out_edges"]) + require.NoError(t, err) + inEdges, err := json.Marshal(payload["in_edges"]) + require.NoError(t, err) + assert.NotContains(t, string(outEdges), betaID, "cross-workspace out_edges must not be disclosed") + assert.NotContains(t, string(inEdges), betaID, "cross-workspace in_edges must not be disclosed") +} + +func TestWorkspaceIsolation_ReachToolsRejectCrossWorkspaceSeedIDs(t *testing.T) { + srv, repoA, _ := newIsolationServer(t) + ctxA := sessionCtx("s-alpha", repoA) + betaID := symbolIDByName(t, srv, "BetaThing") + + tests := []struct { + name string + handler func(context.Context, mcplib.CallToolRequest) (*mcplib.CallToolResult, error) + }{ + {name: "get_dependencies", handler: srv.handleGetDependencies}, + {name: "get_call_chain", handler: srv.handleGetCallChain}, + {name: "find_usages", handler: srv.handleFindUsages}, + {name: "walk_graph", handler: srv.handleWalkGraph}, + } + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + res, err := tt.handler(ctxA, makeReq(tt.name, map[string]any{"id": betaID})) + require.NoError(t, err) + assertRecoverableCondition(t, res, ErrCodeSymbolNotFound) + }) + } +} + // TestWorkspaceIsolation_ResolveRepoFilter verifies the repo-prefix // allow-set is bounded to the session's workspace and that args cannot // escape it. @@ -243,3 +302,23 @@ func hasSymbol(nodes []*graph.Node, name string) bool { } return false } + +func symbolIDByName(t *testing.T, srv *Server, name string) string { + t.Helper() + for _, n := range srv.graph.AllNodes() { + if n.Name == name { + return n.ID + } + } + t.Fatalf("symbol %q not found", name) + return "" +} + +func assertRecoverableCondition(t *testing.T, res *mcplib.CallToolResult, condition ErrorCode) { + t.Helper() + require.NotNil(t, res) + require.False(t, res.IsError, "recoverable guidance should not be an MCP error: %s", toolResultText(res)) + var guidance RecoverableGuidance + require.NoError(t, json.Unmarshal([]byte(toolResultText(res)), &guidance)) + assert.Equal(t, condition, guidance.Condition) +} diff --git a/internal/query/class_hierarchy.go b/internal/query/class_hierarchy.go index bf705cbc..7f1d9699 100644 --- a/internal/query/class_hierarchy.go +++ b/internal/query/class_hierarchy.go @@ -215,7 +215,7 @@ func (e *Engine) classHierarchyPushdown( if n == nil { continue } - if opts.WorkspaceID != "" && id != seed.ID && !opts.ScopeAllows(n) { + if opts.hasScopeFilter() && id != seed.ID && !opts.ScopeAllows(n) { continue } resultNodes = append(resultNodes, n) @@ -279,7 +279,7 @@ func (e *Engine) classHierarchyPushdown( // Workspace-scope post-filter for edges (any edge whose endpoints // were dropped from resultNodes is also dropped). - if opts.WorkspaceID != "" { + if opts.hasScopeFilter() { nodeSet := make(map[string]bool, len(resultNodes)) for _, n := range resultNodes { nodeSet[n.ID] = true @@ -400,7 +400,7 @@ func (e *Engine) classHierarchyWalk( if member.Kind != graph.KindMethod && member.Kind != graph.KindFunction { continue } - if opts.WorkspaceID != "" && !opts.ScopeAllows(member) { + if opts.hasScopeFilter() && !opts.ScopeAllows(member) { continue } addNode(member) @@ -428,7 +428,7 @@ func (e *Engine) classHierarchyWalk( if neighbor == nil { continue } - if opts.WorkspaceID != "" && !opts.ScopeAllows(neighbor) { + if opts.hasScopeFilter() && !opts.ScopeAllows(neighbor) { continue } addEdge(ed) @@ -447,7 +447,7 @@ func (e *Engine) classHierarchyWalk( if neighbor == nil { continue } - if opts.WorkspaceID != "" && !opts.ScopeAllows(neighbor) { + if opts.hasScopeFilter() && !opts.ScopeAllows(neighbor) { continue } addEdge(ed) diff --git a/internal/query/engine.go b/internal/query/engine.go index 5bbf88a1..77f1e78a 100644 --- a/internal/query/engine.go +++ b/internal/query/engine.go @@ -409,7 +409,7 @@ func (e *Engine) FindUsagesScoped(nodeID string, opts QueryOptions) *SubGraph { // find_usages on an Image returns workloads pulling it. if isUsageEdgeKind(edge.Kind) { from := fromByID[edge.From] - if opts.WorkspaceID != "" && !opts.ScopeAllows(from) { + if opts.hasScopeFilter() && (from == nil || !opts.ScopeAllows(from)) { continue } if opts.ExcludeTests && isTestSource(from) { @@ -423,7 +423,9 @@ func (e *Engine) FindUsagesScoped(nodeID string, opts QueryOptions) *SubGraph { } // Include the target node itself (already in the batch above). if n := fromByID[nodeID]; n != nil { - nodeMap[n.ID] = n + if !opts.hasScopeFilter() || opts.ScopeAllows(n) { + nodeMap[n.ID] = n + } } nodes := make([]*graph.Node, 0, len(nodeMap)) for _, n := range nodeMap { @@ -461,7 +463,7 @@ func (e *Engine) SearchSymbolsRanked(query string, limit int, opts QueryOptions, limit = 20 } fetchLimit := limit - if opts.WorkspaceID != "" { + if opts.hasScopeFilter() { fetchLimit = limit * 4 if fetchLimit > 200 { fetchLimit = 200 @@ -493,7 +495,7 @@ func (e *Engine) SearchSymbolsRanked(query string, limit int, opts QueryOptions, } } - if opts.WorkspaceID != "" { + if opts.hasScopeFilter() { kept := cands[:0] for _, c := range cands { if !opts.ScopeAllows(c.Node) { @@ -1069,6 +1071,10 @@ func (e *Engine) bfs(nodeID string, opts QueryOptions, forward bool, edgeKinds [ if opts.Limit <= 0 { opts.Limit = 50 } + seed := e.g.GetNode(nodeID) + if opts.hasScopeFilter() && (seed == nil || !opts.ScopeAllows(seed)) { + return &SubGraph{} + } bidir := edgeKinds == nil kindSet := make(map[graph.EdgeKind]bool, len(edgeKinds)) @@ -1085,7 +1091,7 @@ func (e *Engine) bfs(nodeID string, opts QueryOptions, forward bool, edgeKinds [ // agree by construction; a backend without it (or a capability error) // falls through to the Go walk, which stays the correctness oracle. if capStore, ok := e.g.(graph.BFSCapable); ok && !bidir && len(edgeKinds) > 0 && - opts.WorkspaceID == "" && !opts.ExcludeTests && !opts.IncludeDispatch { + !opts.hasScopeFilter() && !opts.ExcludeTests && !opts.IncludeDispatch { if sg, ok := e.bfsViaCapability(capStore, nodeID, opts, forward, edgeKinds, kindSet); ok { return sg } @@ -1103,11 +1109,11 @@ func (e *Engine) bfs(nodeID string, opts QueryOptions, forward bool, edgeKinds [ var boundaries []graph.EpistemicBoundary boundarySeen := map[string]bool{} - if n := e.g.GetNode(nodeID); n != nil { - // The seed always enters the result, regardless of scope — - // callers ask "what reaches X" with X already in mind. The - // scope check applies to neighbours discovered by traversal. - allNodes = append(allNodes, n) + if seed != nil { + // The seed enters only after the scope gate above; neighbours + // discovered by traversal pass through the same scope check in + // admit. + allNodes = append(allNodes, seed) } // admit is the single place edge/node bookkeeping lives, shared by @@ -1156,7 +1162,7 @@ func (e *Engine) bfs(nodeID string, opts QueryOptions, forward bool, edgeKinds [ } // Workspace/project scope: neighbours outside the bound scope are // dropped along with the edge that pointed at them. - if opts.WorkspaceID != "" && neighbor != nil && !opts.ScopeAllows(neighbor) { + if opts.hasScopeFilter() && neighbor != nil && !opts.ScopeAllows(neighbor) { return "" } allEdges = append(allEdges, edge) diff --git a/internal/query/scope_allows_test.go b/internal/query/scope_allows_test.go index 7eb6ebb8..9c15c964 100644 --- a/internal/query/scope_allows_test.go +++ b/internal/query/scope_allows_test.go @@ -86,6 +86,44 @@ func TestQueryOptions_ScopeAllows(t *testing.T) { node: node("vio", "web", "web"), want: false, }, + { + name: "repo allow nil does not narrow", + opts: QueryOptions{WorkspaceID: "gortex"}, + node: node("gortex", "core", "core"), + want: true, + }, + { + name: "repo allow match passes", + opts: QueryOptions{RepoAllow: map[string]bool{"core": true}}, + node: node("", "", "core"), + want: true, + }, + { + name: "repo allow mismatch is rejected", + opts: QueryOptions{RepoAllow: map[string]bool{"web": true}}, + node: node("", "", "core"), + want: false, + }, + { + name: "workspace project and repo allow compose", + opts: QueryOptions{ + WorkspaceID: "gortex", + ProjectID: "backend", + RepoAllow: map[string]bool{"payments": true}, + }, + node: node("gortex", "backend", "payments"), + want: true, + }, + { + name: "repo allow cannot rescue a project mismatch", + opts: QueryOptions{ + WorkspaceID: "gortex", + ProjectID: "backend", + RepoAllow: map[string]bool{"payments": true}, + }, + node: node("gortex", "frontend", "payments"), + want: false, + }, } for _, tt := range tests { diff --git a/internal/query/subgraph.go b/internal/query/subgraph.go index 21aa0906..4fcd799b 100644 --- a/internal/query/subgraph.go +++ b/internal/query/subgraph.go @@ -76,6 +76,12 @@ type QueryOptions struct { // be ambiguous (two workspaces could declare a project with the // same name). ProjectID string `json:"project_id,omitempty"` + // RepoAllow, when non-empty, restricts traversal to nodes whose + // RepoPrefix is present in the allow-set. Nil or empty preserves + // the legacy no-repo-filter behaviour. This is intentionally a + // soft breadth control inside any workspace boundary, not a + // replacement for caller-side workspace isolation. + RepoAllow map[string]bool `json:"repo_allow,omitempty"` // ExcludeTests, when true, drops edges originating from a function // flagged as a test (Node.Meta["is_test"] = true) — set by the // indexer's test-edge pass. Lets find_usages / get_callers answer @@ -213,24 +219,35 @@ type SearchTimings struct { // boundary on by-id and whole-graph handlers that don't route through // the engine's scoped traversal. func (o QueryOptions) ScopeAllows(n *graph.Node) bool { - if o.WorkspaceID == "" || n == nil { + if n == nil { return true } - ws := n.WorkspaceID - if ws == "" { - ws = n.RepoPrefix + if o.WorkspaceID != "" { + ws := n.WorkspaceID + if ws == "" { + ws = n.RepoPrefix + } + if ws != o.WorkspaceID { + return false + } + if o.ProjectID != "" { + proj := n.ProjectID + if proj == "" { + proj = n.RepoPrefix + } + if proj != o.ProjectID { + return false + } + } } - if ws != o.WorkspaceID { + if len(o.RepoAllow) > 0 && !o.RepoAllow[n.RepoPrefix] { return false } - if o.ProjectID == "" { - return true - } - proj := n.ProjectID - if proj == "" { - proj = n.RepoPrefix - } - return proj == o.ProjectID + return true +} + +func (o QueryOptions) hasScopeFilter() bool { + return o.WorkspaceID != "" || len(o.RepoAllow) > 0 } // FilterByMinTier drops edges whose Origin rank is below minTier. @@ -496,11 +513,12 @@ type WalkOptions struct { // token budget would allow deeper expansion. A non-positive value // falls back to a built-in default. MaxDepth int - // WorkspaceID / ProjectID scope the traversal exactly as the + // WorkspaceID / ProjectID / RepoAllow scope the traversal exactly as the // matching QueryOptions fields do — neighbours outside the scope // are dropped along with the edge that reached them. WorkspaceID string ProjectID string + RepoAllow map[string]bool // CommunityID, when non-empty, constrains the walk to a single // detected community: a neighbour is admitted only when it has no // community membership (a structural node Leiden never partitioned @@ -521,5 +539,5 @@ type WalkOptions struct { // scope. Mirrors QueryOptions.ScopeAllows so budgeted walks enforce the // same boundary without duplicating the fallback rules. func (o WalkOptions) scopeAllows(n *graph.Node) bool { - return QueryOptions{WorkspaceID: o.WorkspaceID, ProjectID: o.ProjectID}.ScopeAllows(n) + return QueryOptions{WorkspaceID: o.WorkspaceID, ProjectID: o.ProjectID, RepoAllow: o.RepoAllow}.ScopeAllows(n) } diff --git a/internal/query/walk.go b/internal/query/walk.go index 13f365b4..6d6687ea 100644 --- a/internal/query/walk.go +++ b/internal/query/walk.go @@ -155,10 +155,13 @@ func (e *Engine) WalkBudgeted(startID string, opts WalkOptions) *SubGraph { } visited[startID] = true - // byteEstimate tracks the running encoded size. The seed always - // enters the result, so it is counted up front. + // byteEstimate tracks the running encoded size. The seed enters + // only after the scope gate, so it is counted up front when kept. byteEstimate := 0 if n := e.g.GetNode(startID); n != nil { + if !opts.scopeAllows(n) { + return &SubGraph{} + } allNodes = append(allNodes, n) byteEstimate += walkTokenEstimate } diff --git a/internal/serverstack/shared_server.go b/internal/serverstack/shared_server.go index 55943460..45cda142 100644 --- a/internal/serverstack/shared_server.go +++ b/internal/serverstack/shared_server.go @@ -510,17 +510,16 @@ func NewSharedServer(cfg SharedServerConfig) (*SharedServer, error) { Allow: conf.MCP.Tools.Allow, Deny: conf.MCP.Tools.Deny, } - var multiOpts []gortexmcp.MultiRepoOptions - if mi != nil || cm != nil { - multiOpts = append(multiOpts, gortexmcp.MultiRepoOptions{ - MultiIndexer: mi, - ConfigManager: cm, - ActiveProject: cfg.ActiveProject, - ScopeWorkspace: cfg.ScopeWorkspace, - ScopeProject: cfg.ScopeProject, - ToolPolicy: &toolPolicyCfg, - }) - } + scopeIntentDefaults := conf.Scope.IntentDefaults + multiOpts := []gortexmcp.MultiRepoOptions{{ + MultiIndexer: mi, + ConfigManager: cm, + ActiveProject: cfg.ActiveProject, + ScopeWorkspace: cfg.ScopeWorkspace, + ScopeProject: cfg.ScopeProject, + ToolPolicy: &toolPolicyCfg, + ScopeIntentDefaults: &scopeIntentDefaults, + }} eng := query.NewEngine(g) eng.SetSearchProvider(idx.Search)