Files
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

427 lines
13 KiB
Go

package main
import (
"encoding/json"
"errors"
"fmt"
"os"
"path/filepath"
"github.com/spf13/cobra"
"github.com/zzet/gortex/internal/coverage"
"github.com/zzet/gortex/internal/daemon"
"github.com/zzet/gortex/internal/progress"
)
var enrichCmd = &cobra.Command{
Use: "enrich",
Short: "Run one-shot enrichments (churn, blame, coverage, releases, cochange) via the running daemon",
Long: `Enrich stamps additional metadata onto the daemon's graph from
external data sources — git blame for authorship, git history for churn
and co-change, git tags for release timelines, and Go cover profiles for
test coverage.
Every enrichment is forwarded to the running daemon, which owns the warm
graph and its on-disk store write lock. The daemon runs the enricher
in-process against that graph so the persisted metadata is immediately
queryable by the analyze / get_churn_rate / coverage tools.
A daemon must be running. If none is, the command exits with an error
rather than building a throwaway in-memory graph that nothing would
read — start one with ` + "`gortex daemon start`" + ` and re-run.`,
}
var enrichReleasesBranch string
// errNoDaemon is the single clean error every enrich subcommand returns
// when no daemon is reachable. The enrichers only make sense against the
// daemon's warm, prefix-stamped graph; a standalone in-memory pass would
// be discarded and a direct on-disk write would race the daemon's writer.
var errNoDaemon = errors.New("enrich requires a running daemon; start it with `gortex daemon start`")
var enrichBlameCmd = &cobra.Command{
Use: "blame [path]",
Short: "Stamp meta.last_authored on every symbol via git blame",
Args: cobra.MaximumNArgs(1),
RunE: runEnrichBlame,
}
var enrichCoverageCmd = &cobra.Command{
Use: "coverage <profile> [path]",
Short: "Stamp meta.coverage_pct on every symbol from a Go cover profile",
Args: cobra.RangeArgs(1, 2),
RunE: runEnrichCoverage,
}
var enrichReleasesCmd = &cobra.Command{
Use: "releases [path]",
Short: "Stamp meta.added_in on every file from git tag history",
Args: cobra.MaximumNArgs(1),
RunE: runEnrichReleases,
}
var enrichCochangeCmd = &cobra.Command{
Use: "cochange [path]",
Short: "Add co_change edges between files that git history shows change together",
Args: cobra.MaximumNArgs(1),
RunE: runEnrichCochange,
}
var (
enrichAllBlame bool
enrichAllReleases bool
enrichAllCochange bool
enrichAllChurn bool
enrichAllProfile string
)
var enrichAllCmd = &cobra.Command{
Use: "all [path]",
Short: "Run every enrichment against the daemon's graph in one invocation",
Long: `Combined enrichment that runs the requested enrichers against the
daemon's graph via successive control calls.
By default runs churn, blame, releases, and co-change (all git-only, no
extra data needed). Pass --coverage <profile> to also project a Go cover
profile. Each enrichment is independently toggleable via the
--no-churn / --no-blame / --no-releases / --no-cochange flags.
Like every enrich subcommand, this requires a running daemon.`,
Args: cobra.MaximumNArgs(1),
RunE: runEnrichAll,
}
func init() {
enrichReleasesCmd.Flags().StringVar(&enrichReleasesBranch, "branch", "",
"restrict to tags reachable from this branch (default: resolve origin/main/master). Empty means every tag in the repo")
enrichAllCmd.Flags().BoolVar(&enrichAllChurn, "churn", true,
"run churn enrichment (default: on)")
enrichAllCmd.Flags().BoolVar(&enrichAllBlame, "blame", true,
"run blame enrichment (default: on)")
enrichAllCmd.Flags().BoolVar(&enrichAllReleases, "releases", true,
"run releases enrichment (default: on)")
enrichAllCmd.Flags().BoolVar(&enrichAllCochange, "cochange", true,
"run co-change enrichment (default: on)")
enrichAllCmd.Flags().StringVar(&enrichAllProfile, "coverage", "",
"path to a Go cover.out profile — coverage enrichment is skipped when empty")
enrichCmd.AddCommand(enrichBlameCmd)
enrichCmd.AddCommand(enrichCoverageCmd)
enrichCmd.AddCommand(enrichReleasesCmd)
enrichCmd.AddCommand(enrichCochangeCmd)
enrichCmd.AddCommand(enrichAllCmd)
rootCmd.AddCommand(enrichCmd)
}
// enrichAbsPath resolves the optional [path] argument to an absolute
// path. Empty args default to the current directory; the abs path is the
// repo scope handed to the daemon (matched against tracked prefixes /
// roots, or "" for "every tracked repo").
func enrichAbsPath(args []string) (string, error) {
path := "."
if len(args) >= 1 {
path = args[0]
}
abs, err := filepath.Abs(path)
if err != nil {
return "", fmt.Errorf("abs path %q: %w", path, err)
}
return abs, nil
}
// dialEnrichDaemon opens a control connection to the running daemon for
// the given client name. Callers must have already checked
// daemon.IsRunning(); a dial failure here means the socket was present
// but unusable (a dying daemon) — surfaced as a clear error.
func dialEnrichDaemon(clientName string) (*daemon.Client, error) {
c, err := daemon.Dial(daemon.Handshake{Mode: daemon.ModeControl, ClientName: clientName})
if err != nil {
if errors.Is(err, daemon.ErrDaemonUnavailable) {
return nil, fmt.Errorf("daemon socket detected but dial failed; restart it with `gortex daemon restart`")
}
return nil, fmt.Errorf("dial daemon: %w", err)
}
return c, nil
}
// controlEnrich sends one control request on c, validates the daemon
// accepted it, and decodes the typed result into out (which must be a
// pointer). Centralises the OK / error-code handling every forwarder
// repeats.
func controlEnrich(c *daemon.Client, kind string, params, out any) error {
resp, err := c.Control(kind, params)
if err != nil {
return fmt.Errorf("control %s: %w", kind, err)
}
if !resp.OK {
return fmt.Errorf("daemon rejected %s [%s]: %s", kind, resp.ErrorCode, resp.ErrorMsg)
}
if out != nil && len(resp.Result) > 0 {
if err := json.Unmarshal(resp.Result, out); err != nil {
return fmt.Errorf("parse daemon %s response: %w", kind, err)
}
}
return nil
}
func runEnrichBlame(cmd *cobra.Command, args []string) error {
abs, err := enrichAbsPath(args)
if err != nil {
return err
}
if !daemon.IsRunning() {
return errNoDaemon
}
c, err := dialEnrichDaemon("cli-enrich-blame")
if err != nil {
return err
}
defer c.Close()
var out daemon.EnrichBlameResult
if err := controlEnrich(c, daemon.ControlEnrichBlame, daemon.EnrichBlameParams{Path: abs}, &out); err != nil {
return err
}
sp := newCLISpinner(cmd, "Enriched via daemon")
sp.Set("", fmt.Sprintf("%d nodes stamped", out.Nodes))
sp.Done()
return printEnrichResult(map[string]any{
"enriched": out.Nodes,
"duration_ms": out.DurationMS,
"path": abs,
"mode": "daemon",
})
}
func runEnrichCoverage(cmd *cobra.Command, args []string) error {
profilePath := args[0]
abs, err := enrichAbsPath(args[1:])
if err != nil {
return err
}
// Parse the profile CLI-side: the path is relative to the caller's
// cwd, not the daemon's, so the daemon can't read it. We hand the
// daemon the parsed segments instead.
segments, err := coverage.ParseFile(profilePath)
if err != nil {
return fmt.Errorf("read profile: %w", err)
}
if !daemon.IsRunning() {
return errNoDaemon
}
c, err := dialEnrichDaemon("cli-enrich-coverage")
if err != nil {
return err
}
defer c.Close()
wire := make([]daemon.EnrichCoverageSegment, len(segments))
for i, s := range segments {
wire[i] = daemon.EnrichCoverageSegment{
File: s.File,
StartLine: s.StartLine,
EndLine: s.EndLine,
NumStmt: s.NumStmt,
Count: s.Count,
}
}
var out daemon.EnrichCoverageResult
if err := controlEnrich(c, daemon.ControlEnrichCoverage, daemon.EnrichCoverageParams{Path: abs, Segments: wire}, &out); err != nil {
return err
}
sp := newCLISpinner(cmd, "Enriched via daemon")
sp.Set("", fmt.Sprintf("%d symbols · %d segments", out.Symbols, out.Segments))
sp.Done()
return printEnrichResult(map[string]any{
"enriched": out.Symbols,
"segments": out.Segments,
"profile": profilePath,
"duration_ms": out.DurationMS,
"path": abs,
"mode": "daemon",
})
}
func runEnrichReleases(cmd *cobra.Command, args []string) error {
abs, err := enrichAbsPath(args)
if err != nil {
return err
}
if !daemon.IsRunning() {
return errNoDaemon
}
c, err := dialEnrichDaemon("cli-enrich-releases")
if err != nil {
return err
}
defer c.Close()
var out daemon.EnrichReleasesResult
if err := controlEnrich(c, daemon.ControlEnrichReleases, daemon.EnrichReleasesParams{Path: abs, Branch: enrichReleasesBranch}, &out); err != nil {
return err
}
sp := newCLISpinner(cmd, "Enriched via daemon")
sp.Set("", fmt.Sprintf("%d files · %s", out.Files, out.Branch))
sp.Done()
return printEnrichResult(map[string]any{
"enriched": out.Files,
"branch": out.Branch,
"duration_ms": out.DurationMS,
"path": abs,
"mode": "daemon",
})
}
func runEnrichCochange(cmd *cobra.Command, args []string) error {
abs, err := enrichAbsPath(args)
if err != nil {
return err
}
if !daemon.IsRunning() {
return errNoDaemon
}
c, err := dialEnrichDaemon("cli-enrich-cochange")
if err != nil {
return err
}
defer c.Close()
var out daemon.EnrichCochangeResult
if err := controlEnrich(c, daemon.ControlEnrichCochange, daemon.EnrichCochangeParams{Path: abs}, &out); err != nil {
return err
}
sp := newCLISpinner(cmd, "Enriched via daemon")
sp.Set("", fmt.Sprintf("%d edges added", out.Edges))
sp.Done()
return printEnrichResult(map[string]any{
"enriched": out.Edges,
"duration_ms": out.DurationMS,
"path": abs,
"mode": "daemon",
})
}
func runEnrichAll(cmd *cobra.Command, args []string) error {
abs, err := enrichAbsPath(args)
if err != nil {
return err
}
// Parse the coverage profile (if any) up front so a bad path fails
// before we touch the daemon.
var covSegments []daemon.EnrichCoverageSegment
if enrichAllProfile != "" {
segments, err := coverage.ParseFile(enrichAllProfile)
if err != nil {
return fmt.Errorf("read profile: %w", err)
}
covSegments = make([]daemon.EnrichCoverageSegment, len(segments))
for i, s := range segments {
covSegments[i] = daemon.EnrichCoverageSegment{
File: s.File,
StartLine: s.StartLine,
EndLine: s.EndLine,
NumStmt: s.NumStmt,
Count: s.Count,
}
}
}
if !daemon.IsRunning() {
return errNoDaemon
}
c, err := dialEnrichDaemon("cli-enrich-all")
if err != nil {
return err
}
defer c.Close()
result := map[string]any{
"path": abs,
"mode": "daemon",
}
if enrichAllChurn {
sp := newCLISpinner(cmd, "Stamping churn")
var out daemon.EnrichChurnResult
if err := controlEnrich(c, daemon.ControlEnrichChurn, daemon.EnrichChurnParams{Path: abs}, &out); err != nil {
sp.Fail(err)
return err
}
sp.Set("", fmt.Sprintf("%d files · %d symbols", out.Files, out.Symbols))
sp.Done()
result["churn_files"] = out.Files
result["churn_symbols"] = out.Symbols
result["churn_branch"] = out.Branch
}
if enrichAllBlame {
sp := newCLISpinner(cmd, "Stamping blame")
var out daemon.EnrichBlameResult
if err := controlEnrich(c, daemon.ControlEnrichBlame, daemon.EnrichBlameParams{Path: abs}, &out); err != nil {
sp.Fail(err)
return err
}
sp.Set("", fmt.Sprintf("%d nodes stamped", out.Nodes))
sp.Done()
result["blame_enriched"] = out.Nodes
}
if enrichAllReleases {
sp := newCLISpinner(cmd, "Stamping releases")
var out daemon.EnrichReleasesResult
if err := controlEnrich(c, daemon.ControlEnrichReleases, daemon.EnrichReleasesParams{Path: abs}, &out); err != nil {
sp.Fail(err)
return err
}
sp.Set("", fmt.Sprintf("%d files stamped", out.Files))
sp.Done()
result["releases_enriched"] = out.Files
}
if enrichAllCochange {
sp := newCLISpinner(cmd, "Mining co-change")
var out daemon.EnrichCochangeResult
if err := controlEnrich(c, daemon.ControlEnrichCochange, daemon.EnrichCochangeParams{Path: abs}, &out); err != nil {
sp.Fail(err)
return err
}
sp.Set("", fmt.Sprintf("%d edges added", out.Edges))
sp.Done()
result["cochange_edges"] = out.Edges
}
if len(covSegments) > 0 {
sp := newCLISpinner(cmd, "Stamping coverage")
sp.Set("", enrichAllProfile)
var out daemon.EnrichCoverageResult
if err := controlEnrich(c, daemon.ControlEnrichCoverage, daemon.EnrichCoverageParams{Path: abs, Segments: covSegments}, &out); err != nil {
sp.Fail(err)
return err
}
sp.Set("", fmt.Sprintf("%d symbols · %d segments", out.Symbols, out.Segments))
sp.Done()
result["coverage_enriched"] = out.Symbols
result["coverage_segments"] = out.Segments
}
return printEnrichResult(result)
}
// printEnrichResult emits the enrichment summary as JSON when stdout
// is captured by a script and as a one-line human-readable text
// when invoked interactively. On a terminal we keep stdout quiet — the
// spinner already showed the per-pass count — and just caption the path /
// profile. On a pipe / redirect we still emit JSON for scripts.
func printEnrichResult(payload map[string]any) error {
if progress.IsTTY(os.Stdout) {
if v, ok := payload["path"]; ok {
fmt.Fprintln(os.Stdout, " "+progress.Caption("path: "+fmt.Sprint(v)))
}
if v, ok := payload["profile"]; ok {
fmt.Fprintln(os.Stdout, " "+progress.Caption("profile: "+fmt.Sprint(v)))
}
return nil
}
data, err := json.MarshalIndent(payload, "", " ")
if err != nil {
return err
}
fmt.Println(string(data))
return nil
}