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
536 lines
19 KiB
Go
536 lines
19 KiB
Go
package main
|
|
|
|
import (
|
|
"context"
|
|
"encoding/json"
|
|
"fmt"
|
|
"os"
|
|
"os/exec"
|
|
"path/filepath"
|
|
"strings"
|
|
"time"
|
|
|
|
"github.com/spf13/cobra"
|
|
"go.uber.org/zap"
|
|
"go.uber.org/zap/zapcore"
|
|
"gopkg.in/yaml.v3"
|
|
|
|
"github.com/zzet/gortex/internal/config"
|
|
"github.com/zzet/gortex/internal/embedding"
|
|
"github.com/zzet/gortex/internal/eval/recall"
|
|
"github.com/zzet/gortex/internal/gitcmd"
|
|
"github.com/zzet/gortex/internal/graph"
|
|
"github.com/zzet/gortex/internal/indexer"
|
|
gortexmcp "github.com/zzet/gortex/internal/mcp"
|
|
"github.com/zzet/gortex/internal/parser"
|
|
"github.com/zzet/gortex/internal/parser/languages"
|
|
"github.com/zzet/gortex/internal/query"
|
|
"github.com/zzet/gortex/internal/search"
|
|
"github.com/zzet/gortex/internal/tokens"
|
|
)
|
|
|
|
var (
|
|
evalRecallFixture string
|
|
evalRecallIndex string
|
|
evalRecallFormat string
|
|
evalRecallOut string
|
|
evalRecallEmbeddings bool
|
|
evalRecallEmbedder string
|
|
evalRecallEmbeddingsURL string
|
|
evalRecallModel string
|
|
evalRecallRankers string
|
|
evalRecallSkipRipgrep bool
|
|
evalRecallVerbose bool
|
|
evalRecallJudge string
|
|
)
|
|
|
|
var evalRecallCmd = &cobra.Command{
|
|
Use: "recall",
|
|
Short: "Recall@1/5/20 + latency + tokens-returned per ranker against a fixture",
|
|
Long: `Runs each query in the fixture through every retrieval ranker and reports
|
|
any-hit recall@1/5/20, mean reciprocal rank, p50/p95 latency, and tokens-
|
|
returned. Cases are tiered (exact / concept / multi_hop) so per-tier recall
|
|
is broken out separately.
|
|
|
|
Rankers:
|
|
bm25 — text-only (default text backend)
|
|
semantic — vector-only (requires --embeddings)
|
|
rrf — BM25 + vector fused via RRF (requires --embeddings)
|
|
winnow — graph-aware constraint chain (MCP winnow_symbols scorer)
|
|
ripgrep — rg --files-with-matches baseline ("retrieval floor")
|
|
|
|
By default every available ranker runs; narrow with --rankers bm25,rrf.
|
|
|
|
Without --embeddings, semantic and RRF degrade: semantic reports SKIPPED
|
|
and RRF falls back to BM25 inside HybridBackend.Search. Use --embeddings
|
|
to enable the built-in static (GloVe) provider, or --embeddings-url to
|
|
point at an OpenAI-compatible API (e.g. Ollama).`,
|
|
RunE: runEvalRecall,
|
|
}
|
|
|
|
func init() {
|
|
evalRecallCmd.Flags().StringVar(&evalRecallFixture, "fixture", "bench/fixtures/retrieval.yaml", "fixture YAML path")
|
|
evalRecallCmd.Flags().StringVar(&evalRecallIndex, "index", ".", "repository path to index before running queries")
|
|
evalRecallCmd.Flags().StringVar(&evalRecallFormat, "format", "markdown", "output format: markdown or json")
|
|
evalRecallCmd.Flags().StringVar(&evalRecallOut, "out", "", "output file (default: stdout)")
|
|
evalRecallCmd.Flags().BoolVar(&evalRecallEmbeddings, "embeddings", false, "enable local embedder for semantic/RRF rankers (auto-picks Hugot MiniLM-L6-v2 by default)")
|
|
evalRecallCmd.Flags().StringVar(&evalRecallEmbedder, "embedder", "auto", "embedder to use when --embeddings is set: auto|hugot|static")
|
|
evalRecallCmd.Flags().StringVar(&evalRecallEmbeddingsURL, "embeddings-url", "", "OpenAI-compatible embeddings API URL (overrides --embedder)")
|
|
evalRecallCmd.Flags().StringVar(&evalRecallModel, "embeddings-model", "", "embeddings model name (for --embeddings-url)")
|
|
evalRecallCmd.Flags().StringVar(&evalRecallRankers, "rankers", "", "comma-separated subset of rankers to run (default: all)")
|
|
evalRecallCmd.Flags().BoolVar(&evalRecallSkipRipgrep, "skip-ripgrep", false, "skip the rg baseline row")
|
|
evalRecallCmd.Flags().BoolVar(&evalRecallVerbose, "verbose", false, "print per-case miss diagnostics to stderr")
|
|
evalRecallCmd.Flags().StringVar(&evalRecallJudge, "judge", "", "LLM judge model (e.g. claude-haiku-4-5) — rescues misses where a top-K entry plausibly answers the query. Requires ANTHROPIC_API_KEY.")
|
|
evalCmd.AddCommand(evalRecallCmd)
|
|
}
|
|
|
|
func runEvalRecall(_ *cobra.Command, _ []string) error {
|
|
fixtureBytes, err := os.ReadFile(evalRecallFixture)
|
|
if err != nil {
|
|
return fmt.Errorf("reading fixture: %w", err)
|
|
}
|
|
var fixture recall.Fixture
|
|
if err := yaml.Unmarshal(fixtureBytes, &fixture); err != nil {
|
|
return fmt.Errorf("parsing fixture: %w", err)
|
|
}
|
|
if len(fixture.Cases) == 0 {
|
|
return fmt.Errorf("fixture %s has no cases", evalRecallFixture)
|
|
}
|
|
if fixture.Name == "" {
|
|
fixture.Name = filepath.Base(evalRecallFixture)
|
|
}
|
|
|
|
absIndex, err := filepath.Abs(evalRecallIndex)
|
|
if err != nil {
|
|
return fmt.Errorf("resolving index path: %w", err)
|
|
}
|
|
|
|
cfg, err := config.Load(cfgFile)
|
|
if err != nil {
|
|
return fmt.Errorf("loading config: %w", err)
|
|
}
|
|
|
|
// Build indexer, optionally with an embedder so vector search has data.
|
|
// A real stderr logger surfaces embedding failures —
|
|
// silently swallowing them via zap.NewNop() let the recall harness
|
|
// fall back to BM25 with no visible signal that the hybrid backend
|
|
// failed to build.
|
|
g := graph.New()
|
|
reg := parser.NewRegistry()
|
|
languages.RegisterAll(reg)
|
|
idx := indexer.New(g, reg, cfg.Index, newRecallLogger())
|
|
|
|
embedder := chooseEmbedder()
|
|
if embedder != nil {
|
|
idx.SetEmbedder(embedder)
|
|
}
|
|
|
|
fmt.Fprintf(os.Stderr, "[gortex eval recall] indexing %s...\n", absIndex)
|
|
res, err := idx.Index(absIndex)
|
|
if err != nil {
|
|
return fmt.Errorf("indexing: %w", err)
|
|
}
|
|
fmt.Fprintf(os.Stderr, "[gortex eval recall] indexed %d files, %d nodes in %dms\n",
|
|
res.FileCount, res.NodeCount, res.DurationMs)
|
|
|
|
// Validate fixture: every expected ID must exist in the graph,
|
|
// otherwise the case is unreachable and skews recall downwards
|
|
// regardless of ranker quality. Warnings go to stderr so they
|
|
// surface during curation but don't pollute the report.
|
|
if missing := validateFixture(fixture, g); len(missing) > 0 {
|
|
fmt.Fprintf(os.Stderr, "[gortex eval recall] WARNING: %d expected IDs missing from graph — fixture bug:\n", len(missing))
|
|
for _, m := range missing {
|
|
fmt.Fprintf(os.Stderr, " %s: %s\n", m.caseID, m.id)
|
|
}
|
|
}
|
|
|
|
selected := parseSelectedRankers(evalRecallRankers)
|
|
|
|
// Peel the Swappable wrapper so we can see the real backend. When
|
|
// embeddings are on, the indexer builds a HybridBackend internally;
|
|
// its TextBackend() is the pure BM25/Bleve side, and the whole
|
|
// HybridBackend is what RRF queries.
|
|
inner := idx.Search()
|
|
if sw, ok := inner.(*search.Swappable); ok {
|
|
inner = sw.Inner()
|
|
}
|
|
hybrid, _ := inner.(*search.HybridBackend)
|
|
var textBackend search.Backend
|
|
switch {
|
|
case hybrid != nil:
|
|
textBackend = hybrid.TextBackend()
|
|
default:
|
|
textBackend = inner
|
|
}
|
|
|
|
fmt.Fprintf(os.Stderr, "[gortex eval recall] inner backend: %T\n", inner)
|
|
fmt.Fprintf(os.Stderr, "[gortex eval recall] text backend: %T count=%d\n", textBackend, textBackend.Count())
|
|
if hybrid != nil {
|
|
vec := hybrid.VectorIndex()
|
|
vecCount := 0
|
|
if vec != nil {
|
|
vecCount = vec.Count()
|
|
}
|
|
fmt.Fprintf(os.Stderr, "[gortex eval recall] vector index: count=%d\n", vecCount)
|
|
}
|
|
|
|
// Engine-backed BM25 mirrors the MCP search_symbols call path
|
|
// (BM25 + substring fallback for camelCase-only queries). This is
|
|
// what real callers hit. Critically: the engine is pointed at the
|
|
// PURE text backend even when --embeddings is on — otherwise the
|
|
// "bm25" row would silently run through HybridBackend.Search (RRF
|
|
// fusion with vector results) and the measurement would no longer
|
|
// isolate BM25's contribution.
|
|
engForBM25 := query.NewEngine(g)
|
|
engForBM25.SetSearch(textBackend)
|
|
engineSearch := func(q string, limit int) []string {
|
|
nodes := engForBM25.SearchSymbols(q, limit)
|
|
out := make([]string, len(nodes))
|
|
for i, n := range nodes {
|
|
out[i] = n.ID
|
|
}
|
|
return out
|
|
}
|
|
|
|
rankers := []recall.Ranker{}
|
|
if selected["bm25"] {
|
|
rankers = append(rankers, recall.EngineRanker("bm25", engineSearch))
|
|
}
|
|
if selected["semantic"] {
|
|
if hybrid != nil && embedder != nil && hybrid.VectorIndex() != nil && hybrid.VectorIndex().Count() > 0 {
|
|
rankers = append(rankers, recall.SemanticRanker("semantic", hybrid.VectorIndex(), embedder))
|
|
} else {
|
|
rankers = append(rankers, skippedRanker("semantic", "no embedder (pass --embeddings)"))
|
|
}
|
|
}
|
|
if selected["rrf"] {
|
|
if hybrid != nil {
|
|
rankers = append(rankers, recall.RRFRanker("rrf", hybrid))
|
|
} else {
|
|
rankers = append(rankers, skippedRanker("rrf", "no hybrid backend (pass --embeddings)"))
|
|
}
|
|
}
|
|
|
|
// Graph-traversal ranker for DI-dependent / call-chain queries. Uses
|
|
// the same engine pointed at the pure text backend as Winnow above —
|
|
// text side doesn't matter for traversal, but keeping the engine
|
|
// identical makes it trivial to add text-plus-graph hybrid rankers
|
|
// later without re-plumbing.
|
|
if selected["graph"] {
|
|
graphEng := query.NewEngine(g)
|
|
graphEng.SetSearch(textBackend)
|
|
rankers = append(rankers, recall.GraphRanker("graph", &graphTraverser{eng: graphEng}))
|
|
}
|
|
|
|
// Winnow: needs a Server to run the graph-aware scorer. The Server's
|
|
// own engine is pointed at the PURE text backend so winnow's
|
|
// internal text-match step measures graph+BM25 behaviour, not
|
|
// graph+hybrid — otherwise weak static-embedding semantics would
|
|
// drag winnow's exact-tier recall down artificially, hiding the
|
|
// real graph-axis contribution.
|
|
if selected["winnow"] {
|
|
eng := query.NewEngine(g)
|
|
eng.SetSearch(textBackend)
|
|
srv := gortexmcp.NewServer(eng, g, idx, nil, zap.NewNop(), cfg.Guards.Rules)
|
|
srv.SetArchitecture(cfg.Architecture)
|
|
srv.SetEventRules(cfg.Events.Rules)
|
|
srv.SetArtifacts(cfg.Artifacts)
|
|
srv.SetNamedQueries(cfg.Queries)
|
|
srv.RunAnalysis()
|
|
provider := func(q string, extras map[string]any, limit int) []string {
|
|
return srv.WinnowForEval(q, extras, limit)
|
|
}
|
|
// Pass through per-case constraints via a query→extras lookup
|
|
// built from the fixture.
|
|
extrasByQuery := make(map[string]map[string]any, len(fixture.Cases))
|
|
for _, c := range fixture.Cases {
|
|
if c.WinnowConstraints != nil {
|
|
extrasByQuery[c.Query] = c.WinnowConstraints
|
|
}
|
|
}
|
|
caseExtras := func(q string) map[string]any { return extrasByQuery[q] }
|
|
rankers = append(rankers, recall.WinnowRanker("winnow", provider, caseExtras))
|
|
}
|
|
|
|
// Ripgrep baseline over file paths. Uses a filename-adapted copy of
|
|
// the fixture so any-hit recall compares file paths, not symbol IDs.
|
|
var rgReport *recall.Report
|
|
if selected["ripgrep"] && !evalRecallSkipRipgrep {
|
|
if _, err := exec.LookPath("rg"); err == nil {
|
|
rgRanker := recall.RipgrepRanker("ripgrep", absIndex)
|
|
rgFixture := recall.Fixture{
|
|
Name: fixture.Name + " (file-level)",
|
|
Cases: recall.AdaptCasesForFileRanker(fixture.Cases),
|
|
}
|
|
r := recall.Run(rgFixture, []recall.Ranker{rgRanker}, tokenCounter())
|
|
rgReport = &r
|
|
} else {
|
|
rankers = append(rankers, skippedRanker("ripgrep", "rg not on PATH"))
|
|
}
|
|
}
|
|
|
|
report := recall.Run(fixture, rankers, tokenCounter())
|
|
report.GortexRev = readGitRev(absIndex)
|
|
stampSkipReasons(&report)
|
|
|
|
if evalRecallVerbose {
|
|
printMissDiagnostics(&report)
|
|
}
|
|
|
|
if evalRecallJudge != "" {
|
|
judge := recall.NewJudge(evalRecallJudge)
|
|
if judge == nil {
|
|
fmt.Fprintln(os.Stderr, "[gortex eval recall] --judge set but ANTHROPIC_API_KEY is empty — skipping")
|
|
} else {
|
|
rescued, errs := recall.ApplyJudge(&report, judge)
|
|
fmt.Fprintf(os.Stderr, "[gortex eval recall] judge: rescued %d misses across all rankers\n", rescued)
|
|
for _, e := range errs {
|
|
fmt.Fprintf(os.Stderr, "[gortex eval recall] judge error: %v\n", e)
|
|
}
|
|
}
|
|
}
|
|
|
|
// Merge rg result into the main report so the markdown table has
|
|
// a single coherent row-set. File-level recall is still honest
|
|
// per-query — methodology note flags the difference.
|
|
if rgReport != nil && len(rgReport.Rankers) > 0 {
|
|
report.Rankers = append(report.Rankers, rgReport.Rankers[0])
|
|
}
|
|
|
|
var out []byte
|
|
switch evalRecallFormat {
|
|
case "markdown", "md":
|
|
out = []byte(recall.Markdown(report))
|
|
case "json":
|
|
out, err = json.MarshalIndent(report, "", " ")
|
|
if err != nil {
|
|
return fmt.Errorf("encoding JSON: %w", err)
|
|
}
|
|
out = append(out, '\n')
|
|
default:
|
|
return fmt.Errorf("unknown format: %s (want markdown or json)", evalRecallFormat)
|
|
}
|
|
|
|
if evalRecallOut == "" {
|
|
_, _ = os.Stdout.Write(out)
|
|
return nil
|
|
}
|
|
if err := os.WriteFile(evalRecallOut, out, 0o644); err != nil {
|
|
return fmt.Errorf("writing output: %w", err)
|
|
}
|
|
fmt.Fprintf(os.Stderr, "[gortex eval recall] wrote %s\n", evalRecallOut)
|
|
return nil
|
|
}
|
|
|
|
// newRecallLogger returns a stderr-only zap logger gated at warn level
|
|
// so the recall harness stays quiet on the success path but surfaces
|
|
// embedding failures and other indexer warnings — silently swallowing
|
|
// them via zap.NewNop() lets a missed embedding chunk demote the
|
|
// backend to BM25 with no visible signal.
|
|
func newRecallLogger() *zap.Logger {
|
|
cfg := zap.NewProductionConfig()
|
|
cfg.Level = zap.NewAtomicLevelAt(zapcore.WarnLevel)
|
|
cfg.OutputPaths = []string{"stderr"}
|
|
cfg.ErrorOutputPaths = []string{"stderr"}
|
|
logger, err := cfg.Build()
|
|
if err != nil {
|
|
return zap.NewNop()
|
|
}
|
|
return logger
|
|
}
|
|
|
|
// chooseEmbedder honours --embeddings-url > --embedder > --embeddings > off.
|
|
// Default with --embeddings is the best local provider (Hugot MiniLM-L6-v2
|
|
// auto-downloads to ~/.gortex/models/ on first use). Users can force
|
|
// static GloVe with --embedder static.
|
|
func chooseEmbedder() embedding.Provider {
|
|
if evalRecallEmbeddingsURL != "" {
|
|
fmt.Fprintf(os.Stderr, "[gortex eval recall] embedder: API (%s)\n", evalRecallEmbeddingsURL)
|
|
return embedding.NewAPIProvider(evalRecallEmbeddingsURL, evalRecallModel)
|
|
}
|
|
if !evalRecallEmbeddings {
|
|
return nil
|
|
}
|
|
switch evalRecallEmbedder {
|
|
case "static":
|
|
p, err := embedding.NewStaticProvider()
|
|
if err != nil {
|
|
fmt.Fprintf(os.Stderr, "[gortex eval recall] static embedder failed: %v\n", err)
|
|
return nil
|
|
}
|
|
fmt.Fprintln(os.Stderr, "[gortex eval recall] embedder: static (GloVe)")
|
|
return p
|
|
case "hugot":
|
|
p, err := embedding.NewHugotProvider()
|
|
if err != nil {
|
|
fmt.Fprintf(os.Stderr, "[gortex eval recall] hugot failed: %v (semantic/RRF will be skipped)\n", err)
|
|
return nil
|
|
}
|
|
fmt.Fprintf(os.Stderr, "[gortex eval recall] embedder: hugot (dim=%d)\n", p.Dimensions())
|
|
return p
|
|
case "auto", "":
|
|
p, err := embedding.NewLocalProvider()
|
|
if err != nil {
|
|
fmt.Fprintf(os.Stderr, "[gortex eval recall] local embedder failed: %v (semantic/RRF will be skipped)\n", err)
|
|
return nil
|
|
}
|
|
fmt.Fprintf(os.Stderr, "[gortex eval recall] embedder: local (%T, dim=%d)\n", p, p.Dimensions())
|
|
return p
|
|
default:
|
|
fmt.Fprintf(os.Stderr, "[gortex eval recall] unknown --embedder %q\n", evalRecallEmbedder)
|
|
return nil
|
|
}
|
|
}
|
|
|
|
// parseSelectedRankers turns a comma-separated flag into a set. Default
|
|
// (empty) = all known rankers on.
|
|
func parseSelectedRankers(csv string) map[string]bool {
|
|
all := []string{"bm25", "semantic", "rrf", "winnow", "graph", "ripgrep"}
|
|
if csv == "" {
|
|
out := make(map[string]bool, len(all))
|
|
for _, r := range all {
|
|
out[r] = true
|
|
}
|
|
return out
|
|
}
|
|
out := make(map[string]bool)
|
|
for _, r := range strings.Split(csv, ",") {
|
|
out[strings.TrimSpace(r)] = true
|
|
}
|
|
return out
|
|
}
|
|
|
|
// skippedRanker registers a placeholder so the report shows the row
|
|
// with a skip reason instead of silently dropping it.
|
|
func skippedRanker(name, reason string) recall.Ranker {
|
|
r := recall.Ranker{
|
|
Name: name,
|
|
Search: func(_ string, _ int) []string {
|
|
return nil
|
|
},
|
|
}
|
|
// The Run loop itself doesn't know about Skipped — we set it
|
|
// post-hoc on the Report.Rankers entries that had Search returning
|
|
// nothing, but only when the reason is non-empty. Easier path: the
|
|
// caller marks the RankerResult. Since recall.Run can't see inside,
|
|
// we record skip reason on a side channel applied after Run.
|
|
evalSkipReasons[name] = reason
|
|
return r
|
|
}
|
|
|
|
// evalSkipReasons carries the skip reasons keyed by ranker name so we
|
|
// can stamp them onto the report after Run. Shared across the single
|
|
// goroutine call site; not reached concurrently.
|
|
var evalSkipReasons = map[string]string{}
|
|
|
|
// tokenCounter returns a real tiktoken-based counter (internal/tokens
|
|
// already handles its own fallback if the encoder fails to init).
|
|
func tokenCounter() recall.TokenCounter {
|
|
return func(s string) int { return tokens.Count(s) }
|
|
}
|
|
|
|
// stampSkipReasons copies skip reasons recorded on the eval-local side
|
|
// channel onto the matching report rows.
|
|
func stampSkipReasons(report *recall.Report) {
|
|
for i, r := range report.Rankers {
|
|
if reason, ok := evalSkipReasons[r.Name]; ok {
|
|
report.Rankers[i].Skipped = reason
|
|
}
|
|
}
|
|
}
|
|
|
|
// printMissDiagnostics dumps per-ranker miss lists so fixture curators
|
|
// can see which cases are genuinely hard vs plainly mis-labelled.
|
|
func printMissDiagnostics(report *recall.Report) {
|
|
for _, r := range report.Rankers {
|
|
if r.Skipped != "" || len(r.Misses) == 0 {
|
|
continue
|
|
}
|
|
fmt.Fprintf(os.Stderr, "\n[gortex eval recall] %s misses (%d):\n", r.Name, len(r.Misses))
|
|
for _, m := range r.Misses {
|
|
top3 := m.Top
|
|
if len(top3) > 3 {
|
|
top3 = top3[:3]
|
|
}
|
|
rank := "MISS"
|
|
if m.Rank > 0 {
|
|
rank = fmt.Sprintf("rank=%d", m.Rank)
|
|
}
|
|
fmt.Fprintf(os.Stderr, " %-38s %-6s %q want=%v got=%v\n", m.CaseID, rank, m.Query, m.Expected, top3)
|
|
}
|
|
}
|
|
}
|
|
|
|
// fixtureMiss records an expected ID that doesn't exist in the graph.
|
|
type fixtureMiss struct{ caseID, id string }
|
|
|
|
// validateFixture reports expected IDs that aren't present in the graph
|
|
// so fixture curation errors surface before they depress recall numbers.
|
|
func validateFixture(f recall.Fixture, g *graph.Graph) []fixtureMiss {
|
|
var out []fixtureMiss
|
|
for _, c := range f.Cases {
|
|
for _, id := range c.Expected {
|
|
if g.GetNode(id) == nil {
|
|
out = append(out, fixtureMiss{caseID: c.ID, id: id})
|
|
}
|
|
}
|
|
}
|
|
return out
|
|
}
|
|
|
|
// readGitRev returns the short commit SHA of the repo at root, empty
|
|
// string if git is missing or the path isn't a repo.
|
|
func readGitRev(root string) string {
|
|
ctx, cancel := context.WithTimeout(context.Background(), 30*time.Second)
|
|
defer cancel()
|
|
// gitcmd injects `-C root`; Output trims trailing whitespace, matching the
|
|
// prior strings.TrimSpace on cmd.Output().
|
|
out, err := gitcmd.Output(ctx, root, "rev-parse", "--short", "HEAD")
|
|
if err != nil {
|
|
return ""
|
|
}
|
|
return out
|
|
}
|
|
|
|
// graphTraverser adapts the query engine to recall.GraphProvider. Each
|
|
// "tool" prefix (callers, call_chain, usages, dependents) maps to a
|
|
// specific engine method with a sane default depth for the fixture's
|
|
// query shape.
|
|
type graphTraverser struct {
|
|
eng *query.Engine
|
|
}
|
|
|
|
func (g *graphTraverser) Traverse(tool, id string, limit int) []string {
|
|
if g == nil || g.eng == nil {
|
|
return nil
|
|
}
|
|
opts := query.QueryOptions{Depth: 3, Limit: limit * 2}
|
|
var sg *query.SubGraph
|
|
switch tool {
|
|
case "callers":
|
|
sg = g.eng.GetCallers(id, opts)
|
|
case "call_chain":
|
|
sg = g.eng.GetCallChain(id, opts)
|
|
case "usages":
|
|
sg = g.eng.FindUsages(id)
|
|
case "dependents":
|
|
sg = g.eng.GetDependents(id, opts)
|
|
default:
|
|
return nil
|
|
}
|
|
if sg == nil {
|
|
return nil
|
|
}
|
|
out := make([]string, 0, len(sg.Nodes))
|
|
for _, n := range sg.Nodes {
|
|
if n.ID == id {
|
|
continue // the subject itself is not a caller/callee/user
|
|
}
|
|
out = append(out, n.ID)
|
|
if len(out) >= limit {
|
|
break
|
|
}
|
|
}
|
|
return out
|
|
}
|