项目文件夹

文件
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

411 行
14 KiB
Go

package mcp
import (
"encoding/json"
"os"
"path/filepath"
"slices"
"strings"
"github.com/mark3labs/mcp-go/mcp"
"github.com/zzet/gortex/internal/graph"
"github.com/zzet/gortex/internal/indexer"
)
// freshnessRiderFor returns a small structured freshness block for a
// file-reading tool whose target file has changed on disk since it was
// indexed — so an agent reading a just-edited file sees, inline, that the
// graph view may lag the working tree. It returns nil (no rider, zero
// extra tokens) for the overwhelmingly common fresh case, for non-file
// tools, and in multi-repo mode (where the legacy single-indexer's
// staleness signal is all-noise — per-repo watchers own freshness there).
//
// The check is O(1): one map lookup + one stat on the single file the
// tool targets, only for the handful of read/source tools.
func (s *Server) freshnessRiderFor(toolName string, req mcp.CallToolRequest) map[string]any {
if s.indexer == nil && s.multiIndexer == nil {
return nil
}
if os.Getenv("GORTEX_NO_FRESHNESS_RIDER") == "1" {
return nil
}
raw := targetRepoFileRaw(toolName, req)
if raw == "" {
return nil
}
// Route the target to its OWNING indexer. In multi-repo mode this is the
// per-repo sub-indexer that tracks the file's own mtimes / SHA — so the
// staleness signal is reclaimed in the flagship deployment instead of
// being suppressed wholesale. In single-repo mode it is the lone indexer.
owner, repoRel := s.indexerForRel(raw)
if owner == nil || repoRel == "" {
return nil
}
state := owner.TrackedFileState(repoRel)
mismatch := s.detectWorktreeMismatch()
// Extractor-version staleness: which languages' extractors this binary
// bumped since the OWNING repo was indexed. Fetched alongside the index
// state so a graph indexed by an older extractor is flagged even when the
// touched file's content is unchanged — per-language, so the advisory
// names the exact languages to reindex rather than implying a full rebuild.
var indexState graph.RepoIndexState
var haveState bool
var staleLangs []string
if r, ok := graph.Store(s.graph).(graph.RepoIndexStateReader); ok {
if st, found, _ := r.GetRepoIndexState(owner.RepoPrefix()); found {
indexState, haveState = st, true
staleLangs = indexer.ExtractorVersionStaleLangs(st.ExtractorVersions)
}
}
// Whole-index "frozen" banner: when the file watcher is degraded (inotify
// or FD exhaustion) the graph may silently lag every live edit, not just
// this file — a condition distinct from per-file staleness. Surface it even
// when the targeted file itself looks fresh.
frozen := s.watchDegradedReason()
if state == indexer.FileFresh && !mismatch && len(staleLangs) == 0 && frozen == "" {
return nil
}
out := map[string]any{"file": repoRel}
if frozen != "" {
out["index_frozen"] = true
out["index_frozen_hint"] = frozen
}
if p := owner.RepoPrefix(); p != "" {
out["repo"] = p
}
switch state {
case indexer.FileStale:
out["stale"] = true
out["hint"] = "this file changed on disk since it was last indexed; the graph view may lag the working tree"
if haveState {
if indexState.IndexedSHA != "" {
out["indexed_sha"] = shortFreshSHA(indexState.IndexedSHA)
}
if indexState.Dirty {
out["working_tree_dirty_at_index"] = true
}
}
case indexer.FileMissing:
out["missing"] = true
out["hint"] = "this file is recorded in the graph but no longer exists on disk; it was deleted or moved since indexing"
}
if len(staleLangs) > 0 {
out["extractor_stale_langs"] = staleLangs
if fileLang := indexer.ExtractorLangForFile(repoRel); fileLang != "" && slices.Contains(staleLangs, fileLang) {
out["extractor_stale_hint"] = "this file's language extractor was upgraded since indexing; reindex to pick up the newer extraction (gortex index .)"
}
}
if mismatch {
out["worktree_mismatch"] = true
out["worktree_hint"] = "the working directory is a linked git worktree the indexed graph does not cover — results reflect another checkout"
}
return out
}
// watchDegradedReason returns the file watcher's degraded reason (inotify / FD
// exhaustion) when the index may be silently frozen, or "" when watching is
// healthy or unavailable. Read through an interface assertion so the rider
// stays decoupled from the concrete *indexer.Watcher behind the server's
// watcher field.
func (s *Server) watchDegradedReason() string {
if s.watcher == nil {
return ""
}
if dr, ok := s.watcher.(interface{ DegradedReason() string }); ok {
return dr.DegradedReason()
}
return ""
}
// indexerForRel routes a graph file path (as it appears in tool output or
// requests — repo-prefixed in multi-repo mode) to the per-repo indexer that
// owns it, returning that indexer and the repo-relative subpath its mtime keys
// use. In single-repo mode it returns the lone indexer with the configured
// prefix stripped. Returns (nil, "") when the path maps to no tracked repo.
func (s *Server) indexerForRel(graphPath string) (*indexer.Indexer, string) {
graphPath = filepath.ToSlash(graphPath)
if s.multiIndexer == nil {
if s.indexer == nil {
return nil, ""
}
rel := graphPath
if p := s.indexer.RepoPrefix(); p != "" {
rel = strings.TrimPrefix(rel, p+"/")
}
return s.indexer, rel
}
abs := s.multiIndexer.ResolveFilePath(graphPath)
if abs == "" {
return nil, ""
}
owner, prefix := s.multiIndexer.IndexerForFile(abs)
if owner == nil {
return nil, ""
}
return owner, strings.TrimPrefix(graphPath, prefix+"/")
}
// detectWorktreeMismatch reports (once per server, cached) whether the
// current working directory is a linked git worktree that the indexed graph
// does not cover — i.e. the agent is working in a worktree but the graph
// reflects a different checkout, so its results may not match the files on
// disk. Single-repo only; multi-repo routing owns its own worktree scoping.
func (s *Server) detectWorktreeMismatch() bool {
s.worktreeMismatchOnce.Do(func() {
if s.indexer == nil || s.multiIndexer != nil {
return
}
cwd, err := os.Getwd()
if err != nil || cwd == "" {
return
}
if !indexer.ResolveWorktree(cwd).IsWorktree {
return // not a linked worktree — nothing to warn about
}
root := s.indexer.RootPath()
if root == "" {
return
}
// The cwd is a linked worktree. If the indexed root does not contain
// it, the graph reflects another checkout.
cwdResolved, _ := filepath.EvalSymlinks(cwd)
rootResolved, _ := filepath.EvalSymlinks(root)
if cwdResolved == "" {
cwdResolved = cwd
}
if rootResolved == "" {
rootResolved = root
}
if !pathWithin(cwdResolved, rootResolved) {
s.worktreeMismatch = true
}
})
return s.worktreeMismatch
}
// pathWithin reports whether child is equal to or nested under parent, on a
// slash-segment boundary (so /a/bc is not "within" /a/b).
func pathWithin(child, parent string) bool {
child = filepath.Clean(child)
parent = filepath.Clean(parent)
if child == parent {
return true
}
return strings.HasPrefix(child, parent+string(filepath.Separator))
}
// targetRepoFileRaw extracts the single file a read tool targets, verbatim
// (no prefix stripping) so the caller can route it to its owning repo. Returns
// "" for tools that are not file-scoped.
func targetRepoFileRaw(toolName string, req mcp.CallToolRequest) string {
var raw string
switch toolName {
case "read_file", "get_file_summary", "get_editing_context":
raw = req.GetString("path", "")
case "get_symbol_source", "get_symbol":
id := req.GetString("id", "")
if i := strings.Index(id, "::"); i >= 0 {
raw = id[:i]
}
default:
return ""
}
if raw == "" {
return ""
}
return filepath.ToSlash(raw)
}
// targetRepoRelFile extracts the repo-relative path of the single file a
// read tool targets, or "" when the tool is not file-scoped. A leading
// repo prefix is stripped so the result matches the indexer's mtime keys;
// a path that does not match is simply reported not-stale (an unknown key
// reads as fresh), so imperfect normalization is safe.
func targetRepoRelFile(toolName string, req mcp.CallToolRequest, prefix string) string {
raw := targetRepoFileRaw(toolName, req)
if raw == "" {
return ""
}
if prefix != "" {
raw = strings.TrimPrefix(raw, prefix+"/")
}
return raw
}
// isFreshnessListTool reports whether a tool returns a list of graph hits
// whose underlying files should be swept for on-disk drift / deletion.
func isFreshnessListTool(name string) bool {
switch name {
case "search_symbols", "find_usages", "smart_context", "get_callers":
return true
}
return false
}
// freshnessPathKeys are the JSON keys whose string value names a file across
// the list-shaped tool results (search_symbols / find_usages / smart_context /
// get_callers).
var freshnessPathKeys = map[string]bool{
"file": true, "file_path": true, "path": true, "filePath": true,
}
// maxFreshnessSweep bounds how many distinct files one list result is swept
// for, so a pathological response can't turn the rider into a hot loop.
const maxFreshnessSweep = 256
// decorateListResultWithFreshness sweeps the file paths a list result
// references and attaches a `freshness` block naming the hits that are stale
// or missing on disk — each carrying its owning repo prefix plus (for stale)
// the indexed SHA / dirty provenance, so the signal is per-repo rather than a
// flat workspace banner. Only JSON-object payloads are touched; GCX / TOON /
// array wire formats the caller opted into pass through unchanged, and a clean
// sweep adds nothing.
func (s *Server) decorateListResultWithFreshness(res *mcp.CallToolResult) *mcp.CallToolResult {
if res == nil || (s.indexer == nil && s.multiIndexer == nil) {
return res
}
if os.Getenv("GORTEX_NO_FRESHNESS_RIDER") == "1" {
return res
}
text, ok := singleTextContent(res)
if !ok || text == "" {
return res
}
var asObj map[string]any
if json.Unmarshal([]byte(text), &asObj) != nil {
return res // GCX / TOON / array — not a JSON object
}
if _, exists := asObj["freshness"]; exists {
return res
}
var staleFiles, missingFiles []map[string]any
for _, p := range collectGraphFilePaths(asObj) {
owner, repoRel := s.indexerForRel(p)
if owner == nil || repoRel == "" {
continue
}
switch owner.TrackedFileState(repoRel) {
case indexer.FileStale:
staleFiles = append(staleFiles, s.freshFileProvenance(owner, repoRel, true))
case indexer.FileMissing:
missingFiles = append(missingFiles, s.freshFileProvenance(owner, repoRel, false))
}
}
if len(staleFiles) == 0 && len(missingFiles) == 0 {
return res
}
rider := map[string]any{}
if len(staleFiles) > 0 {
rider["stale_files"] = staleFiles
rider["stale_hint"] = "some result files changed on disk since indexing; re-read them before acting on the graph view"
}
if len(missingFiles) > 0 {
rider["missing_files"] = missingFiles
rider["missing_hint"] = "some result files are recorded in the graph but no longer on disk; they were deleted or moved since indexing"
}
asObj["freshness"] = rider
body, err := json.Marshal(asObj)
if err != nil {
return res
}
return rebuildTextResult(res, string(body))
}
// freshFileProvenance builds one stale/missing entry: the repo-relative file,
// its owning repo prefix, and — when withSHA — the repo's indexed SHA and
// working-tree-dirty flag. This per-repo provenance is the multi-repo upgrade
// over a single flat staleness banner.
func (s *Server) freshFileProvenance(owner *indexer.Indexer, repoRel string, withSHA bool) map[string]any {
entry := map[string]any{"file": repoRel}
if p := owner.RepoPrefix(); p != "" {
entry["repo"] = p
}
if withSHA {
if r, ok := graph.Store(s.graph).(graph.RepoIndexStateReader); ok {
if st, found, _ := r.GetRepoIndexState(owner.RepoPrefix()); found {
if st.IndexedSHA != "" {
entry["indexed_sha"] = shortFreshSHA(st.IndexedSHA)
}
if st.Dirty {
entry["working_tree_dirty_at_index"] = true
}
}
}
}
return entry
}
// collectGraphFilePaths walks a decoded list result and gathers the distinct
// graph-relative file paths referenced under the common path keys, bounded by
// maxFreshnessSweep. Absolute paths and directory values are skipped /
// harmless — they resolve to no tracked file.
func collectGraphFilePaths(v any) []string {
var out []string
seen := make(map[string]bool)
var walk func(any)
walk = func(node any) {
if len(out) >= maxFreshnessSweep {
return
}
switch t := node.(type) {
case map[string]any:
for k, val := range t {
if sv, ok := val.(string); ok {
if freshnessPathKeys[k] && sv != "" && !filepath.IsAbs(sv) && !seen[sv] {
seen[sv] = true
out = append(out, sv)
}
continue
}
walk(val)
}
case []any:
for _, e := range t {
walk(e)
}
}
}
walk(v)
return out
}
// decorateResultWithFreshness attaches the freshness rider to a JSON-object
// tool response under the "freshness" key. Non-JSON-object payloads
// (GCX / TOON / arrays) are left untouched — a best-effort hint must never
// reshape a compact wire format the caller opted into.
func decorateResultWithFreshness(res *mcp.CallToolResult, rider map[string]any) *mcp.CallToolResult {
if len(rider) == 0 {
return res
}
text, ok := singleTextContent(res)
if !ok || text == "" {
return res
}
var asObj map[string]any
if json.Unmarshal([]byte(text), &asObj) != nil {
return res
}
if _, exists := asObj["freshness"]; exists {
return res
}
asObj["freshness"] = rider
body, err := json.Marshal(asObj)
if err != nil {
return res
}
return rebuildTextResult(res, string(body))
}
// shortFreshSHA trims a git SHA to 12 chars for the rider.
func shortFreshSHA(sha string) string {
if len(sha) > 12 {
return sha[:12]
}
return sha
}