Files
zzet--gortex/internal/mcp/tools_analyze_doc_staleness.go
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

174 lines
5.3 KiB
Go

package mcp
import (
"context"
"sort"
"github.com/mark3labs/mcp-go/mcp"
"github.com/zzet/gortex/internal/graph"
)
// docStalenessLink is one content/rationale -> code link and its assessed state.
type docStalenessLink struct {
Symbol string `json:"symbol"`
State string `json:"state"` // dangling | pending | live
}
// docStalenessRow rolls up one knowledge source (a content chunk or rationale
// node) and the staleness of the symbols it motivates.
type docStalenessRow struct {
Source string `json:"source"`
File string `json:"file,omitempty"`
Name string `json:"name,omitempty"`
WorstState string `json:"worst_state"`
Dangling int `json:"dangling"`
Pending int `json:"pending"`
TotalRefs int `json:"total_refs"`
Links []docStalenessLink `json:"links,omitempty"`
}
// docStalenessResult is the advisory rollup: knowledge sources that reference
// code which no longer exists (dangling) or isn't indexed yet (pending).
type docStalenessResult struct {
Stale []docStalenessRow `json:"stale"`
AssessedLinks int `json:"assessed_links"`
Note string `json:"note,omitempty"`
}
// analyzeDocStaleness walks every EdgeMotivates and flags the knowledge sources
// whose code references have gone stale. It is deterministic and zero-FP by
// design: a "dangling" link names a symbol that is genuinely absent from the
// graph, a "pending" link points at an unresolved (e.g. cross-repo, not-yet-
// indexed) target. Timestamp / signature-materiality drift (a doc that predates
// a code change) is a future, blame-gated enhancement; this signal needs no git
// history and never false-positives.
func analyzeDocStaleness(g graph.Store, limit int) docStalenessResult {
type acc struct {
row docStalenessRow
links []docStalenessLink
}
bySource := map[string]*acc{}
assessed := 0
for _, e := range g.AllEdges() {
if e == nil || e.Kind != graph.EdgeMotivates {
continue
}
assessed++
state := "live"
if g.GetNode(e.To) == nil {
if graph.IsUnresolvedTarget(e.To) {
state = "pending"
} else {
state = "dangling"
}
}
a := bySource[e.From]
if a == nil {
a = &acc{row: docStalenessRow{Source: e.From}}
if n := g.GetNode(e.From); n != nil {
a.row.File = n.FilePath
a.row.Name = n.Name
}
bySource[e.From] = a
}
a.row.TotalRefs++
switch state {
case "dangling":
a.row.Dangling++
case "pending":
a.row.Pending++
}
a.links = append(a.links, docStalenessLink{Symbol: e.To, State: state})
}
var stale []docStalenessRow
for _, a := range bySource {
if a.row.Dangling == 0 && a.row.Pending == 0 {
continue
}
if a.row.Dangling > 0 {
a.row.WorstState = "dangling"
} else {
a.row.WorstState = "pending"
}
a.row.Links = a.links
stale = append(stale, a.row)
}
sort.Slice(stale, func(i, j int) bool {
if stale[i].Dangling != stale[j].Dangling {
return stale[i].Dangling > stale[j].Dangling
}
if stale[i].Pending != stale[j].Pending {
return stale[i].Pending > stale[j].Pending
}
return stale[i].Source < stale[j].Source
})
if limit > 0 && len(stale) > limit {
stale = stale[:limit]
}
res := docStalenessResult{Stale: stale, AssessedLinks: assessed}
if assessed == 0 {
res.Note = "No content->code links in the graph yet. Index a repo whose documents (ADRs, specs, slides) reference code, or record decisions with store_memory."
}
return res
}
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)
}