Files
zzet--gortex/internal/docs/docs.go
T
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

310 lines
8.5 KiB
Go

// Package docs generates a "living changelog + ownership + blame +
// stale code" bundle from the in-memory graph. Output is markdown or
// JSON; the CLI verb `gortex docs` and the MCP tool `generate_docs`
// both call into here.
//
// The package depends on:
// - graph.Graph (for symbol nodes and blame metadata).
// - optional indexer.HistoryProvider (for recent change events).
// - blame.EnrichGraph (callable via the BlameRunner injection).
//
// All four sections are independent — passing Sections lets the caller
// pick a subset (e.g. only recent changes for a post-commit hook).
package docs
import (
"sort"
"strings"
"time"
"github.com/zzet/gortex/internal/graph"
)
// HistoryEvent is the docs-package projection of one watcher event.
// Kept local so the package doesn't import internal/indexer.
type HistoryEvent struct {
FilePath string `json:"file_path"`
Kind string `json:"kind"`
NodesAdded int `json:"nodes_added"`
NodesRemoved int `json:"nodes_removed"`
Timestamp time.Time `json:"timestamp"`
}
// HistoryProvider is the minimal surface docs needs from the indexer.
// Both indexer.Watcher and indexer.MultiWatcher already implement
// HistorySince natively; the MCP / CLI callers adapt them through a
// thin shim.
type HistoryProvider interface {
HistorySince(since time.Time) []HistoryEvent
}
// BlameRunner re-runs git blame across the indexed repos and stamps
// meta.last_authored on every blame-eligible node. Returns the number
// of nodes enriched per repo prefix and an aggregate total.
type BlameRunner func() (total int, perRepo map[string]int, err error)
// Options controls what the bundle includes.
type Options struct {
// Since narrows recent changes. Zero means "last 7 days".
Since time.Duration
// Top caps every list section. Zero means 20.
Top int
// Sections is an ordered allow-list. Empty means all four:
// "recent", "ownership", "stale", "blame".
Sections []string
// PathPrefix restricts ownership/stale rows to this file prefix.
PathPrefix string
// MinSymbols filters ownership table.
MinSymbols int
// IncludeBlame triggers BlameRunner. False when blame metadata
// is already enriched and a re-run isn't desired.
IncludeBlame bool
// WorkspaceID restricts nodes to a single workspace. Empty
// means "all workspaces".
WorkspaceID string
}
// Bundle is the canonical structured result. RenderMarkdown / RenderJSON
// project it to one of the two wire formats.
type Bundle struct {
GeneratedAt time.Time `json:"generated_at"`
Since time.Time `json:"since"`
RecentChanges []HistoryEvent `json:"recent_changes,omitempty"`
OwnershipRows []OwnershipRow `json:"ownership,omitempty"`
StaleCodeRows []StaleCodeRow `json:"stale_code,omitempty"`
Blame *BlameSummary `json:"blame,omitempty"`
Sections []string `json:"sections"`
}
// OwnershipRow describes one author's stake in the codebase.
type OwnershipRow struct {
Email string `json:"email"`
Symbols int `json:"symbols"`
Files int `json:"files"`
Oldest int64 `json:"oldest_timestamp"`
Newest int64 `json:"newest_timestamp"`
}
// StaleCodeRow describes one stale symbol.
type StaleCodeRow struct {
ID string `json:"id"`
File string `json:"file"`
Line int `json:"line"`
Email string `json:"email"`
Commit string `json:"commit"`
Timestamp int64 `json:"timestamp"`
AgeDays int `json:"age_days"`
}
// BlameSummary captures the result of a blame re-run.
type BlameSummary struct {
Enriched int `json:"enriched"`
PerRepo map[string]int `json:"per_repo"`
Error string `json:"error,omitempty"`
}
// Deps bundles the runtime dependencies injected by the MCP/CLI layer.
type Deps struct {
Graph graph.Store
History HistoryProvider
Blame BlameRunner
}
// Generate assembles a Bundle by walking the graph and (when
// requested) calling the blame runner. The output is deterministic
// for a fixed graph + clock.
func Generate(deps Deps, opts Options) (*Bundle, error) {
if deps.Graph == nil {
return nil, errNilGraph
}
if opts.Since == 0 {
opts.Since = 7 * 24 * time.Hour
}
if opts.Top == 0 {
opts.Top = 20
}
if opts.MinSymbols == 0 {
opts.MinSymbols = 1
}
sections := opts.Sections
if len(sections) == 0 {
sections = []string{"recent", "ownership", "stale", "blame"}
}
want := make(map[string]bool, len(sections))
for _, s := range sections {
want[strings.ToLower(strings.TrimSpace(s))] = true
}
now := time.Now().UTC()
since := now.Add(-opts.Since)
bundle := &Bundle{
GeneratedAt: now,
Since: since,
Sections: sections,
}
// Recent changes — relies on a watcher being attached.
if want["recent"] && deps.History != nil {
evs := deps.History.HistorySince(since)
if len(evs) > opts.Top {
evs = evs[len(evs)-opts.Top:]
}
bundle.RecentChanges = evs
}
// Ownership / stale code — read blame metadata stamped on nodes.
if want["ownership"] || want["stale"] {
ownerRows, staleRows := walkNodes(deps.Graph, opts, now)
if want["ownership"] {
if len(ownerRows) > opts.Top {
ownerRows = ownerRows[:opts.Top]
}
bundle.OwnershipRows = ownerRows
}
if want["stale"] {
if len(staleRows) > opts.Top {
staleRows = staleRows[:opts.Top]
}
bundle.StaleCodeRows = staleRows
}
}
// Blame re-run (optional; usually run on-demand by the post-commit hook).
if want["blame"] && deps.Blame != nil && opts.IncludeBlame {
total, perRepo, err := deps.Blame()
sum := &BlameSummary{
Enriched: total,
PerRepo: perRepo,
}
if err != nil {
sum.Error = err.Error()
}
bundle.Blame = sum
}
return bundle, nil
}
// walkNodes does a single pass over symbol nodes and emits the
// ownership and stale-code tables in a single pass.
func walkNodes(g graph.Store, opts Options, now time.Time) ([]OwnershipRow, []StaleCodeRow) {
type ownerStats struct {
row OwnershipRow
fileSet map[string]struct{}
}
owners := make(map[string]*ownerStats)
var stale []StaleCodeRow
cutoffSec := now.Add(-365 * 24 * time.Hour).Unix() // default 365d
if opts.Since > 0 {
// stale uses a separate cutoff (always 365d by spec), keep simple.
cutoffSec = now.Add(-365 * 24 * time.Hour).Unix()
}
allowed := map[graph.NodeKind]struct{}{
graph.KindFunction: {},
graph.KindMethod: {},
}
for _, n := range g.AllNodes() {
if _, ok := allowed[n.Kind]; !ok {
continue
}
if opts.PathPrefix != "" && !strings.HasPrefix(n.FilePath, opts.PathPrefix) {
continue
}
if opts.WorkspaceID != "" {
ws := n.WorkspaceID
if ws == "" {
ws = n.RepoPrefix
}
if ws != opts.WorkspaceID {
continue
}
}
la, ok := n.Meta["last_authored"].(map[string]any)
if !ok {
continue
}
email, _ := la["email"].(string)
commit, _ := la["commit"].(string)
ts := tsFromMeta(la["timestamp"])
if ts == 0 {
continue
}
if email != "" {
s, ok := owners[email]
if !ok {
s = &ownerStats{
row: OwnershipRow{Email: email, Oldest: ts, Newest: ts},
fileSet: map[string]struct{}{},
}
owners[email] = s
}
s.row.Symbols++
s.fileSet[n.FilePath] = struct{}{}
if ts < s.row.Oldest {
s.row.Oldest = ts
}
if ts > s.row.Newest {
s.row.Newest = ts
}
}
if ts < cutoffSec {
stale = append(stale, StaleCodeRow{
ID: n.ID,
File: n.FilePath,
Line: n.StartLine,
Email: email,
Commit: commit,
Timestamp: ts,
AgeDays: int((now.Unix() - ts) / (24 * 3600)),
})
}
}
ownerRows := make([]OwnershipRow, 0, len(owners))
for _, s := range owners {
if s.row.Symbols < opts.MinSymbols {
continue
}
s.row.Files = len(s.fileSet)
ownerRows = append(ownerRows, s.row)
}
sort.Slice(ownerRows, func(i, j int) bool {
if ownerRows[i].Symbols != ownerRows[j].Symbols {
return ownerRows[i].Symbols > ownerRows[j].Symbols
}
return ownerRows[i].Email < ownerRows[j].Email
})
sort.Slice(stale, func(i, j int) bool {
return stale[i].Timestamp < stale[j].Timestamp
})
return ownerRows, stale
}
// tsFromMeta normalises int64 (in-process) vs float64 (gob-decoded
// snapshot) timestamps so this package works on both code paths —
// same trick the MCP handlers use today.
func tsFromMeta(v any) int64 {
switch x := v.(type) {
case int64:
return x
case float64:
return int64(x)
case int:
return int64(x)
}
return 0
}
// errNilGraph is returned by Generate when no graph is supplied.
type sentinelError string
func (e sentinelError) Error() string { return string(e) }
const errNilGraph sentinelError = "docs: graph is required"