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
678 lines
28 KiB
Go
678 lines
28 KiB
Go
package query
|
|
|
|
import (
|
|
"fmt"
|
|
"strings"
|
|
"time"
|
|
|
|
"github.com/zzet/gortex/internal/graph"
|
|
"github.com/zzet/gortex/internal/search/rerank"
|
|
)
|
|
|
|
// SubGraph is a JSON-serializable result from a graph query.
|
|
type SubGraph struct {
|
|
Nodes []*graph.Node `json:"nodes"`
|
|
Edges []*graph.Edge `json:"edges"`
|
|
TotalNodes int `json:"total_nodes"`
|
|
TotalEdges int `json:"total_edges"`
|
|
Truncated bool `json:"truncated"`
|
|
// TextMatchedSuppressed counts name-only (text_matched) edges dropped
|
|
// by the adaptive default: once the result carries resolver-verified
|
|
// evidence for the symbol, the name-only fan-out is redundant noise.
|
|
// Zero — and omitted — when nothing was suppressed. Re-run with
|
|
// min_tier:"text_matched" to include the hidden rows.
|
|
TextMatchedSuppressed int `json:"text_matched_suppressed,omitempty"`
|
|
// SuppressionCaveat is attached by the adaptive text_matched default
|
|
// (find_usages / get_callers) when TextMatchedSuppressed > 0 AND the
|
|
// target's file was re-parsed on the live watch path without re-running
|
|
// semantic enrichment — so the resolver-verified edges that triggered
|
|
// suppression may be below the tier the enrichment pass would mint, and
|
|
// the hidden name-only usages could be the real ones. Empty (omitted)
|
|
// otherwise. Independent of Caveat, which only fires for a zero-edge
|
|
// result — and since suppression only runs when a stronger edge exists,
|
|
// the two never coexist.
|
|
SuppressionCaveat string `json:"suppression_caveat,omitempty"`
|
|
// RelatedTools is a one-line, additive completeness cue naming a
|
|
// deferred tool whose trigger the response content just matched (e.g. a
|
|
// find_usages on a dispatch-heavy interface → find_implementations). It
|
|
// is emitted at most once per tool per session so a discovery hint
|
|
// surfaces without repeating. Empty (omitted) when nothing matched or the
|
|
// cue was already shown this session.
|
|
RelatedTools string `json:"related_tools,omitempty"`
|
|
// Caveat is attached only when an edge-returning query (find_usages,
|
|
// get_callers) comes back with no edges, classifying whether the
|
|
// empty result reflects genuinely unused code or an extraction gap.
|
|
// Nil — and omitted from the response — for any non-empty result.
|
|
Caveat *graph.ZeroEdgeCaveat `json:"caveat,omitempty"`
|
|
// TierFiltered is attached when a min_tier filter dropped edges while
|
|
// lower-tier edges still existed — so a min_tier that empties the result
|
|
// is legible as "filtered", not "no usages". Set by FilterByMinTier;
|
|
// omitted when no min_tier was applied or nothing was below the tier.
|
|
TierFiltered *graph.TierFilteredCaveat `json:"tier_filtered,omitempty"`
|
|
// CallerNotes carries concurrency-safety annotations keyed by node
|
|
// ID. Populated only by get_callers (which classifies each caller);
|
|
// other traversal tools share this struct and leave it nil, so it
|
|
// is omitted from their responses. A node appears here only when at
|
|
// least one concurrency flag is set, so an absent entry means
|
|
// "neither sync_guarded nor cross_concurrent".
|
|
CallerNotes map[string]*graph.ConcurrencyAnnotation `json:"caller_notes,omitempty"`
|
|
// BudgetHit is set by token-budgeted traversals (WalkBudgeted) when
|
|
// the walk stopped because the estimated encoded size of the result
|
|
// reached the caller's token budget. False — and omitted — for a
|
|
// traversal that completed within budget or never imposed one.
|
|
BudgetHit bool `json:"budget_hit,omitempty"`
|
|
// StoppedAtDepth records the BFS depth the budgeted traversal had
|
|
// reached when it stopped — either the deepest depth fully expanded,
|
|
// or the depth at which the budget / depth cap halted expansion.
|
|
// Zero — and omitted — for traversals that don't track depth.
|
|
StoppedAtDepth int `json:"stopped_at_depth,omitempty"`
|
|
// LastSynced is the time the stalest federation proxy node in this
|
|
// result was last pulled from its owning remote. Set only when the
|
|
// traversal crossed into a remote-owned proxy node; omitted (and nil)
|
|
// for a purely-local result, so a caller can see how fresh the
|
|
// remote-derived part of the answer is.
|
|
LastSynced *time.Time `json:"last_synced,omitempty"`
|
|
// LowerBound is set by call-graph traversals (get_call_chain) when the
|
|
// walk dropped one or more dynamic-dispatch / unresolved out-edges: the
|
|
// reachable set is then a floor, not exhaustive. Omitted when false.
|
|
LowerBound bool `json:"lower_bound,omitempty"`
|
|
// DynamicBoundaries enriches a dispatch-bounded result with the body-level
|
|
// {site, form, key, candidate-shortlist} of each runtime-dispatch site in
|
|
// the seed symbol — so an agent reads "static path ends HERE, form X, key
|
|
// Y, candidates A/B" instead of spiralling through the source. Computed on
|
|
// demand (find_usages / smart_context / explore); never persisted.
|
|
DynamicBoundaries []graph.DynamicBoundary `json:"dynamic_boundaries,omitempty"`
|
|
// Boundaries names the unresolved/dispatch sites that made the result a
|
|
// floor. Populated only by call-graph traversals; omitted when empty.
|
|
Boundaries []graph.EpistemicBoundary `json:"boundaries,omitempty"`
|
|
// UsageSummary is a compact completeness rollup attached only by
|
|
// find_usages: the total reference count, the number of distinct
|
|
// files those references span, and the test-file share. Nil — and
|
|
// omitted — for every other traversal that shares this struct, and
|
|
// for an empty result (the Caveat already explains that case). Lets
|
|
// an agent see at a glance whether the usage list already covers
|
|
// tests instead of re-grepping *_test.go files to find out.
|
|
UsageSummary *UsageSummary `json:"usage_summary,omitempty"`
|
|
}
|
|
|
|
// UsageSummary is the compact completeness rollup on a find_usages
|
|
// result: NRefs total references, spread across NFiles distinct files,
|
|
// of which NTestRefs originate in test files. It is derived from the
|
|
// same edges and per-node test classification as the per-usage rows, so
|
|
// the rollup never disagrees with the references it summarizes.
|
|
type UsageSummary struct {
|
|
NRefs int `json:"n_refs" toon:"n_refs"`
|
|
NFiles int `json:"n_files" toon:"n_files"`
|
|
NTestRefs int `json:"n_test_refs" toon:"n_test_refs"`
|
|
}
|
|
|
|
// QueryOptions controls traversal depth, result limits, and detail level.
|
|
type QueryOptions struct {
|
|
Depth int `json:"depth"`
|
|
Limit int `json:"limit"`
|
|
Detail string `json:"detail"` // "brief" or "full"
|
|
MinTier string `json:"min_tier,omitempty"` // see graph.Origin* constants; "" = no filter
|
|
// WorkspaceID, when set, restricts traversal to nodes whose
|
|
// effective workspace (Node.WorkspaceID || Node.RepoPrefix
|
|
// fallback) equals this slug. Empty disables the filter —
|
|
// preserves the legacy global-graph behaviour for callers that
|
|
// don't care about the workspace boundary.
|
|
WorkspaceID string `json:"workspace_id,omitempty"`
|
|
// ProjectID applies the same scoping for the soft sub-boundary.
|
|
// Honoured only when WorkspaceID is also set; on its own it would
|
|
// be ambiguous (two workspaces could declare a project with the
|
|
// same name).
|
|
ProjectID string `json:"project_id,omitempty"`
|
|
// RepoAllow, when non-empty, restricts traversal to nodes whose
|
|
// RepoPrefix is present in the allow-set. Nil or empty preserves
|
|
// the legacy no-repo-filter behaviour. This is intentionally a
|
|
// soft breadth control inside any workspace boundary, not a
|
|
// replacement for caller-side workspace isolation.
|
|
RepoAllow map[string]bool `json:"repo_allow,omitempty"`
|
|
// ExcludeTests, when true, drops edges originating from a function
|
|
// flagged as a test (Node.Meta["is_test"] = true) — set by the
|
|
// indexer's test-edge pass. Lets find_usages / get_callers answer
|
|
// "who depends on X *in production*" without test-noise dilution.
|
|
ExcludeTests bool `json:"exclude_tests,omitempty"`
|
|
|
|
// IncludeDispatch makes a forward call-graph walk (get_call_chain /
|
|
// trace / callees) polymorphic-dispatch aware: when the chain reaches an
|
|
// interface / abstract method, it also expands through that method's
|
|
// EdgeOverrides in-edges to the concrete implementations that override
|
|
// it. The dedicated override edges are recorded AS-IS — never synthesized
|
|
// into fake `calls` edges — so find_implementations / get_class_hierarchy
|
|
// stay precise while a trace auto-reaches the impls. Off by default.
|
|
IncludeDispatch bool `json:"include_dispatch,omitempty"`
|
|
// DispatchMinTier gates which override edges qualify for dispatch
|
|
// expansion by minimum provenance tier (see graph.Origin* constants);
|
|
// "" admits every override edge. Lets a caller demand, e.g., only
|
|
// LSP-confirmed overrides — a min_tier control codegraph cannot offer.
|
|
DispatchMinTier string `json:"dispatch_min_tier,omitempty"`
|
|
// DispatchFanout caps how many overriders a single method expands to
|
|
// (0 → defaultDispatchFanout), bounding the blow-up on a hub interface
|
|
// implemented by hundreds of types.
|
|
DispatchFanout int `json:"dispatch_fanout,omitempty"`
|
|
|
|
// SearchTimings, when non-nil, is populated by the search hot path
|
|
// (SearchSymbolsScoped → gatherBackendCandidates) with per-phase
|
|
// wall-clock breakdowns. Used by the MCP search_symbols handler's
|
|
// debug log line; nil disables instrumentation. Single-call: the
|
|
// caller MUST hand a fresh struct per query (the engine does not
|
|
// reset). Never serialised — `json:"-"` keeps the option struct
|
|
// JSON shape stable.
|
|
SearchTimings *SearchTimings `json:"-"`
|
|
|
|
// RerankContext is the optional rerank context the engine uses when
|
|
// gathering bundle candidates: each bundle's in/out edges are
|
|
// seeded into the context's edge caches so the handler-side
|
|
// rerank.Pipeline.Rerank can skip its own batched edge fetch on
|
|
// the merged candidate set. Pass nil — the engine's gather path
|
|
// still works, the bundle's edges are just discarded after the
|
|
// per-call rerank. Never serialised.
|
|
RerankContext *rerank.Context `json:"-"`
|
|
|
|
// SkipInnerRerank, when true, makes SearchSymbolsRanked skip its
|
|
// own per-call rerank.Pipeline.Rerank pass. Callers that fan a
|
|
// search across N expansion terms and merge the results themselves
|
|
// (the MCP search_symbols handler) re-run the rerank once on the
|
|
// merged candidate set with the full session-aware context — the
|
|
// inner per-call rerank is wasted work whose output is mostly
|
|
// discarded by the merge. Flipping this on collapses N+1
|
|
// engine-side rerank invocations to zero. The merge-side rerank
|
|
// is the source of truth either way.
|
|
SkipInnerRerank bool `json:"-"`
|
|
|
|
// SkipVectorChannel, when true, makes gatherBackendCandidates skip
|
|
// the vector channel entirely — no embedder call, no ANN search.
|
|
// Set by the MCP search_symbols handler on identifier-shape queries
|
|
// (QueryClassSymbol / QueryClassPath / QueryClassSignature) where
|
|
// the rerank's classWeightTable already proves the semantic
|
|
// channel contributes near-zero useful signal (multipliers 0.65 /
|
|
// 0.45 / 0.80 vs the baseline 1.00 for concept). Saves the embed
|
|
// + vector search round-trip on the common-case identifier lookup.
|
|
// The bundle path's vector-only branch and the legacy
|
|
// SearchChannels path both honour this flag.
|
|
SkipVectorChannel bool `json:"-"`
|
|
|
|
// SkipExactNameSplice, when true, makes gatherBackendCandidates
|
|
// skip the FindNodesByName(query) splice-in. Set by callers that
|
|
// know the query string cannot match any exact node name — the
|
|
// fetchAndMergeBM25 fan-out's combined-OR call is the canonical
|
|
// case: a concatenated bag of expansion terms ("NewServer
|
|
// StartServer Server.Init …") can't be the literal Name of any
|
|
// node, so the FindNodesByName query round-trip is wasted work.
|
|
// The primary query still runs the splice.
|
|
SkipExactNameSplice bool `json:"-"`
|
|
|
|
// CosineRerank, when true, runs the post-rerank exact-cosine
|
|
// refinement stage after SearchSymbolsRanked's per-call rerank:
|
|
// the top candidates are re-ordered by exact cosine similarity
|
|
// between the query embedding and each candidate's stored
|
|
// embedding, recovering the precise semantic distance the
|
|
// rank-based SemanticSignal discards. The stage is a strict no-op
|
|
// when the vector channel is inactive (no embedder, no stored
|
|
// vectors, query fails to embed), so it can never regress a
|
|
// text-only search. Honoured only when the engine has not been
|
|
// asked to skip its inner rerank — the production handler runs
|
|
// the refinement itself against its merged candidate set.
|
|
CosineRerank bool `json:"-"`
|
|
|
|
// CosineTopN bounds how many of the top ranked candidates the
|
|
// cosine refinement re-scores. Zero uses the package default.
|
|
CosineTopN int `json:"-"`
|
|
}
|
|
|
|
// SearchTimings carries per-phase wall-clock measurements collected
|
|
// by the BM25 retrieval pipeline. Zero-valued fields mean the phase
|
|
// didn't run on this call (e.g. FallbackMS is 0 when the BM25 result
|
|
// already saturated the limit).
|
|
type SearchTimings struct {
|
|
BM25PrimaryMS int64 // time spent in the primary BM25 backend call
|
|
BM25ExpansionMS int64 // time spent across all expansion-term BM25 calls
|
|
GetNodesMS int64 // time spent materialising BM25/vector IDs via GetNodesByIDs
|
|
FindNameMS int64 // time spent on the FindNodesByName splice-in
|
|
FallbackMS int64 // time spent in the substring/name-contains fallback
|
|
// Sub-buckets of the BM25*MS totals — proves which phase inside
|
|
// the wrapper is actually slow. Accumulated across every
|
|
// primary + expansion BM25 invocation.
|
|
TextBackendMS int64 // strictly inside Backend.Search / text channel
|
|
EmbedMS int64 // inside embedder.Embed (vector path only)
|
|
VectorSearchMS int64 // inside vector.Search ANN call (vector path only)
|
|
EngineRerankMS int64 // inside rerank.Pipeline.Rerank in SearchSymbolsRanked
|
|
// BundleMS accumulates the wall-clock spent inside
|
|
// SymbolBundleSearcherBackend.SearchSymbolBundles (one query per
|
|
// BM25 fan-out that returns Node + in/out edges in one bundle).
|
|
// When the backend supports bundles, the bundle path replaces the
|
|
// (TextBackend + GetNodes) sub-buckets; the bm25_backend_ms
|
|
// derivation in the handler subtracts BundleMS so the existing
|
|
// fields stay meaningful.
|
|
BundleMS int64
|
|
// CacheHitRate is the fraction of post-merge candidates whose
|
|
// in/out edges were already in the rerank Context cache when the
|
|
// handler-side prepare() ran. 1.0 means every candidate was
|
|
// pre-seeded from a bundle; 0.0 means the rerank had to fetch
|
|
// every candidate's edges itself. Populated by the handler when
|
|
// the bundle path is active so the search_symbols debug log can
|
|
// surface how often the seeding actually catches.
|
|
CacheHitRate float64
|
|
}
|
|
|
|
// ScopeAllows reports whether a node passes the workspace/project
|
|
// scope expressed in opts. Empty WorkspaceID means "no scope" — every
|
|
// node passes. Same effective-fallback rule as the matcher: missing
|
|
// WorkspaceID on the node falls back to its RepoPrefix.
|
|
//
|
|
// Exported so the MCP layer can enforce the session's workspace
|
|
// boundary on by-id and whole-graph handlers that don't route through
|
|
// the engine's scoped traversal.
|
|
func (o QueryOptions) ScopeAllows(n *graph.Node) bool {
|
|
if n == nil {
|
|
return true
|
|
}
|
|
if o.WorkspaceID != "" {
|
|
ws := n.WorkspaceID
|
|
if ws == "" {
|
|
ws = n.RepoPrefix
|
|
}
|
|
if ws != o.WorkspaceID {
|
|
return false
|
|
}
|
|
if o.ProjectID != "" {
|
|
proj := n.ProjectID
|
|
if proj == "" {
|
|
proj = n.RepoPrefix
|
|
}
|
|
if proj != o.ProjectID {
|
|
return false
|
|
}
|
|
}
|
|
}
|
|
// A node with an empty RepoPrefix was minted in single-repo
|
|
// (unprefixed) mode — the RepoAllow keys are registry prefixes,
|
|
// which unprefixed nodes never carry, so a repo narrow can only
|
|
// ever be satisfied vacuously. Admit the node: the workspace /
|
|
// project checks above still bound it for scoped sessions. Same
|
|
// carve-out the MCP layer's filterNodes / field-query / API-impact
|
|
// filters already apply.
|
|
if len(o.RepoAllow) > 0 && n.RepoPrefix != "" && !o.RepoAllow[n.RepoPrefix] {
|
|
return false
|
|
}
|
|
return true
|
|
}
|
|
|
|
func (o QueryOptions) hasScopeFilter() bool {
|
|
return o.WorkspaceID != "" || len(o.RepoAllow) > 0
|
|
}
|
|
|
|
// FilterByMinTier drops edges whose Origin rank is below minTier.
|
|
//
|
|
// Nodes are left untouched — a hop that gets filtered can leave an
|
|
// unreachable node in Nodes. That's acceptable for the current surface
|
|
// area (agents filter by tier mainly for one-hop questions like "who
|
|
// calls this?"), and pruning orphans would silently change the node set
|
|
// when a caller might still want to see them. Callers that care can
|
|
// post-prune themselves.
|
|
//
|
|
// Edges without Origin set fall back to graph.DefaultOriginFor (derived
|
|
// from kind + confidence + semantic_source meta) so filters work on
|
|
// edges produced before this field existed or by providers not yet
|
|
// updated.
|
|
func (sg *SubGraph) FilterByMinTier(minTier string) {
|
|
if minTier == "" || sg == nil {
|
|
return
|
|
}
|
|
kept := make([]*graph.Edge, 0, len(sg.Edges))
|
|
dropped := 0
|
|
maxDroppedRank := -1
|
|
maxDroppedOrigin := ""
|
|
for _, e := range sg.Edges {
|
|
origin := effectiveOrigin(e)
|
|
if graph.MeetsMinTier(origin, minTier) {
|
|
kept = append(kept, e)
|
|
continue
|
|
}
|
|
dropped++
|
|
if r := graph.OriginRank(origin); r > maxDroppedRank {
|
|
maxDroppedRank = r
|
|
maxDroppedOrigin = origin
|
|
}
|
|
}
|
|
sg.Edges = kept
|
|
// Record a caveat when the filter dropped edges that still exist below the
|
|
// tier — so an empty (or thinned) result reads as "tier-filtered", not as
|
|
// "no usages". Only meaningful when the filter actually emptied the visible
|
|
// set; a min_tier that leaves edges keeps its own rows as the signal.
|
|
if dropped > 0 && len(kept) == 0 {
|
|
sg.TierFiltered = &graph.TierFilteredCaveat{
|
|
Class: graph.TierFilteredClass,
|
|
EdgesBelowMinTier: dropped,
|
|
MaxAvailableTier: maxDroppedOrigin,
|
|
}
|
|
}
|
|
}
|
|
|
|
// effectiveOrigin returns the edge's origin tier, backfilled for edges
|
|
// produced before Origin existed (or by providers not yet stamping it).
|
|
func effectiveOrigin(e *graph.Edge) string {
|
|
if e.Origin != "" {
|
|
return e.Origin
|
|
}
|
|
src, _ := e.Meta["semantic_source"].(string)
|
|
return graph.DefaultOriginFor(e.Kind, e.Confidence, src)
|
|
}
|
|
|
|
// SuppressRedundantTextMatches drops text_matched edges when the result
|
|
// also carries resolver-verified evidence (ast_inferred or better): once a
|
|
// symbol has real resolved references, the name-only fan-out — every
|
|
// same-named token in the repo — buries them. When text matches are the
|
|
// ONLY evidence they are all kept, so recall through dynamic code paths
|
|
// never regresses. Orphaned nodes are pruned; the drop count lands in
|
|
// TextMatchedSuppressed. Callers apply this only when the user did not
|
|
// pass an explicit min_tier.
|
|
func (sg *SubGraph) SuppressRedundantTextMatches() {
|
|
if sg == nil || len(sg.Edges) == 0 {
|
|
return
|
|
}
|
|
textRank := graph.OriginRank(graph.OriginTextMatched)
|
|
stronger := false
|
|
for _, e := range sg.Edges {
|
|
if graph.OriginRank(effectiveOrigin(e)) > textRank {
|
|
stronger = true
|
|
break
|
|
}
|
|
}
|
|
if !stronger {
|
|
return
|
|
}
|
|
kept := make([]*graph.Edge, 0, len(sg.Edges))
|
|
dropped := 0
|
|
for _, e := range sg.Edges {
|
|
// Drop exactly the text_matched tier. Untagged edges (rank 0)
|
|
// stay: they predate origin stamping and carry unknown — not
|
|
// low — confidence.
|
|
if e.Origin == graph.OriginTextMatched {
|
|
dropped++
|
|
continue
|
|
}
|
|
kept = append(kept, e)
|
|
}
|
|
if dropped == 0 {
|
|
return
|
|
}
|
|
sg.Edges = kept
|
|
sg.TextMatchedSuppressed = dropped
|
|
referenced := make(map[string]struct{}, len(kept)*2)
|
|
for _, e := range kept {
|
|
referenced[e.From] = struct{}{}
|
|
referenced[e.To] = struct{}{}
|
|
}
|
|
nodes := make([]*graph.Node, 0, len(sg.Nodes))
|
|
for _, n := range sg.Nodes {
|
|
if _, ok := referenced[n.ID]; ok {
|
|
nodes = append(nodes, n)
|
|
}
|
|
}
|
|
sg.Nodes = nodes
|
|
}
|
|
|
|
// FilterSpeculative drops best-guess speculative edges (Meta[speculative]=true)
|
|
// unless include is true. Called with include=false by default on every
|
|
// edge-returning query, so speculative dynamic-dispatch edges never pollute a
|
|
// default result — they are opt-in only.
|
|
func (sg *SubGraph) FilterSpeculative(include bool) {
|
|
if include || sg == nil {
|
|
return
|
|
}
|
|
kept := sg.Edges[:0]
|
|
for _, e := range sg.Edges {
|
|
if !e.IsSpeculative() {
|
|
kept = append(kept, e)
|
|
}
|
|
}
|
|
sg.Edges = kept
|
|
}
|
|
|
|
// ToDot returns a Graphviz DOT representation of the subgraph.
|
|
func (sg *SubGraph) ToDot() string {
|
|
var b strings.Builder
|
|
b.WriteString("digraph gortex {\n")
|
|
b.WriteString(" rankdir=LR;\n")
|
|
b.WriteString(" node [fontname=\"monospace\" fontsize=10];\n")
|
|
b.WriteString(" edge [fontname=\"monospace\" fontsize=8];\n\n")
|
|
|
|
kindColors := map[graph.NodeKind]string{
|
|
graph.KindFile: "#607D8B",
|
|
graph.KindPackage: "#bb9af7",
|
|
graph.KindFunction: "#7aa2f7",
|
|
graph.KindMethod: "#7dcfff",
|
|
graph.KindType: "#9ece6a",
|
|
graph.KindInterface: "#73daca",
|
|
graph.KindVariable: "#ff9e64",
|
|
graph.KindImport: "#795548",
|
|
}
|
|
|
|
kindShapes := map[graph.NodeKind]string{
|
|
graph.KindFile: "folder",
|
|
graph.KindFunction: "ellipse",
|
|
graph.KindMethod: "ellipse",
|
|
graph.KindType: "box",
|
|
graph.KindInterface: "box",
|
|
graph.KindVariable: "triangle",
|
|
graph.KindImport: "note",
|
|
graph.KindPackage: "diamond",
|
|
}
|
|
|
|
for _, n := range sg.Nodes {
|
|
color := kindColors[n.Kind]
|
|
if color == "" {
|
|
color = "#565f89"
|
|
}
|
|
shape := kindShapes[n.Kind]
|
|
if shape == "" {
|
|
shape = "ellipse"
|
|
}
|
|
label := fmt.Sprintf("%s\\n%s", n.Name, n.Kind)
|
|
fmt.Fprintf(&b, " %q [label=%q shape=%s style=filled fillcolor=%q fontcolor=white];\n",
|
|
n.ID, label, shape, color)
|
|
}
|
|
|
|
b.WriteString("\n")
|
|
|
|
edgeColors := map[graph.EdgeKind]string{
|
|
graph.EdgeCalls: "#7aa2f7",
|
|
graph.EdgeImports: "#565f89",
|
|
graph.EdgeDefines: "#414868",
|
|
graph.EdgeImplements: "#9ece6a",
|
|
graph.EdgeExtends: "#bb9af7",
|
|
graph.EdgeOverrides: "#f7768e",
|
|
graph.EdgeReferences: "#3b4261",
|
|
graph.EdgeMemberOf: "#3b4261",
|
|
graph.EdgeInstantiates: "#e0af68",
|
|
}
|
|
|
|
for _, e := range sg.Edges {
|
|
color := edgeColors[e.Kind]
|
|
if color == "" {
|
|
color = "#3b4261"
|
|
}
|
|
fmt.Fprintf(&b, " %q -> %q [label=%q color=%q];\n",
|
|
e.From, e.To, e.Kind, color)
|
|
}
|
|
|
|
b.WriteString("}\n")
|
|
return b.String()
|
|
}
|
|
|
|
// ToMermaid returns a Mermaid flowchart representation of the subgraph.
|
|
// Renders in GitHub, Notion, and most markdown viewers.
|
|
func (sg *SubGraph) ToMermaid() string {
|
|
var b strings.Builder
|
|
b.WriteString("graph LR\n")
|
|
|
|
// Mermaid node shapes by kind.
|
|
// [text] = rectangle, ([text]) = rounded, ((text)) = circle,
|
|
// {text} = diamond, >text] = flag, [(text)] = stadium
|
|
for _, n := range sg.Nodes {
|
|
safeID := mermaidID(n.ID)
|
|
label := fmt.Sprintf("%s\n%s", n.Name, n.Kind)
|
|
|
|
switch n.Kind {
|
|
case graph.KindFile:
|
|
fmt.Fprintf(&b, " %s[\"%s\"]\n", safeID, mermaidEscape(label))
|
|
case graph.KindFunction, graph.KindMethod:
|
|
fmt.Fprintf(&b, " %s([\"%s\"])\n", safeID, mermaidEscape(label))
|
|
case graph.KindType, graph.KindInterface:
|
|
fmt.Fprintf(&b, " %s[\"%s\"]\n", safeID, mermaidEscape(label))
|
|
case graph.KindVariable:
|
|
fmt.Fprintf(&b, " %s>\"%s\"]\n", safeID, mermaidEscape(label))
|
|
case graph.KindPackage:
|
|
fmt.Fprintf(&b, " %s{\"%s\"}\n", safeID, mermaidEscape(label))
|
|
default:
|
|
fmt.Fprintf(&b, " %s[\"%s\"]\n", safeID, mermaidEscape(label))
|
|
}
|
|
}
|
|
|
|
b.WriteString("\n")
|
|
|
|
// Mermaid edge styles by kind.
|
|
edgeStyles := map[graph.EdgeKind]string{
|
|
graph.EdgeCalls: "-->",
|
|
graph.EdgeImports: "-.->",
|
|
graph.EdgeDefines: "-->",
|
|
graph.EdgeImplements: "-. implements .->",
|
|
graph.EdgeExtends: "-. extends .->",
|
|
graph.EdgeOverrides: "-. overrides .->",
|
|
graph.EdgeReferences: "-->",
|
|
graph.EdgeMemberOf: "-->",
|
|
graph.EdgeInstantiates: "-. new .->",
|
|
}
|
|
|
|
for _, e := range sg.Edges {
|
|
style := edgeStyles[e.Kind]
|
|
if style == "" {
|
|
style = "-->"
|
|
}
|
|
fromID := mermaidID(e.From)
|
|
toID := mermaidID(e.To)
|
|
|
|
// For simple arrow styles, add the edge kind as label.
|
|
if style == "-->" || style == "-.->" {
|
|
fmt.Fprintf(&b, " %s %s|%s| %s\n", fromID, style, e.Kind, toID)
|
|
} else {
|
|
fmt.Fprintf(&b, " %s %s %s\n", fromID, style, toID)
|
|
}
|
|
}
|
|
|
|
// Style classes for node coloring.
|
|
b.WriteString("\n")
|
|
kindCSS := map[graph.NodeKind]string{
|
|
graph.KindFile: "fill:#607D8B,color:#fff",
|
|
graph.KindPackage: "fill:#bb9af7,color:#fff",
|
|
graph.KindFunction: "fill:#7aa2f7,color:#fff",
|
|
graph.KindMethod: "fill:#7dcfff,color:#fff",
|
|
graph.KindType: "fill:#9ece6a,color:#fff",
|
|
graph.KindInterface: "fill:#73daca,color:#fff",
|
|
graph.KindVariable: "fill:#ff9e64,color:#fff",
|
|
graph.KindImport: "fill:#795548,color:#fff",
|
|
}
|
|
|
|
// Group nodes by kind for class assignment.
|
|
byKind := make(map[graph.NodeKind][]string)
|
|
for _, n := range sg.Nodes {
|
|
byKind[n.Kind] = append(byKind[n.Kind], mermaidID(n.ID))
|
|
}
|
|
for kind, ids := range byKind {
|
|
css := kindCSS[kind]
|
|
if css == "" {
|
|
continue
|
|
}
|
|
fmt.Fprintf(&b, " classDef %s %s\n", kind, css)
|
|
fmt.Fprintf(&b, " class %s %s\n", strings.Join(ids, ","), kind)
|
|
}
|
|
|
|
return b.String()
|
|
}
|
|
|
|
// mermaidID converts a node ID to a Mermaid-safe identifier.
|
|
// Mermaid IDs can't contain ::, /, or dots.
|
|
func mermaidID(id string) string {
|
|
r := strings.NewReplacer(
|
|
"::", "_",
|
|
"/", "_",
|
|
".", "_",
|
|
"-", "_",
|
|
" ", "_",
|
|
"<", "_",
|
|
">", "_",
|
|
"(", "_",
|
|
")", "_",
|
|
)
|
|
return r.Replace(id)
|
|
}
|
|
|
|
// mermaidEscape escapes characters that break Mermaid labels.
|
|
func mermaidEscape(s string) string {
|
|
s = strings.ReplaceAll(s, "\"", "#quot;")
|
|
return s
|
|
}
|
|
|
|
// DefaultOptions returns options with sensible defaults.
|
|
func DefaultOptions() QueryOptions {
|
|
return QueryOptions{
|
|
Depth: 3,
|
|
Limit: 50,
|
|
Detail: "brief",
|
|
}
|
|
}
|
|
|
|
// WalkOptions controls a token-budgeted free-form graph traversal
|
|
// (Engine.WalkBudgeted). It is deliberately a separate struct from
|
|
// QueryOptions: a budgeted walk stops on an encoded-size estimate
|
|
// rather than a node count, and lets the caller pick an arbitrary set
|
|
// of edge kinds and a traversal direction — neither of which the
|
|
// fixed-purpose QueryOptions traversals expose.
|
|
type WalkOptions struct {
|
|
// EdgeKinds is the set of edge kinds the walk follows. An empty
|
|
// slice means "every edge kind" and, combined with Direction
|
|
// "both", reproduces an undirected neighbourhood walk.
|
|
EdgeKinds []graph.EdgeKind
|
|
// Direction is "out" (follow outgoing edges — the default when
|
|
// empty), "in" (follow incoming edges), or "both" (undirected).
|
|
Direction string
|
|
// TokenBudget is the approximate token ceiling for the encoded
|
|
// result. The walk stops appending nodes once the running estimate
|
|
// would exceed it. A non-positive value disables the budget.
|
|
TokenBudget int
|
|
// MaxDepth is a hard safety cap on BFS depth, applied even when the
|
|
// token budget would allow deeper expansion. A non-positive value
|
|
// falls back to a built-in default.
|
|
MaxDepth int
|
|
// WorkspaceID / ProjectID / RepoAllow scope the traversal exactly as the
|
|
// matching QueryOptions fields do — neighbours outside the scope
|
|
// are dropped along with the edge that reached them.
|
|
WorkspaceID string
|
|
ProjectID string
|
|
RepoAllow map[string]bool
|
|
// CommunityID, when non-empty, constrains the walk to a single
|
|
// detected community: a neighbour is admitted only when it has no
|
|
// community membership (a structural node Leiden never partitioned
|
|
// — file / import / param) OR its membership equals CommunityID.
|
|
// A neighbour with a *different* membership is dropped along with
|
|
// the edge that reached it. NodeToComm must be supplied for the
|
|
// filter to engage; an empty CommunityID disables it entirely.
|
|
CommunityID string
|
|
// NodeToComm maps node ID to its community ID, as produced by the
|
|
// community-detection pass. Only nodes Leiden partitioned over the
|
|
// call / reference graph appear here; an absent entry means "no
|
|
// defined membership" and the CommunityID filter lets such a node
|
|
// pass (it never had a community to be excluded from).
|
|
NodeToComm map[string]string
|
|
}
|
|
|
|
// scopeAllows reports whether n passes this walk's workspace/project
|
|
// scope. Mirrors QueryOptions.ScopeAllows so budgeted walks enforce the
|
|
// same boundary without duplicating the fallback rules.
|
|
func (o WalkOptions) scopeAllows(n *graph.Node) bool {
|
|
return QueryOptions{WorkspaceID: o.WorkspaceID, ProjectID: o.ProjectID, RepoAllow: o.RepoAllow}.ScopeAllows(n)
|
|
}
|