chore: import upstream snapshot with attribution
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
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
This commit is contained in:
@@ -0,0 +1,309 @@
|
||||
// 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"
|
||||
Reference in New Issue
Block a user