Files
wehub-resource-sync a06f331eb8
CI / benchmark (push) Has been skipped
install-script / posix-syntax (push) Successful in 6m1s
CI / build-onnx (push) Failing after 6m43s
init-smoke / dry-run (push) Failing after 15m57s
security / govulncheck (push) Has been cancelled
security / trivy-fs (push) Has been cancelled
CI / test (1.26, ubuntu-latest) (push) Has been cancelled
Scorecard supply-chain security / Scorecard analysis (push) Has been cancelled
CI / test (1.26, macos-latest) (push) Has been cancelled
CI / build-windows (push) Has been cancelled
CI / lint (push) Has been cancelled
install-script / powershell-syntax (push) Has been cancelled
install-script / install (macos-14) (push) Has been cancelled
install-script / install (ubuntu-latest) (push) Has been cancelled
chore: import upstream snapshot with attribution
2026-07-13 12:33:42 +08:00

580 lines
18 KiB
Go
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
package mcp
import (
"context"
"crypto/sha256"
"encoding/hex"
"fmt"
"path/filepath"
"sort"
"strings"
"sync"
"github.com/zzet/gortex/internal/daemon"
"github.com/zzet/gortex/internal/graph"
"github.com/zzet/gortex/internal/indexer"
"github.com/zzet/gortex/internal/parser"
"github.com/zzet/gortex/internal/query"
)
// overlayViewCtxKey is the unexported context-key type for the
// per-request OverlaidView. The view is set by the tool-handler
// middleware (`wrapToolHandler`) and read by tool handlers via
// `s.readerFor(ctx)`. Unexported so external code can't smuggle a
// view onto an unrelated context.
type overlayViewCtxKey struct{}
// WithOverlayView returns a child context carrying the
// shadow-graph view for the current `tools/call`. Tool handlers
// should not call this directly — `wrapToolHandler` is responsible
// for installing the view based on the calling session's overlay
// state.
func WithOverlayView(ctx context.Context, v *graph.OverlaidView) context.Context {
if v == nil {
return ctx
}
return context.WithValue(ctx, overlayViewCtxKey{}, v)
}
// OverlayViewFromContext returns the per-request view, or nil when
// the call isn't overlay-active. Tool handlers prefer
// `s.readerFor(ctx)` which folds this with the base graph; direct
// callers (the diff tool) use this when they need both base and
// overlay sides explicitly.
func OverlayViewFromContext(ctx context.Context) *graph.OverlaidView {
if ctx == nil {
return nil
}
if v, ok := ctx.Value(overlayViewCtxKey{}).(*graph.OverlaidView); ok {
return v
}
return nil
}
// readerFor returns the graph.Reader the calling tool handler should
// read through. When ctx carries an overlay view, that view is the
// reader and base is consulted through it. Otherwise the base graph
// is returned directly. The single helper keeps every read site
// overlay-aware with one line of plumbing.
//
// Never nil unless the server itself has no graph wired (test-only
// state). Callers that hot-loop on this should hoist the lookup —
// the helper is cheap but the indirection still matters at million-
// edge scales.
func (s *Server) readerFor(ctx context.Context) graph.Reader {
if v := OverlayViewFromContext(ctx); v != nil {
return v
}
return s.graph
}
// engineFor returns the query engine scoped to the calling request's
// graph reader. For non-overlay calls this is `s.engine` unchanged;
// for overlay-active calls the returned engine reads through the
// overlay view, so engine-level walks (`FindUsages`, `GetCallers`,
// `GetCallChain`, dependency / dependent walkers, hierarchy …) all
// see the editor-buffer state automatically.
//
// Cheap: WithReader is a shallow clone (one struct copy, shares
// search provider and rerank pipeline). Safe to call inside hot
// tool-handler paths.
func (s *Server) engineFor(ctx context.Context) *query.Engine {
if s == nil || s.engine == nil {
return nil
}
if v := OverlayViewFromContext(ctx); v != nil {
return s.engine.WithReader(v)
}
return s.engine
}
// overlayLayerCacheEntry is one (sessionID, content-hash sum) bucket
// in s.overlayLayerCache. The hash is over (sorted) overlay files'
// (path, content, deleted, base_sha) tuples; identical pushes from a
// long sequence of tool calls hit the same entry and reuse the same
// parsed layer without re-running the per-language extractors.
type overlayLayerCacheEntry struct {
hash string
layer *graph.OverlayLayer
// Files captured in the entry, for invalidation lookups and for
// the diff tool's enumeration.
files []string
}
// buildOverlayViewForCtx is the per-request entry called by
// wrapToolHandler. Returns (nil, nil) for non-overlay sessions, the
// overlay-view for overlay-active sessions, or (nil, err) when drift
// detection trips so the client knows to refresh and resubmit.
func (s *Server) buildOverlayViewForCtx(ctx context.Context) (*graph.OverlaidView, error) {
if s == nil || s.overlays == nil {
return nil, nil
}
sessID := SessionIDFromContext(ctx)
if sessID == "" {
return nil, nil
}
if s.overlays.FileCount(sessID) == 0 {
return nil, nil
}
_, files, err := s.overlays.SnapshotFor(sessID)
if err != nil {
// Session evaporated. Fast-path the non-overlay route.
return nil, nil
}
if len(files) == 0 {
return nil, nil
}
// Drift check up front for every overlay that carries a BaseSHA.
// We do it here, before parsing, so a stale overlay never costs
// the extractor time and the client gets a clear error.
for _, ov := range files {
if ov.BaseSHA == "" {
continue
}
abs, resolveErr := s.resolveOverlayAbsPath(ov.Path)
if resolveErr != nil {
return nil, resolveErr
}
if abs == "" {
continue
}
if !overlaySHAMatches(abs, ov.BaseSHA) {
return nil, fmt.Errorf("%w: %s", daemon.ErrOverlayDrift, ov.Path)
}
}
hash := hashOverlayFiles(files)
// Cache hit: same session pushed the same buffers; reuse the
// parsed layer. The cache stores up to one entry per session; a
// changed content hash evicts the prior entry.
if v, ok := s.overlayLayerCache.Load(sessID); ok {
if entry := v.(*overlayLayerCacheEntry); entry.hash == hash {
return graph.NewOverlaidView(s.graph, entry.layer), nil
}
s.overlayLayerCache.Delete(sessID)
}
// Cache miss: parse + resolve under a per-server build mutex so
// two requests for the same fresh content don't duplicate the
// extractor work. We re-check the cache under the lock.
s.overlayLayerBuildMu.Lock()
defer s.overlayLayerBuildMu.Unlock()
if v, ok := s.overlayLayerCache.Load(sessID); ok {
if entry := v.(*overlayLayerCacheEntry); entry.hash == hash {
return graph.NewOverlaidView(s.graph, entry.layer), nil
}
}
layer, paths, err := s.constructOverlayLayer(files)
if err != nil {
return nil, err
}
if layer == nil {
return nil, nil
}
s.overlayLayerCache.Store(sessID, &overlayLayerCacheEntry{
hash: hash,
layer: layer,
files: paths,
})
return graph.NewOverlaidView(s.graph, layer), nil
}
// resolveOverlayAbsPath turns the overlay's caller-supplied path into
// the absolute filesystem path the indexer would have used. Accepts
// absolute paths, repo-prefixed paths (multi-repo), and repo-relative
// paths (single-repo). Returns ("", nil) for paths that don't
// resolve to any tracked workspace — those overlays are silently
// skipped, matching how the on-disk path treats untracked files.
func (s *Server) resolveOverlayAbsPath(p string) (string, error) {
if p == "" {
return "", fmt.Errorf("overlay path is empty")
}
if filepath.IsAbs(p) {
return filepath.Clean(p), nil
}
if s.multiIndexer != nil {
if abs := s.multiIndexer.ResolveFilePath(p); abs != "" {
return abs, nil
}
}
if s.indexer != nil {
if root := s.indexer.RootPath(); root != "" {
return filepath.Join(root, p), nil
}
}
return "", nil
}
// resolveOverlayGraphPath turns the overlay path into the
// `graph_path` form (repo-prefixed in multi-repo mode, repo-relative
// in single-repo mode) — the form `GetFileNodes` and friends use.
func (s *Server) resolveOverlayGraphPath(p, absPath string) string {
if s.multiIndexer != nil {
if prefix := s.multiIndexer.RepoForFile(absPath); prefix != "" {
if idx, _ := s.multiIndexer.IndexerForFile(absPath); idx != nil {
if root := idx.RootPath(); root != "" {
if rel, err := filepath.Rel(root, absPath); err == nil {
return prefix + "/" + filepath.ToSlash(rel)
}
}
}
}
}
if s.indexer != nil {
if root := s.indexer.RootPath(); root != "" {
if rel, err := filepath.Rel(root, absPath); err == nil {
return filepath.ToSlash(rel)
}
}
}
// Fall back to caller-supplied path — this is the single-repo
// repo-relative case where p is already the graph_path.
return filepath.ToSlash(p)
}
// pickIndexerForPath chooses the per-repo Indexer (multi-repo) or
// the single Indexer (single-repo) that owns absPath. Returns nil
// when no Indexer owns the path (the overlay is silently skipped
// upstream).
func (s *Server) pickIndexerForPath(absPath string) *indexer.Indexer {
if s.multiIndexer != nil {
if idx, _ := s.multiIndexer.IndexerForFile(absPath); idx != nil {
return idx
}
}
if s.indexer != nil {
if root := s.indexer.RootPath(); root != "" {
if rel, err := filepath.Rel(root, absPath); err == nil && !strings.HasPrefix(rel, "..") {
return s.indexer
}
}
}
return nil
}
// constructOverlayLayer is the parse-and-resolve pass. For each
// overlay file:
//
// - Resolve the absolute and graph-prefixed paths.
// - For deleted: true overlays, mark the graph path as a tombstone
// and continue (no parsing).
// - For content overlays, look up the per-language extractor via
// the indexer's parser.Registry and call Extract on the buffer.
// - Apply the repo prefix to the result so IDs match base's
// `<repo-prefix>/<file>::<symbol>` shape.
// - Add the parsed nodes + edges to the layer.
// - Track removed-by-overlay symbols (names that existed in base's
// view of the file but disappeared) so FindNodesByName drops
// those base hits.
//
// After parsing all files, run a local resolver pass that rewrites
// every `unresolved::*` edge in the overlay to point at a real node
// when one exists in `(layer base)`. The pass is conservative —
// simple name resolution — but covers the common cases: direct
// function calls, method calls in the same file, intra-package
// references.
func (s *Server) constructOverlayLayer(files []daemon.OverlayFile) (*graph.OverlayLayer, []string, error) {
if s.graph == nil {
return nil, nil, nil
}
layer := graph.NewOverlayLayer()
var coveredPaths []string
for _, ov := range files {
absPath, err := s.resolveOverlayAbsPath(ov.Path)
if err != nil {
return nil, nil, err
}
if absPath == "" {
continue // untracked file — silent skip, matches disk path
}
graphPath := s.resolveOverlayGraphPath(ov.Path, absPath)
coveredPaths = append(coveredPaths, graphPath)
if ov.Deleted {
// Tombstone: hide every base node for this file.
for _, n := range s.graph.GetFileNodes(graphPath) {
layer.MarkRemoved(n.Name, n.ID)
}
layer.MarkFile(graphPath, true)
continue
}
idx := s.pickIndexerForPath(absPath)
if idx == nil {
continue
}
reg := idx.Registry()
if reg == nil {
continue
}
lang, ok := reg.DetectLanguageContent(absPath, []byte(ov.Content))
if !ok {
continue
}
ext, _ := reg.GetByLanguage(lang)
if ext == nil {
continue
}
root := idx.RootPath()
relPath := graphPath
if idx.RepoPrefix() != "" {
relPath = strings.TrimPrefix(graphPath, idx.RepoPrefix()+"/")
} else if root != "" {
if r, err := filepath.Rel(root, absPath); err == nil {
relPath = filepath.ToSlash(r)
}
}
result, err := ext.Extract(relPath, []byte(ov.Content))
if err != nil {
return nil, nil, fmt.Errorf("overlay parse %s: %w", ov.Path, err)
}
// Track which base IDs disappear under the overlay so
// FindNodesByName / GetInEdges filter them. Build the set
// first from base, then mark every base ID that the overlay
// did NOT re-emit (by ID equality).
baseIDsByName := map[string]map[string]bool{}
for _, n := range s.graph.GetFileNodes(graphPath) {
set, ok := baseIDsByName[n.Name]
if !ok {
set = make(map[string]bool)
baseIDsByName[n.Name] = set
}
set[n.ID] = true
}
overlayIDsByName := map[string]map[string]bool{}
applyRepoPrefixToResult(result, idx.RepoPrefix())
layer.MarkFile(graphPath, false)
for _, n := range result.Nodes {
layer.AddNode(graphPath, n)
set, ok := overlayIDsByName[n.Name]
if !ok {
set = make(map[string]bool)
overlayIDsByName[n.Name] = set
}
set[n.ID] = true
}
for _, e := range result.Edges {
layer.AddEdge(e)
}
// Names that existed in base but were not re-emitted by the
// overlay (same name, different ID, or absent entirely) get
// marked removed so FindNodesByName filters the base hits.
for name, baseIDs := range baseIDsByName {
overlayIDs := overlayIDsByName[name]
for id := range baseIDs {
if !overlayIDs[id] {
layer.MarkRemoved(name, id)
}
}
}
}
if len(coveredPaths) == 0 {
return nil, nil, nil
}
sort.Strings(coveredPaths)
// Local resolver pass: rewrite unresolved overlay edges to point
// at concrete IDs whenever a single best match exists in
// (overlay base).
s.resolveOverlayEdges(layer)
return layer, coveredPaths, nil
}
// applyRepoPrefixToResult prepends repoPrefix to every node/edge in
// an extraction result so IDs match base's shape in multi-repo mode.
// Mirrors `Indexer.applyRepoPrefix` but kept here to avoid having to
// expose that helper to non-indexer packages.
func applyRepoPrefixToResult(result *parser.ExtractionResult, repoPrefix string) {
if result == nil || repoPrefix == "" {
return
}
for _, n := range result.Nodes {
if n == nil {
continue
}
n.ID = repoPrefix + "/" + n.ID
if n.FilePath != "" {
n.FilePath = repoPrefix + "/" + n.FilePath
}
n.RepoPrefix = repoPrefix
}
for _, e := range result.Edges {
if e == nil {
continue
}
e.From = repoPrefix + "/" + e.From
if !strings.HasPrefix(e.To, unresolvedPrefix) {
e.To = repoPrefix + "/" + e.To
}
}
}
// unresolvedPrefix matches the resolver's prefix for placeholder
// edge targets. Kept as a package constant so resolveOverlayEdges
// can recognise placeholders without importing the resolver package
// (which would create a layering cycle through the indexer).
const unresolvedPrefix = "unresolved::"
// resolveOverlayEdges runs a conservative local resolver pass over
// the overlay layer's edges. For each placeholder `unresolved::*`
// edge:
//
// - Strip the `unresolved::<kind>::` prefix to recover the target
// name (and optional fully-qualified suffix).
// - Look the name up in (layer.nodesByName base.FindNodesByName).
// Prefer overlay matches over base; prefer a single unambiguous
// match over multiple.
// - On a unique match, rewrite the edge's To and re-index it in
// the layer's outEdges / inEdges maps.
//
// The pass deliberately does NOT replicate the full resolver
// (interface dispatch, import-path attribution, dataflow, etc.) —
// overlay buffers are transient and the common case the editor
// cares about is "I added a call to Foo; does find_usages of Foo
// now include this site?" Direct name resolution covers that.
func (s *Server) resolveOverlayEdges(layer *graph.OverlayLayer) {
if layer == nil {
return
}
// Collect every From → []Edge that the layer holds. We iterate
// over a copy of the map so we can rewrite layer edges
// in-place via AddEdge / removal pattern (layer is meant
// to be append-only post-construction; the resolver pass runs
// before the layer is handed to the View, so we still own it).
for _, edges := range layer.OutEdgesByFromAll() {
for _, e := range edges {
if !strings.HasPrefix(e.To, unresolvedPrefix) {
continue
}
target := strings.TrimPrefix(e.To, unresolvedPrefix)
// Strip kind segment if present (e.g. "call::FooBar").
if i := strings.Index(target, "::"); i > 0 {
target = target[i+2:]
}
// Strip trailing argument-count / disambiguator hints.
if i := strings.Index(target, "@"); i > 0 {
target = target[:i]
}
if target == "" {
continue
}
resolved := s.lookupOverlayTarget(layer, target)
if resolved == "" {
continue
}
e.To = resolved
}
}
// Rebuild the layer's inEdges index now that targets may have
// changed. The layer exposes a Rebuild helper so we don't have
// to know the internal map shape.
layer.RebuildInEdges()
}
// lookupOverlayTarget tries to find a unique node with the given
// short name in (layer base). Returns the node ID on a unique
// match, empty string otherwise. Tied matches return empty so the
// edge stays as a placeholder rather than picking the wrong target.
func (s *Server) lookupOverlayTarget(layer *graph.OverlayLayer, name string) string {
overlay := layer.NodesByName(name)
if len(overlay) == 1 {
return overlay[0].ID
}
if len(overlay) > 1 {
return ""
}
if s.graph == nil {
return ""
}
hits := s.graph.FindNodesByName(name)
// Drop hits whose file is overlaid AND whose ID wasn't kept by
// the overlay — those are now-deleted symbols.
keep := hits[:0:0]
for _, n := range hits {
if layer.HasFile(graph.IDFile(n.ID)) {
if !layer.HasNode(n.ID) {
continue
}
}
keep = append(keep, n)
}
if len(keep) == 1 {
return keep[0].ID
}
return ""
}
// hashOverlayFiles produces a stable content-hash of an overlay
// file set so the cache can detect "same set, reuse parse".
func hashOverlayFiles(files []daemon.OverlayFile) string {
sorted := make([]daemon.OverlayFile, len(files))
copy(sorted, files)
sort.Slice(sorted, func(i, j int) bool { return sorted[i].Path < sorted[j].Path })
h := sha256.New()
for _, f := range sorted {
fmt.Fprintf(h, "%s\x00%t\x00%s\x00", f.Path, f.Deleted, f.BaseSHA)
_, _ = h.Write([]byte(f.Content))
_, _ = h.Write([]byte{0})
}
return hex.EncodeToString(h.Sum(nil))
}
// overlayContentFor returns the editor-buffer content for an
// absolute path in the calling session's overlay, if any. Used by
// source-reading handlers (get_symbol_source, get_editing_context,
// smart_context) to substitute the overlay's text for the on-disk
// file when the request is overlay-active. Returns (content, true)
// on a hit, ("", false) otherwise — including the deleted-overlay
// case, since a tombstone has no content to return and callers
// should treat the file as absent.
func (s *Server) overlayContentFor(ctx context.Context, absPath string) (string, bool) {
if s == nil || s.overlays == nil || ctx == nil {
return "", false
}
sessID := SessionIDFromContext(ctx)
if sessID == "" {
return "", false
}
if s.overlays.FileCount(sessID) == 0 {
return "", false
}
_, files, err := s.overlays.SnapshotFor(sessID)
if err != nil || len(files) == 0 {
return "", false
}
cleanedAbs := filepath.Clean(absPath)
for _, ov := range files {
if ov.Deleted {
continue
}
ovAbs, _ := s.resolveOverlayAbsPath(ov.Path)
if ovAbs == "" {
continue
}
if filepath.Clean(ovAbs) == cleanedAbs {
return ov.Content, true
}
}
return "", false
}
// overlayCacheInvalidate drops the cached layer for a session. Called
// by overlay_push / overlay_delete / overlay_drop so the next tool
// call re-parses with the fresh buffer state.
func (s *Server) overlayCacheInvalidate(sessID string) {
if s == nil || sessID == "" {
return
}
s.overlayLayerCache.Delete(sessID)
}
// Compile-time sanity: a sync.Mutex usage placeholder so future
// linter-driven import pruning doesn't strip the package.
var _ sync.Mutex