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
174 lines
5.3 KiB
Go
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)
|
|
}
|