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
2719 lines
82 KiB
Go
2719 lines
82 KiB
Go
package mcp
|
|
|
|
import (
|
|
"bytes"
|
|
"context"
|
|
"encoding/json"
|
|
"fmt"
|
|
"sort"
|
|
"strconv"
|
|
"strings"
|
|
|
|
"github.com/mark3labs/mcp-go/mcp"
|
|
|
|
wire "github.com/gortexhq/gcx-go"
|
|
"github.com/zzet/gortex/internal/analysis"
|
|
"github.com/zzet/gortex/internal/contracts"
|
|
"github.com/zzet/gortex/internal/graph"
|
|
"github.com/zzet/gortex/internal/query"
|
|
)
|
|
|
|
// isGCX reports whether the caller requested the GCX1 compact wire
|
|
// format for this tool call. Selection order:
|
|
//
|
|
// 1. Explicit `format` arg on the request — wins unconditionally.
|
|
// `format: "json"` returns false even when the session default is
|
|
// gcx, so an agent can opt out per-call when it needs a JSON
|
|
// payload (e.g. piping to a non-GCX consumer).
|
|
// 2. Per-session default driven by the MCP `clientInfo.name` snooped
|
|
// at handshake — claude-code / cursor / vscode / zed / etc. ship
|
|
// with the @gortex/wire (or gcx-go) decoder so the daemon emits
|
|
// gcx by default for them. Other clients fall through.
|
|
// 3. JSON fallback for unknown clients.
|
|
//
|
|
// The Server receiver is needed only for the session lookup; nil
|
|
// receiver collapses to "explicit arg only" semantics, matching the
|
|
// pre-session-default behaviour.
|
|
func (s *Server) isGCX(ctx context.Context, req mcp.CallToolRequest) bool {
|
|
if v, ok := req.GetArguments()["format"].(string); ok && v != "" {
|
|
return wire.ParseFormat(v) == wire.FormatGCX
|
|
}
|
|
if s == nil {
|
|
return false
|
|
}
|
|
return s.resolveSessionFormat(ctx) == "gcx"
|
|
}
|
|
|
|
// gcxResponseWithBudget binds a request to the gcx-response builder.
|
|
// Returns a 2-arg function so it can be called inline with the
|
|
// encoder's `(payload []byte, err error)` return tuple — the same
|
|
// invocation shape as the legacy gcxResponse. Byte-level row-tail
|
|
// trimming kicks in when the caller opted into a budget (`max_bytes`
|
|
// or `paginate: true`); without opt-in the payload is forwarded
|
|
// untouched, so non-budgeted callers retain pre-budgeting behaviour
|
|
// (full result inline, transport-spilled if the harness cap fires).
|
|
func (s *Server) gcxResponseWithBudget(req mcp.CallToolRequest) func([]byte, error) (*mcp.CallToolResult, error) {
|
|
budget := effectiveBudget(req)
|
|
return func(payload []byte, err error) (*mcp.CallToolResult, error) {
|
|
if err != nil {
|
|
return mcp.NewToolResultError(fmt.Sprintf("wire encode failed: %v", err)), nil
|
|
}
|
|
if budget > 0 {
|
|
if trimmed, didTrim := trimGCXBytes(payload, budget); trimmed != nil {
|
|
payload = trimmed
|
|
if didTrim {
|
|
payload = decorateTokenBudgetGCX(payload, req)
|
|
}
|
|
}
|
|
}
|
|
return mcp.NewToolResultText(string(payload)), nil
|
|
}
|
|
}
|
|
|
|
// newGCX creates an encoder writing to w with the given tool + fields.
|
|
// Encoders own their header so section layout stays visible at the
|
|
// call site.
|
|
func newGCX(w *bytes.Buffer, tool string, fields []string, metaKV ...string) *wire.Encoder {
|
|
meta := map[string]string{}
|
|
for i := 0; i+1 < len(metaKV); i += 2 {
|
|
meta[metaKV[i]] = metaKV[i+1]
|
|
}
|
|
return wire.NewEncoder(w, wire.Header{
|
|
Tool: tool,
|
|
Fields: fields,
|
|
Meta: meta,
|
|
})
|
|
}
|
|
|
|
// nodeShort returns the short form of a node ID — whatever follows
|
|
// the last "::" separator. For methods this carries the receiver; for
|
|
// functions / types it is the plain name.
|
|
func nodeShort(n *graph.Node) string {
|
|
if n == nil {
|
|
return ""
|
|
}
|
|
// A prose-section node's ID tail is the slugified heading path
|
|
// ("doc:readme-setup-build") -- noise. Its Name is the readable
|
|
// breadcrumb, so use that.
|
|
if n.Kind == graph.KindDoc {
|
|
return n.Name
|
|
}
|
|
if idx := strings.LastIndex(n.ID, "::"); idx >= 0 {
|
|
return n.ID[idx+2:]
|
|
}
|
|
return n.Name
|
|
}
|
|
|
|
// nodeSig returns the rendered signature string for a node, falling
|
|
// back to "" when no signature was extracted.
|
|
func nodeSig(n *graph.Node) string {
|
|
if n == nil || n.Meta == nil {
|
|
return ""
|
|
}
|
|
// Prose-section nodes carry no signature -- surface a short
|
|
// snippet of the section body in the sig column instead, so a
|
|
// docs hit is self-describing in the compact GCX output.
|
|
if n.Kind == graph.KindDoc {
|
|
if txt, ok := n.Meta["section_text"].(string); ok && txt != "" {
|
|
const snippetCap = 160
|
|
if len(txt) > snippetCap {
|
|
return txt[:snippetCap] + "\u2026"
|
|
}
|
|
return txt
|
|
}
|
|
return ""
|
|
}
|
|
if s, ok := n.Meta["signature"].(string); ok {
|
|
return s
|
|
}
|
|
return ""
|
|
}
|
|
|
|
// nodeIsTest reports whether a node was flagged as a test by the
|
|
// indexer's test-edge pass (Meta["is_test"]).
|
|
func nodeIsTest(n *graph.Node) bool {
|
|
if n == nil || n.Meta == nil {
|
|
return false
|
|
}
|
|
v, _ := n.Meta["is_test"].(bool)
|
|
return v
|
|
}
|
|
|
|
// nodeTestRole returns the node's specific test role — "test",
|
|
// "benchmark", "fuzz", or "example" — or "" for non-test nodes.
|
|
func nodeTestRole(n *graph.Node) string {
|
|
if n == nil || n.Meta == nil {
|
|
return ""
|
|
}
|
|
r, _ := n.Meta["test_role"].(string)
|
|
return r
|
|
}
|
|
|
|
// nodeTypeFlavor returns the node's structural flavor (class / struct /
|
|
// enum / interface / …) stamped by the language extractors, or "".
|
|
func nodeTypeFlavor(n *graph.Node) string {
|
|
if n == nil || n.Meta == nil {
|
|
return ""
|
|
}
|
|
v, _ := n.Meta["type_flavor"].(string)
|
|
return v
|
|
}
|
|
|
|
// nodeUIComponent returns the node's UI-component framework name
|
|
// (react / vue / svelte / swiftui / …) stamped on a detected component
|
|
// node, or "".
|
|
func nodeUIComponent(n *graph.Node) string {
|
|
if n == nil || n.Meta == nil {
|
|
return ""
|
|
}
|
|
v, _ := n.Meta["ui_component"].(string)
|
|
return v
|
|
}
|
|
|
|
// nodeTestRunner returns the node's resolved test-runner identifier —
|
|
// "mocha" / "bun-test" / "jest" / "vitest" / "node-test" / "playwright"
|
|
// / "cypress" / "gotest" / "pytest" / "unittest" / "rspec" / "minitest"
|
|
// — or "" when the test-edge pass found no signal. Surfaced on the
|
|
// listing rows alongside is_test / test_role so agents can scope test
|
|
// pickers per runner (e.g. `--testNamePattern` vs `--grep`).
|
|
func nodeTestRunner(n *graph.Node) string {
|
|
if n == nil || n.Meta == nil {
|
|
return ""
|
|
}
|
|
r, _ := n.Meta["test_runner"].(string)
|
|
return r
|
|
}
|
|
|
|
// shouldSkipGraphNode filters File and Import pseudo-nodes the way the
|
|
// legacy compact / TOON formatters do — they add noise without
|
|
// informational value in symbol-oriented outputs.
|
|
func shouldSkipGraphNode(n *graph.Node) bool {
|
|
if n == nil {
|
|
return true
|
|
}
|
|
return n.Kind == graph.KindFile || n.Kind == graph.KindImport
|
|
}
|
|
|
|
// --------------------------------------------------------------------
|
|
// Hand-tuned encoders for the top-10 hot-path tools.
|
|
// --------------------------------------------------------------------
|
|
|
|
// encodeWinnowSymbols emits one row per ranked hit with per-axis score
|
|
// contributions. The contributions column is a pipe-separated list of
|
|
// `axis=value` pairs so decoders can recover the attribution without a
|
|
// nested structure.
|
|
func encodeWinnowSymbols(rows []winnowResult, total, limit int, weights map[string]float64) ([]byte, error) {
|
|
truncated := total > limit
|
|
if weights == nil {
|
|
weights = winnowAxisWeights
|
|
}
|
|
var buf bytes.Buffer
|
|
enc := newGCX(&buf, "winnow_symbols",
|
|
[]string{"id", "kind", "name", "path", "line", "sig", "score", "fan_in", "fan_out", "churn", "community", "contributions", "is_test", "test_role", "test_runner"},
|
|
"total", fmt.Sprintf("%d", total),
|
|
"truncated", boolString(truncated),
|
|
"weights", formatAxisWeights(weights),
|
|
)
|
|
if err := enc.WriteComment(fmt.Sprintf("%d result(s)", len(rows))); err != nil {
|
|
return nil, err
|
|
}
|
|
for _, r := range rows {
|
|
if shouldSkipGraphNode(r.Node) {
|
|
continue
|
|
}
|
|
if err := enc.WriteRow(
|
|
r.Node.ID,
|
|
string(r.Node.Kind),
|
|
nodeShort(r.Node),
|
|
r.Node.FilePath,
|
|
r.Node.StartLine,
|
|
nodeSig(r.Node),
|
|
roundFloat(r.Score),
|
|
r.FanIn,
|
|
r.FanOut,
|
|
r.Churn,
|
|
r.Community,
|
|
formatContributions(r.Contributions),
|
|
nodeIsTest(r.Node),
|
|
nodeTestRole(r.Node),
|
|
nodeTestRunner(r.Node),
|
|
); err != nil {
|
|
return nil, err
|
|
}
|
|
}
|
|
return buf.Bytes(), enc.Close()
|
|
}
|
|
|
|
// formatContributions renders the per-axis attribution map as a stable
|
|
// pipe-separated key=value list (sorted by key for determinism).
|
|
func formatContributions(m map[string]float64) string {
|
|
if len(m) == 0 {
|
|
return ""
|
|
}
|
|
keys := make([]string, 0, len(m))
|
|
for k := range m {
|
|
keys = append(keys, k)
|
|
}
|
|
sort.Strings(keys)
|
|
var b strings.Builder
|
|
for i, k := range keys {
|
|
if i > 0 {
|
|
b.WriteByte('|')
|
|
}
|
|
fmt.Fprintf(&b, "%s=%.3f", k, m[k])
|
|
}
|
|
return b.String()
|
|
}
|
|
|
|
// formatAxisWeights mirrors formatContributions for the meta header.
|
|
func formatAxisWeights(m map[string]float64) string {
|
|
return formatContributions(m)
|
|
}
|
|
|
|
// encodeSearchSymbols emits one row per search hit with the minimum
|
|
// fields an agent needs to decide whether to fetch more detail.
|
|
func encodeSearchSymbols(nodes []*graph.Node, total, limit int) ([]byte, error) {
|
|
truncated := total > limit
|
|
if len(nodes) > limit {
|
|
nodes = nodes[:limit]
|
|
}
|
|
var buf bytes.Buffer
|
|
enc := newGCX(&buf, "search_symbols",
|
|
[]string{"id", "kind", "name", "path", "path_abs", "line", "sig", "enclosing", "is_test", "test_role", "test_runner", "type_flavor", "ui_component"},
|
|
"total", fmt.Sprintf("%d", total),
|
|
"truncated", boolString(truncated),
|
|
)
|
|
if err := enc.WriteComment(fmt.Sprintf("%d result(s)", len(nodes))); err != nil {
|
|
return nil, err
|
|
}
|
|
for _, n := range nodes {
|
|
if shouldSkipGraphNode(n) {
|
|
continue
|
|
}
|
|
_, encName := graph.EnclosingFromID(n.ID, n.Kind)
|
|
if err := enc.WriteRow(
|
|
n.ID,
|
|
string(n.Kind),
|
|
nodeShort(n),
|
|
n.FilePath,
|
|
n.AbsoluteFilePath,
|
|
n.StartLine,
|
|
nodeSig(n),
|
|
encName,
|
|
nodeIsTest(n),
|
|
nodeTestRole(n),
|
|
nodeTestRunner(n),
|
|
nodeTypeFlavor(n),
|
|
nodeUIComponent(n),
|
|
); err != nil {
|
|
return nil, err
|
|
}
|
|
}
|
|
return buf.Bytes(), enc.Close()
|
|
}
|
|
|
|
// encodeGetSymbolSource emits a single row carrying the node
|
|
// metadata plus the full source. Source is escaped line-by-line.
|
|
func encodeGetSymbolSource(node *graph.Node, source string, fromLine int, etag, omissionKinds string) ([]byte, error) {
|
|
var buf bytes.Buffer
|
|
meta := []string{"etag", etag}
|
|
if omissionKinds != "" {
|
|
meta = append(meta, "omissions", omissionKinds)
|
|
}
|
|
enc := newGCX(&buf, "get_symbol_source",
|
|
[]string{"id", "kind", "name", "path", "start_line", "end_line", "from_line", "sig", "etag", "source"},
|
|
meta...,
|
|
)
|
|
if err := enc.WriteRow(
|
|
node.ID,
|
|
string(node.Kind),
|
|
node.Name,
|
|
node.FilePath,
|
|
node.StartLine,
|
|
node.EndLine,
|
|
fromLine,
|
|
nodeSig(node),
|
|
etag,
|
|
source,
|
|
); err != nil {
|
|
return nil, err
|
|
}
|
|
return buf.Bytes(), enc.Close()
|
|
}
|
|
|
|
// encodeBatchSymbols emits one row per requested symbol; missing
|
|
// symbols carry an error cell instead of a real node.
|
|
func encodeBatchSymbols(rows []map[string]any, includeSource bool) ([]byte, error) {
|
|
fields := []string{"id", "kind", "name", "path", "start_line", "end_line", "sig"}
|
|
if includeSource {
|
|
fields = append(fields, "source")
|
|
}
|
|
fields = append(fields, "error")
|
|
var buf bytes.Buffer
|
|
enc := newGCX(&buf, "batch_symbols", fields,
|
|
"count", fmt.Sprintf("%d", len(rows)),
|
|
)
|
|
for _, r := range rows {
|
|
values := []any{
|
|
str(r["id"]),
|
|
str(r["kind"]),
|
|
str(r["name"]),
|
|
str(r["file_path"]),
|
|
r["start_line"],
|
|
r["end_line"],
|
|
str(r["signature"]),
|
|
}
|
|
if includeSource {
|
|
values = append(values, str(r["source"]))
|
|
}
|
|
values = append(values, str(r["error"]))
|
|
if err := enc.WriteRow(values...); err != nil {
|
|
return nil, err
|
|
}
|
|
}
|
|
return buf.Bytes(), enc.Close()
|
|
}
|
|
|
|
// zeroEdgeCaveatMeta renders a zero-edge caveat as GCX header meta
|
|
// key/value pairs (caveat + caveat_message). Returns nil when the
|
|
// caveat is absent so a non-empty result carries no extra meta.
|
|
func zeroEdgeCaveatMeta(c *graph.ZeroEdgeCaveat) []string {
|
|
if c == nil {
|
|
return nil
|
|
}
|
|
return []string{"caveat", string(c.Class), "caveat_message", c.Message}
|
|
}
|
|
|
|
// tierFilteredCaveatMeta lowers a min_tier-filtered caveat into GCX meta so a
|
|
// tier-emptied result reads as "filtered", not "no usages".
|
|
func tierFilteredCaveatMeta(c *graph.TierFilteredCaveat) []string {
|
|
if c == nil {
|
|
return nil
|
|
}
|
|
return []string{
|
|
"tier_filtered", c.Class,
|
|
"edges_below_min_tier", fmt.Sprintf("%d", c.EdgesBelowMinTier),
|
|
"max_available_tier", c.MaxAvailableTier,
|
|
}
|
|
}
|
|
|
|
// encodeFindUsages emits one row per usage edge. Each row names the
|
|
// caller symbol, its location, the edge kind, and the origin tier so
|
|
// agents can filter without a second call.
|
|
func encodeFindUsages(sg *query.SubGraph, g graph.Store) ([]byte, error) {
|
|
var buf bytes.Buffer
|
|
meta := []string{"edges", fmt.Sprintf("%d", len(sg.Edges))}
|
|
meta = append(meta, zeroEdgeCaveatMeta(sg.Caveat)...)
|
|
meta = append(meta, tierFilteredCaveatMeta(sg.TierFiltered)...)
|
|
if sg.TextMatchedSuppressed > 0 {
|
|
meta = append(meta, "text_matched_suppressed", fmt.Sprintf("%d", sg.TextMatchedSuppressed))
|
|
}
|
|
if sg.SuppressionCaveat != "" {
|
|
meta = append(meta, "suppression_caveat", sg.SuppressionCaveat)
|
|
}
|
|
if sg.RelatedTools != "" {
|
|
meta = append(meta, "related_tools", sg.RelatedTools)
|
|
}
|
|
if s := sg.UsageSummary; s != nil {
|
|
// Completeness rollup, same three numbers as the JSON / TOON
|
|
// usage_summary so an agent reads the split without re-grepping
|
|
// tests. n_refs mirrors edges above; both are kept so the summary
|
|
// is self-contained across wire formats.
|
|
meta = append(meta,
|
|
"n_refs", fmt.Sprintf("%d", s.NRefs),
|
|
"n_files", fmt.Sprintf("%d", s.NFiles),
|
|
"n_test_refs", fmt.Sprintf("%d", s.NTestRefs),
|
|
)
|
|
}
|
|
enc := newGCX(&buf, "find_usages",
|
|
[]string{"from", "to", "edge_kind", "context", "return_usage", "origin", "tier", "confidence", "from_name", "from_path", "from_line", "from_is_test", "from_test_role", "from_test_runner", "from_type_flavor", "from_ui_component"},
|
|
meta...,
|
|
)
|
|
nodeIdx := indexNodes(sg.Nodes)
|
|
for _, e := range sg.Edges {
|
|
fn := nodeIdx[e.From]
|
|
var fname, fpath string
|
|
var fline int
|
|
if fn != nil {
|
|
fname = nodeShort(fn)
|
|
fpath = fn.FilePath
|
|
fline = fn.StartLine
|
|
}
|
|
// Prefer the edge's own call-site line over the enclosing
|
|
// symbol's start. Two calls from the same caller used to
|
|
// surface as duplicate rows pinned to the caller's first
|
|
// line; the edge line is what the agent actually wants to
|
|
// jump to.
|
|
if e.Line > 0 {
|
|
fline = e.Line
|
|
}
|
|
if e.FilePath != "" {
|
|
fpath = e.FilePath
|
|
}
|
|
tier := e.Tier
|
|
if tier == "" {
|
|
tier = graph.ResolvedBy(e.Origin)
|
|
}
|
|
ftf, fuc := usageFromFlavor(g, e.From, fn)
|
|
if err := enc.WriteRow(
|
|
e.From, e.To, string(e.Kind), e.Context, e.ReturnUsage, e.Origin, tier, e.Confidence,
|
|
fname, fpath, fline, nodeIsTest(fn), nodeTestRole(fn), nodeTestRunner(fn), ftf, fuc,
|
|
); err != nil {
|
|
return nil, err
|
|
}
|
|
}
|
|
return buf.Bytes(), enc.Close()
|
|
}
|
|
|
|
// encodeSubGraph is a shared encoder for the edge-returning traversal
|
|
// tools (get_callers, get_call_chain, get_dependencies, get_dependents,
|
|
// find_implementations). Emits two sections — nodes then edges — plus a
|
|
// third caller_notes section when get_callers attached concurrency
|
|
// annotations. The third section is omitted entirely when empty so the
|
|
// other traversal tools' wire output is byte-identical to before.
|
|
func encodeSubGraph(tool string, sg *query.SubGraph) ([]byte, error) {
|
|
var buf bytes.Buffer
|
|
nodes := make([]*graph.Node, 0, len(sg.Nodes))
|
|
for _, n := range sg.Nodes {
|
|
if shouldSkipGraphNode(n) {
|
|
continue
|
|
}
|
|
nodes = append(nodes, n)
|
|
}
|
|
nodeMeta := []string{
|
|
"total", fmt.Sprintf("%d", sg.TotalNodes),
|
|
"truncated", boolString(sg.Truncated),
|
|
}
|
|
// Budgeted traversals (walk_graph) ride two extra meta fields. They
|
|
// are emitted only when meaningful so non-budgeted subgraphs keep
|
|
// their existing wire shape byte-for-byte.
|
|
if sg.BudgetHit {
|
|
nodeMeta = append(nodeMeta, "budget_hit", boolString(sg.BudgetHit))
|
|
}
|
|
if sg.StoppedAtDepth > 0 {
|
|
nodeMeta = append(nodeMeta, "stopped_at_depth", fmt.Sprintf("%d", sg.StoppedAtDepth))
|
|
}
|
|
// Epistemic lower-bound flag rides the node meta only when set, so a
|
|
// result with no dispatch boundary keeps its wire shape byte-for-byte.
|
|
if sg.LowerBound {
|
|
nodeMeta = append(nodeMeta, "lower_bound", boolString(sg.LowerBound))
|
|
}
|
|
nodeEnc := newGCX(&buf, tool+".nodes",
|
|
[]string{"id", "kind", "name", "path", "path_abs", "line", "is_test", "test_role", "test_runner"},
|
|
nodeMeta...,
|
|
)
|
|
for _, n := range nodes {
|
|
if err := nodeEnc.WriteRow(n.ID, string(n.Kind), nodeShort(n), n.FilePath, n.AbsoluteFilePath, n.StartLine, nodeIsTest(n), nodeTestRole(n), nodeTestRunner(n)); err != nil {
|
|
return nil, err
|
|
}
|
|
}
|
|
if err := nodeEnc.Close(); err != nil {
|
|
return nil, err
|
|
}
|
|
edgeMeta := []string{"count", fmt.Sprintf("%d", len(sg.Edges))}
|
|
edgeMeta = append(edgeMeta, zeroEdgeCaveatMeta(sg.Caveat)...)
|
|
if sg.TextMatchedSuppressed > 0 {
|
|
edgeMeta = append(edgeMeta, "text_matched_suppressed", fmt.Sprintf("%d", sg.TextMatchedSuppressed))
|
|
}
|
|
if sg.SuppressionCaveat != "" {
|
|
edgeMeta = append(edgeMeta, "suppression_caveat", sg.SuppressionCaveat)
|
|
}
|
|
if sg.RelatedTools != "" {
|
|
edgeMeta = append(edgeMeta, "related_tools", sg.RelatedTools)
|
|
}
|
|
edgeEnc := newGCX(&buf, tool+".edges",
|
|
// line + file_path on the edge let the caller distinguish two
|
|
// call sites with the same (from, to, kind). Without them
|
|
// walk_graph / get_callers / get_call_chain etc. surfaced
|
|
// duplicate-looking rows that an agent couldn't tell apart and
|
|
// couldn't jump to.
|
|
[]string{"from", "to", "kind", "origin", "tier", "via", "confidence", "label", "line", "file_path"},
|
|
edgeMeta...,
|
|
)
|
|
for _, e := range sg.Edges {
|
|
label := e.ConfidenceLabel
|
|
if label == "" {
|
|
label = graph.ConfidenceLabelFor(e.Kind, e.Confidence)
|
|
}
|
|
tier := e.Tier
|
|
if tier == "" {
|
|
tier = graph.ResolvedBy(e.Origin)
|
|
}
|
|
via := e.Via
|
|
if via == "" && e.Meta != nil {
|
|
if v, _ := e.Meta["via"].(string); v != "" {
|
|
via = graph.ViaLabelFor(v)
|
|
}
|
|
}
|
|
if err := edgeEnc.WriteRow(e.From, e.To, string(e.Kind), e.Origin, tier, via, e.Confidence, label, e.Line, e.FilePath); err != nil {
|
|
return nil, err
|
|
}
|
|
}
|
|
if err := edgeEnc.Close(); err != nil {
|
|
return nil, err
|
|
}
|
|
// caller_notes — one row per caller carrying a concurrency flag. Emitted
|
|
// only when get_callers attached annotations, so other traversal tools'
|
|
// wire output is byte-identical to before.
|
|
if len(sg.CallerNotes) > 0 {
|
|
noteIDs := make([]string, 0, len(sg.CallerNotes))
|
|
for id := range sg.CallerNotes {
|
|
noteIDs = append(noteIDs, id)
|
|
}
|
|
sort.Strings(noteIDs)
|
|
noteEnc := newGCX(&buf, tool+".caller_notes",
|
|
[]string{"id", "sync_guarded", "sync_guarded_why", "cross_concurrent", "cross_concurrent_why"},
|
|
"count", fmt.Sprintf("%d", len(noteIDs)),
|
|
)
|
|
for _, id := range noteIDs {
|
|
a := sg.CallerNotes[id]
|
|
if err := noteEnc.WriteRow(id, a.SyncGuarded, a.SyncGuardedWhy, a.CrossConcurrent, a.CrossConcurrentWhy); err != nil {
|
|
return nil, err
|
|
}
|
|
}
|
|
if err := noteEnc.Close(); err != nil {
|
|
return nil, err
|
|
}
|
|
}
|
|
// boundaries — the epistemic lower-bound diagnosis. Emitted only when the
|
|
// walk crossed a dynamic-dispatch / unresolved site, so results with none
|
|
// keep their existing wire bytes.
|
|
if len(sg.Boundaries) > 0 {
|
|
bEnc := newGCX(&buf, tool+".boundaries",
|
|
[]string{"seed_id", "target", "edge_kind", "reason", "direction"},
|
|
"count", fmt.Sprintf("%d", len(sg.Boundaries)),
|
|
)
|
|
for _, b := range sg.Boundaries {
|
|
if err := bEnc.WriteRow(b.SeedID, b.Target, b.EdgeKind, string(b.Reason), b.Direction); err != nil {
|
|
return nil, err
|
|
}
|
|
}
|
|
if err := bEnc.Close(); err != nil {
|
|
return nil, err
|
|
}
|
|
}
|
|
return buf.Bytes(), nil
|
|
}
|
|
|
|
// encodeFileSummary emits one row per symbol in a file plus a trailing
|
|
// edge-distribution comment. Pulls the edge total from sg.TotalEdges
|
|
// rather than len(sg.Edges) so the count-only handler path (which
|
|
// leaves the Edge slice nil to avoid materialising every adjacent
|
|
// edge over cgo) still reports the right number.
|
|
func encodeFileSummary(sg *query.SubGraph, etag string) ([]byte, error) {
|
|
var buf bytes.Buffer
|
|
totalEdges := sg.TotalEdges
|
|
if totalEdges == 0 {
|
|
totalEdges = len(sg.Edges)
|
|
}
|
|
enc := newGCX(&buf, "get_file_summary",
|
|
[]string{"id", "kind", "name", "line", "sig"},
|
|
"total_nodes", fmt.Sprintf("%d", sg.TotalNodes),
|
|
"total_edges", fmt.Sprintf("%d", totalEdges),
|
|
"truncated", boolString(sg.Truncated),
|
|
"etag", etag,
|
|
)
|
|
for _, n := range sg.Nodes {
|
|
if shouldSkipGraphNode(n) {
|
|
continue
|
|
}
|
|
if err := enc.WriteRow(n.ID, string(n.Kind), nodeShort(n), n.StartLine, nodeSig(n)); err != nil {
|
|
return nil, err
|
|
}
|
|
}
|
|
return buf.Bytes(), enc.Close()
|
|
}
|
|
|
|
// encodeAnalyze routes to per-kind encoders. The `kind` field is
|
|
// placed in the meta header rather than per-row so consumers can
|
|
// dispatch without inspecting every row.
|
|
func encodeAnalyze(kind string, payload any) ([]byte, error) {
|
|
var buf bytes.Buffer
|
|
switch kind {
|
|
case "dead_code":
|
|
items, _ := payload.([]deadCodeItem)
|
|
enc := newGCX(&buf, "analyze.dead_code",
|
|
[]string{"id", "kind", "name", "path", "line", "reason"},
|
|
"count", fmt.Sprintf("%d", len(items)),
|
|
)
|
|
for _, it := range items {
|
|
if err := enc.WriteRow(it.ID, it.Kind, it.Name, it.Path, it.Line, it.Reason); err != nil {
|
|
return nil, err
|
|
}
|
|
}
|
|
return buf.Bytes(), enc.Close()
|
|
case "hotspots":
|
|
items, _ := payload.([]hotspotItem)
|
|
enc := newGCX(&buf, "analyze.hotspots",
|
|
[]string{"id", "name", "path", "line", "fan_in", "fan_out", "cross_cut", "betweenness", "score"},
|
|
"count", fmt.Sprintf("%d", len(items)),
|
|
)
|
|
for _, it := range items {
|
|
if err := enc.WriteRow(it.ID, it.Name, it.Path, it.Line, it.FanIn, it.FanOut, it.CrossCommunity, it.Betweenness, it.Score); err != nil {
|
|
return nil, err
|
|
}
|
|
}
|
|
return buf.Bytes(), enc.Close()
|
|
case "cycles":
|
|
cycles, _ := payload.([]cycleItem)
|
|
enc := newGCX(&buf, "analyze.cycles",
|
|
[]string{"size", "severity", "nodes"},
|
|
"count", fmt.Sprintf("%d", len(cycles)),
|
|
)
|
|
for _, c := range cycles {
|
|
if err := enc.WriteRow(c.Size, c.Severity, strings.Join(c.Nodes, ",")); err != nil {
|
|
return nil, err
|
|
}
|
|
}
|
|
return buf.Bytes(), enc.Close()
|
|
case "def_use":
|
|
items, _ := payload.([]defUseItem)
|
|
enc := newGCX(&buf, "analyze.def_use",
|
|
[]string{"symbol", "var", "use_line", "use_text", "def_lines"},
|
|
"count", fmt.Sprintf("%d", len(items)),
|
|
)
|
|
for _, it := range items {
|
|
if err := enc.WriteRow(it.Symbol, it.Var, it.UseLine, it.UseText, it.DefLines); err != nil {
|
|
return nil, err
|
|
}
|
|
}
|
|
return buf.Bytes(), enc.Close()
|
|
case "channel_ops":
|
|
items, _ := payload.([]channelOpItem)
|
|
enc := newGCX(&buf, "analyze.channel_ops",
|
|
[]string{"channel", "sends", "recvs", "senders", "receivers"},
|
|
"count", fmt.Sprintf("%d", len(items)),
|
|
)
|
|
for _, it := range items {
|
|
if err := enc.WriteRow(it.Channel, it.Sends, it.Recvs, it.Senders, it.Receivers); err != nil {
|
|
return nil, err
|
|
}
|
|
}
|
|
return buf.Bytes(), enc.Close()
|
|
case "goroutine_spawns":
|
|
items, _ := payload.([]spawnItem)
|
|
enc := newGCX(&buf, "analyze.goroutine_spawns",
|
|
[]string{"target", "mode", "spawns", "spawners", "sync_guarded", "sync_guarded_why", "cross_concurrent", "cross_concurrent_why"},
|
|
"count", fmt.Sprintf("%d", len(items)),
|
|
)
|
|
for _, it := range items {
|
|
if err := enc.WriteRow(it.Target, it.Mode, it.Spawns, it.Spawners, it.SyncGuarded, it.SyncGuardedWhy, it.CrossConcurrent, it.CrossConcurrentWhy); err != nil {
|
|
return nil, err
|
|
}
|
|
}
|
|
return buf.Bytes(), enc.Close()
|
|
case "field_writers":
|
|
items, _ := payload.([]fieldWriterItem)
|
|
enc := newGCX(&buf, "analyze.field_writers",
|
|
[]string{"field", "writes", "writers"},
|
|
"count", fmt.Sprintf("%d", len(items)),
|
|
)
|
|
for _, it := range items {
|
|
if err := enc.WriteRow(it.Field, it.Writes, it.Writers); err != nil {
|
|
return nil, err
|
|
}
|
|
}
|
|
return buf.Bytes(), enc.Close()
|
|
case "race_writes":
|
|
items, _ := payload.([]raceWriteItem)
|
|
enc := newGCX(&buf, "analyze.race_writes",
|
|
[]string{"field", "writer", "file", "line", "reason"},
|
|
"count", fmt.Sprintf("%d", len(items)),
|
|
)
|
|
for _, it := range items {
|
|
if err := enc.WriteRow(it.Field, it.Writer, it.FilePath, it.Line, it.Reason); err != nil {
|
|
return nil, err
|
|
}
|
|
}
|
|
return buf.Bytes(), enc.Close()
|
|
case "unclosed_channels":
|
|
items, _ := payload.([]unclosedChannelItem)
|
|
enc := newGCX(&buf, "analyze.unclosed_channels",
|
|
[]string{"channel", "file", "line", "sends", "recvs", "senders", "risk", "reason"},
|
|
"count", fmt.Sprintf("%d", len(items)),
|
|
)
|
|
for _, it := range items {
|
|
if err := enc.WriteRow(it.Channel, it.FilePath, it.Line, it.Sends, it.Recvs, it.Senders, it.Risk, it.Reason); err != nil {
|
|
return nil, err
|
|
}
|
|
}
|
|
return buf.Bytes(), enc.Close()
|
|
case "annotation_users":
|
|
items, _ := payload.([]annotatedItem)
|
|
enc := newGCX(&buf, "analyze.annotation_users",
|
|
[]string{"symbol", "file", "line", "args"},
|
|
"count", fmt.Sprintf("%d", len(items)),
|
|
)
|
|
for _, it := range items {
|
|
if err := enc.WriteRow(it.Symbol, it.File, it.Line, it.Args); err != nil {
|
|
return nil, err
|
|
}
|
|
}
|
|
return buf.Bytes(), enc.Close()
|
|
case "annotation_users.list":
|
|
items, _ := payload.([]annotationItem)
|
|
enc := newGCX(&buf, "analyze.annotation_users.list",
|
|
[]string{"id", "name", "users"},
|
|
"count", fmt.Sprintf("%d", len(items)),
|
|
)
|
|
for _, it := range items {
|
|
if err := enc.WriteRow(it.ID, it.Name, it.Users); err != nil {
|
|
return nil, err
|
|
}
|
|
}
|
|
return buf.Bytes(), enc.Close()
|
|
case "config_readers":
|
|
items, _ := payload.([]configReaderItem)
|
|
enc := newGCX(&buf, "analyze.config_readers",
|
|
[]string{"id", "name", "source", "reads", "readers"},
|
|
"count", fmt.Sprintf("%d", len(items)),
|
|
)
|
|
for _, it := range items {
|
|
if err := enc.WriteRow(it.ID, it.Name, it.Source, it.Reads, it.Readers); err != nil {
|
|
return nil, err
|
|
}
|
|
}
|
|
return buf.Bytes(), enc.Close()
|
|
case "event_emitters":
|
|
items, _ := payload.([]eventEmitterItem)
|
|
enc := newGCX(&buf, "analyze.event_emitters",
|
|
[]string{"id", "name", "event_kind", "emits", "emitters"},
|
|
"count", fmt.Sprintf("%d", len(items)),
|
|
)
|
|
for _, it := range items {
|
|
if err := enc.WriteRow(it.ID, it.Name, it.Kind, it.Emits, it.Emitters); err != nil {
|
|
return nil, err
|
|
}
|
|
}
|
|
return buf.Bytes(), enc.Close()
|
|
case "pubsub":
|
|
items, _ := payload.([]pubsubItem)
|
|
enc := newGCX(&buf, "analyze.pubsub",
|
|
[]string{"id", "name", "transport", "publishes", "subscribes", "publishers", "subscribers"},
|
|
"count", fmt.Sprintf("%d", len(items)),
|
|
)
|
|
for _, it := range items {
|
|
if err := enc.WriteRow(it.ID, it.Name, it.Transport, it.Publishes, it.Subscribes, it.Publishers, it.Subscribers); err != nil {
|
|
return nil, err
|
|
}
|
|
}
|
|
return buf.Bytes(), enc.Close()
|
|
case "string_emitters":
|
|
items, _ := payload.([]stringEmitterItem)
|
|
enc := newGCX(&buf, "analyze.string_emitters",
|
|
[]string{"id", "context", "value", "emits", "emitters"},
|
|
"count", fmt.Sprintf("%d", len(items)),
|
|
)
|
|
for _, it := range items {
|
|
if err := enc.WriteRow(it.ID, it.Context, it.Value, it.Emits, it.Emitters); err != nil {
|
|
return nil, err
|
|
}
|
|
}
|
|
return buf.Bytes(), enc.Close()
|
|
case "error_surface":
|
|
items, _ := payload.([]errorSurfaceItem)
|
|
enc := newGCX(&buf, "analyze.error_surface",
|
|
[]string{"symbol", "file", "line", "throws", "errors", "error_msgs"},
|
|
"count", fmt.Sprintf("%d", len(items)),
|
|
)
|
|
for _, it := range items {
|
|
if err := enc.WriteRow(it.Symbol, it.File, it.Line, it.Throws, it.Errors, it.ErrorMsgs); err != nil {
|
|
return nil, err
|
|
}
|
|
}
|
|
return buf.Bytes(), enc.Close()
|
|
case "log_events":
|
|
items, _ := payload.([]logEventItem)
|
|
enc := newGCX(&buf, "analyze.log_events",
|
|
[]string{"id", "value", "level", "emits", "emitters"},
|
|
"count", fmt.Sprintf("%d", len(items)),
|
|
)
|
|
for _, it := range items {
|
|
if err := enc.WriteRow(it.ID, it.Value, it.Level, it.Emits, it.Emitters); err != nil {
|
|
return nil, err
|
|
}
|
|
}
|
|
return buf.Bytes(), enc.Close()
|
|
case "sql_rebuild":
|
|
items, _ := payload.([]sqlRebuildItem)
|
|
enc := newGCX(&buf, "analyze.sql_rebuild",
|
|
[]string{"strings_visited", "tables_created", "columns_created", "query_edges", "reads_col_edges", "writes_col_edges", "emitters_linked", "skipped"},
|
|
"count", fmt.Sprintf("%d", len(items)),
|
|
)
|
|
for _, it := range items {
|
|
if err := enc.WriteRow(it.StringsVisited, it.TablesCreated, it.ColumnsCreated, it.QueryEdges, it.ReadColEdges, it.WriteColEdges, it.EmittersLinked, it.Skipped); err != nil {
|
|
return nil, err
|
|
}
|
|
}
|
|
return buf.Bytes(), enc.Close()
|
|
case "cross_repo":
|
|
items, _ := payload.([]crossRepoItem)
|
|
enc := newGCX(&buf, "analyze.cross_repo",
|
|
[]string{"from_repo", "to_repo", "kind", "count", "samples"},
|
|
"count", fmt.Sprintf("%d", len(items)),
|
|
)
|
|
for _, it := range items {
|
|
if err := enc.WriteRow(it.FromRepo, it.ToRepo, it.Kind, it.Count, it.Samples); err != nil {
|
|
return nil, err
|
|
}
|
|
}
|
|
return buf.Bytes(), enc.Close()
|
|
case "unsafe_patterns":
|
|
items, _ := payload.([]unsafePatternItem)
|
|
enc := newGCX(&buf, "analyze.unsafe_patterns",
|
|
[]string{"detector", "severity", "language", "file", "line", "symbol", "text"},
|
|
"count", fmt.Sprintf("%d", len(items)),
|
|
)
|
|
for _, it := range items {
|
|
if err := enc.WriteRow(it.Detector, it.Severity, it.Language, it.File, it.Line, it.Symbol, it.Text); err != nil {
|
|
return nil, err
|
|
}
|
|
}
|
|
return buf.Bytes(), enc.Close()
|
|
case "health_score":
|
|
items, _ := payload.([]healthScoreItem)
|
|
enc := newGCX(&buf, "analyze.health_score",
|
|
[]string{
|
|
"id", "name", "kind", "file", "line",
|
|
"score", "grade",
|
|
"coverage_pct", "complexity_pct", "recency_pct", "churn_pct",
|
|
"fan_in", "fan_out", "crossings",
|
|
"age_days", "mods", "axes_used",
|
|
},
|
|
"count", fmt.Sprintf("%d", len(items)),
|
|
)
|
|
for _, it := range items {
|
|
if err := enc.WriteRow(
|
|
it.ID, it.Name, it.Kind, it.File, it.Line,
|
|
it.Score, it.Grade,
|
|
it.CoveragePct, it.ComplexityPct, it.RecencyPct, it.ChurnPct,
|
|
it.FanIn, it.FanOut, it.Crossings,
|
|
it.AgeDays, it.Mods, it.AxesUsed,
|
|
); err != nil {
|
|
return nil, err
|
|
}
|
|
}
|
|
return buf.Bytes(), enc.Close()
|
|
case "health_score.rollup":
|
|
items, _ := payload.([]healthRollupItem)
|
|
enc := newGCX(&buf, "analyze.health_score.rollup",
|
|
[]string{
|
|
"scope", "key",
|
|
"avg_score", "min_score", "max_score",
|
|
"symbols", "grade",
|
|
"count_a", "count_b", "count_c", "count_d", "count_f",
|
|
},
|
|
"count", fmt.Sprintf("%d", len(items)),
|
|
)
|
|
for _, it := range items {
|
|
if err := enc.WriteRow(
|
|
it.Scope, it.Key,
|
|
it.AvgScore, it.MinScore, it.MaxScore,
|
|
it.Symbols, it.Grade,
|
|
it.CountA, it.CountB, it.CountC, it.CountD, it.CountF,
|
|
); err != nil {
|
|
return nil, err
|
|
}
|
|
}
|
|
return buf.Bytes(), enc.Close()
|
|
default:
|
|
// Fall back to generic so analyze variants without a hand-tuned
|
|
// encoder still produce valid GCX instead of failing.
|
|
if err := wire.EncodeAny(&buf, "analyze."+kind, payload); err != nil {
|
|
return nil, err
|
|
}
|
|
return buf.Bytes(), nil
|
|
}
|
|
}
|
|
|
|
// Narrow row contracts used by analyze.* encoders so the caller can
|
|
// build them from heterogeneous internal structs.
|
|
type deadCodeItem struct {
|
|
ID, Kind, Name, Path, Reason string
|
|
Line int
|
|
}
|
|
|
|
type hotspotItem struct {
|
|
ID, Name, Path string
|
|
Line, FanIn int
|
|
FanOut int
|
|
CrossCommunity int
|
|
Betweenness float64
|
|
Score float64
|
|
}
|
|
|
|
type cycleItem struct {
|
|
Size int
|
|
Severity string
|
|
Nodes []string
|
|
}
|
|
|
|
// Row contracts for the edge-driven analyzers (see
|
|
// tools_analyze_edges.go). Values are pre-flattened (slices joined)
|
|
// so the GCX encoder writes one scalar column per field.
|
|
type channelOpItem struct {
|
|
Channel string
|
|
Sends int
|
|
Recvs int
|
|
Senders string
|
|
Receivers string
|
|
}
|
|
|
|
type spawnItem struct {
|
|
Target string
|
|
Mode string
|
|
Spawns int
|
|
// Spawners is a comma-joined list of caller node IDs.
|
|
Spawners string
|
|
// Concurrency columns carry the shared classification of the
|
|
// spawned target — sync_guarded (goroutine body on a lock-holding
|
|
// type) and cross_concurrent (reached across a concurrency
|
|
// boundary). Empty when the target carries neither flag.
|
|
SyncGuarded bool
|
|
SyncGuardedWhy string
|
|
CrossConcurrent bool
|
|
CrossConcurrentWhy string
|
|
}
|
|
|
|
type fieldWriterItem struct {
|
|
Field string
|
|
Writes int
|
|
Writers string
|
|
}
|
|
|
|
// raceWriteItem is one row of the `race_writes` analyzer: a field
|
|
// write that fires from inside a goroutine-reachable function with
|
|
// no detected lock guard.
|
|
type raceWriteItem struct {
|
|
Field string
|
|
Writer string
|
|
FilePath string
|
|
Line int
|
|
Reason string
|
|
}
|
|
|
|
// unclosedChannelItem is one row of the `unclosed_channels`
|
|
// analyzer: a channel that takes sends but nobody (sender or
|
|
// receiver) calls close() on.
|
|
type unclosedChannelItem struct {
|
|
Channel string
|
|
FilePath string
|
|
Line int
|
|
Sends int
|
|
Recvs int
|
|
Senders int
|
|
Risk string
|
|
Reason string
|
|
}
|
|
|
|
type annotatedItem struct {
|
|
Symbol string
|
|
File string
|
|
Line int
|
|
Args string
|
|
}
|
|
|
|
type annotationItem struct {
|
|
ID string
|
|
Name string
|
|
Users int
|
|
}
|
|
|
|
type configReaderItem struct {
|
|
ID string
|
|
Name string
|
|
Source string
|
|
Reads int
|
|
Readers string
|
|
}
|
|
|
|
type eventEmitterItem struct {
|
|
ID string
|
|
Name string
|
|
Kind string
|
|
Emits int
|
|
Emitters string
|
|
}
|
|
|
|
type pubsubItem struct {
|
|
ID string
|
|
Name string
|
|
Transport string
|
|
Publishes int
|
|
Subscribes int
|
|
Publishers string
|
|
Subscribers string
|
|
}
|
|
|
|
type errorSurfaceItem struct {
|
|
Symbol string
|
|
File string
|
|
Line int
|
|
Throws int
|
|
Errors string
|
|
ErrorMsgs string
|
|
}
|
|
|
|
type logEventItem struct {
|
|
ID string
|
|
Value string
|
|
Level string
|
|
Emits int
|
|
Emitters string
|
|
}
|
|
|
|
type sqlRebuildItem struct {
|
|
StringsVisited int
|
|
TablesCreated int
|
|
ColumnsCreated int
|
|
QueryEdges int
|
|
ReadColEdges int
|
|
WriteColEdges int
|
|
EmittersLinked int
|
|
Skipped int
|
|
}
|
|
|
|
type crossRepoItem struct {
|
|
FromRepo string
|
|
ToRepo string
|
|
Kind string
|
|
Count int
|
|
Samples string
|
|
}
|
|
|
|
// unsafePatternItem is one row of `analyze kind=unsafe_patterns`:
|
|
// a single match from one of the bundled unsafe-pattern detectors.
|
|
type unsafePatternItem struct {
|
|
Detector string
|
|
Severity string
|
|
Language string
|
|
File string
|
|
Line int
|
|
Symbol string
|
|
Text string
|
|
}
|
|
|
|
// healthScoreItem is one row of `analyze kind=health_score`: a
|
|
// per-symbol composite health value with the per-axis breakdown.
|
|
// "_pct" fields are the 0..100 axis values; NaN encodes "no data
|
|
// for this axis" (the GCX encoder serializes NaN as empty so a
|
|
// downstream reader can distinguish missing from zero).
|
|
type healthScoreItem struct {
|
|
ID string
|
|
Name string
|
|
Kind string
|
|
File string
|
|
Line int
|
|
Score float64
|
|
Grade string
|
|
CoveragePct float64
|
|
ComplexityPct float64
|
|
RecencyPct float64
|
|
ChurnPct float64
|
|
FanIn int
|
|
FanOut int
|
|
Crossings int
|
|
AgeDays int
|
|
Mods int
|
|
AxesUsed int
|
|
}
|
|
|
|
// healthRollupItem is one row of `analyze kind=health_score` when
|
|
// `roll_up` aggregates the per-symbol scores up to file or repo
|
|
// scope.
|
|
type healthRollupItem struct {
|
|
Scope string
|
|
Key string
|
|
AvgScore float64
|
|
MinScore float64
|
|
MaxScore float64
|
|
Symbols int
|
|
Grade string
|
|
CountA int
|
|
CountB int
|
|
CountC int
|
|
CountD int
|
|
CountF int
|
|
}
|
|
|
|
// --------------------------------------------------------------------
|
|
// contracts
|
|
// --------------------------------------------------------------------
|
|
|
|
// contractFields is the fixed column layout for one contract row.
|
|
// method + path are promoted out of Meta so HTTP/gRPC filters don't
|
|
// have to parse the compact meta column.
|
|
var contractFields = []string{
|
|
"type", "role", "repo", "method", "path",
|
|
"file", "line", "id", "symbol_id", "confidence", "meta",
|
|
}
|
|
|
|
// writeContractRow emits one contract using contractFields.
|
|
func writeContractRow(enc *wire.Encoder, c contracts.Contract) error {
|
|
method, _ := c.Meta["method"].(string)
|
|
path, _ := c.Meta["path"].(string)
|
|
return enc.WriteRow(
|
|
string(c.Type), string(c.Role), c.RepoPrefix, method, path,
|
|
c.FilePath, c.Line, c.ID, c.SymbolID, c.Confidence,
|
|
formatContractMeta(c.Meta, "method", "path"),
|
|
)
|
|
}
|
|
|
|
// formatContractMeta renders Meta as a stable semicolon-separated k=v
|
|
// list, dropping excluded keys already promoted to dedicated columns.
|
|
func formatContractMeta(m map[string]any, exclude ...string) string {
|
|
if len(m) == 0 {
|
|
return ""
|
|
}
|
|
skip := make(map[string]bool, len(exclude))
|
|
for _, k := range exclude {
|
|
skip[k] = true
|
|
}
|
|
keys := make([]string, 0, len(m))
|
|
for k := range m {
|
|
if skip[k] {
|
|
continue
|
|
}
|
|
keys = append(keys, k)
|
|
}
|
|
sort.Strings(keys)
|
|
var b strings.Builder
|
|
for i, k := range keys {
|
|
if i > 0 {
|
|
b.WriteByte(';')
|
|
}
|
|
fmt.Fprintf(&b, "%s=%v", k, m[k])
|
|
}
|
|
return b.String()
|
|
}
|
|
|
|
// encodeContractsList emits one row per contract. The by_repo grouping
|
|
// from the JSON payload is flattened — rows carry repo in-band so
|
|
// consumers can regroup without walking a tree.
|
|
func encodeContractsList(rows []contracts.Contract, total int, extraMeta ...string) ([]byte, error) {
|
|
var buf bytes.Buffer
|
|
meta := append([]string{"total", fmt.Sprintf("%d", total)}, extraMeta...)
|
|
enc := newGCX(&buf, "contracts.list", contractFields, meta...)
|
|
if err := enc.WriteComment(fmt.Sprintf("%d contract(s)", len(rows))); err != nil {
|
|
return nil, err
|
|
}
|
|
for _, c := range rows {
|
|
if err := writeContractRow(enc, c); err != nil {
|
|
return nil, err
|
|
}
|
|
}
|
|
return buf.Bytes(), enc.Close()
|
|
}
|
|
|
|
// encodeContractsCheck emits three sections: matched pairs, orphan
|
|
// providers, orphan consumers. Orphan sections reuse contractFields.
|
|
func encodeContractsCheck(result contracts.MatchResult) ([]byte, error) {
|
|
var buf bytes.Buffer
|
|
|
|
matchedFields := []string{
|
|
"contract_id", "cross_repo",
|
|
"provider_repo", "provider_file", "provider_line", "provider_symbol",
|
|
"consumer_repo", "consumer_file", "consumer_line", "consumer_symbol",
|
|
}
|
|
matchedEnc := newGCX(&buf, "contracts.check.matched", matchedFields,
|
|
"count", fmt.Sprintf("%d", len(result.Matched)),
|
|
)
|
|
for _, m := range result.Matched {
|
|
if err := matchedEnc.WriteRow(
|
|
m.ContractID, m.CrossRepo,
|
|
m.Provider.RepoPrefix, m.Provider.FilePath, m.Provider.Line, m.Provider.SymbolID,
|
|
m.Consumer.RepoPrefix, m.Consumer.FilePath, m.Consumer.Line, m.Consumer.SymbolID,
|
|
); err != nil {
|
|
return nil, err
|
|
}
|
|
}
|
|
if err := matchedEnc.Close(); err != nil {
|
|
return nil, err
|
|
}
|
|
|
|
provEnc := newGCX(&buf, "contracts.check.orphan_providers", contractFields,
|
|
"count", fmt.Sprintf("%d", len(result.OrphanProviders)),
|
|
)
|
|
for _, c := range result.OrphanProviders {
|
|
if err := writeContractRow(provEnc, c); err != nil {
|
|
return nil, err
|
|
}
|
|
}
|
|
if err := provEnc.Close(); err != nil {
|
|
return nil, err
|
|
}
|
|
|
|
consEnc := newGCX(&buf, "contracts.check.orphan_consumers", contractFields,
|
|
"count", fmt.Sprintf("%d", len(result.OrphanConsumers)),
|
|
)
|
|
for _, c := range result.OrphanConsumers {
|
|
if err := writeContractRow(consEnc, c); err != nil {
|
|
return nil, err
|
|
}
|
|
}
|
|
return buf.Bytes(), consEnc.Close()
|
|
}
|
|
|
|
// --------------------------------------------------------------------
|
|
// get_editing_context
|
|
// --------------------------------------------------------------------
|
|
|
|
// encodeEditingContext emits four sections: defines, imports,
|
|
// called_by, calls. File metadata (id, language, etag) lives in the
|
|
// defines-section header so there is no single-row wrapper section.
|
|
func encodeEditingContext(file map[string]any, defines, imports, calledBy, calls []map[string]any, etag, omissionKinds string) ([]byte, error) {
|
|
var buf bytes.Buffer
|
|
|
|
// file_id (the path) is not echoed in the header: paths on macOS /
|
|
// Windows can contain spaces, and the GCX header tokeniser splits on
|
|
// raw spaces. Callers can recover the file path from any defines
|
|
// row's ID prefix (`<path>::<symbol>`).
|
|
var language string
|
|
if v, ok := file["language"]; ok {
|
|
language = fmt.Sprint(v)
|
|
}
|
|
|
|
defMeta := []string{
|
|
"etag", etag,
|
|
"language", language,
|
|
"count", fmt.Sprintf("%d", len(defines)),
|
|
}
|
|
if omissionKinds != "" {
|
|
defMeta = append(defMeta, "omissions", omissionKinds)
|
|
}
|
|
defEnc := newGCX(&buf, "get_editing_context.defines",
|
|
[]string{"id", "kind", "name", "line", "sig"},
|
|
defMeta...,
|
|
)
|
|
for _, d := range defines {
|
|
if err := defEnc.WriteRow(
|
|
str(d["id"]),
|
|
str(d["kind"]),
|
|
str(d["name"]),
|
|
d["start_line"],
|
|
str(d["signature"]),
|
|
); err != nil {
|
|
return nil, err
|
|
}
|
|
}
|
|
if err := defEnc.Close(); err != nil {
|
|
return nil, err
|
|
}
|
|
|
|
impEnc := newGCX(&buf, "get_editing_context.imports",
|
|
[]string{"id", "external"},
|
|
"count", fmt.Sprintf("%d", len(imports)),
|
|
)
|
|
for _, im := range imports {
|
|
if err := impEnc.WriteRow(str(im["id"]), im["external"]); err != nil {
|
|
return nil, err
|
|
}
|
|
}
|
|
if err := impEnc.Close(); err != nil {
|
|
return nil, err
|
|
}
|
|
|
|
cbEnc := newGCX(&buf, "get_editing_context.called_by",
|
|
[]string{"id", "name", "path", "line"},
|
|
"count", fmt.Sprintf("%d", len(calledBy)),
|
|
)
|
|
for _, c := range calledBy {
|
|
if err := cbEnc.WriteRow(str(c["id"]), str(c["name"]), str(c["file_path"]), c["start_line"]); err != nil {
|
|
return nil, err
|
|
}
|
|
}
|
|
if err := cbEnc.Close(); err != nil {
|
|
return nil, err
|
|
}
|
|
|
|
callEnc := newGCX(&buf, "get_editing_context.calls",
|
|
[]string{"id", "name", "path", "line"},
|
|
"count", fmt.Sprintf("%d", len(calls)),
|
|
)
|
|
for _, c := range calls {
|
|
if err := callEnc.WriteRow(str(c["id"]), str(c["name"]), str(c["file_path"]), c["start_line"]); err != nil {
|
|
return nil, err
|
|
}
|
|
}
|
|
return buf.Bytes(), callEnc.Close()
|
|
}
|
|
|
|
// --------------------------------------------------------------------
|
|
// smart_context
|
|
// --------------------------------------------------------------------
|
|
|
|
// encodeSmartContext emits up to seven sections — symbols, cross_repo,
|
|
// entry_file, callers, callees, tests, files — in the order the
|
|
// orchestrator populates them. Only non-empty sections are written so
|
|
// small tasks stay small. Each section has its own field list to
|
|
// avoid wide rows padded with empty cells.
|
|
func encodeSmartContext(result map[string]any) ([]byte, error) {
|
|
var buf bytes.Buffer
|
|
|
|
// The task string is not echoed in the header: the GCX header tokeniser
|
|
// splits on spaces and escape() doesn't escape them, so a free-text
|
|
// task would corrupt parsing. The caller already knows what they
|
|
// asked for — no need to round-trip it.
|
|
|
|
symbols, _ := result["relevant_symbols"].([]map[string]any)
|
|
symEnc := newGCX(&buf, "smart_context.symbols",
|
|
[]string{"id", "kind", "name", "path", "line", "sig", "source"},
|
|
"count", fmt.Sprintf("%d", len(symbols)),
|
|
"etag", str(result["etag"]),
|
|
)
|
|
for _, s := range symbols {
|
|
if err := symEnc.WriteRow(
|
|
str(s["id"]),
|
|
str(s["kind"]),
|
|
str(s["name"]),
|
|
str(s["file_path"]),
|
|
s["start_line"],
|
|
str(s["signature"]),
|
|
str(s["source"]),
|
|
); err != nil {
|
|
return nil, err
|
|
}
|
|
}
|
|
if err := symEnc.Close(); err != nil {
|
|
return nil, err
|
|
}
|
|
|
|
if mani, ok := result["context_manifest"].(map[string]any); ok {
|
|
entries, _ := mani["entries"].([]map[string]any)
|
|
manEnc := newGCX(&buf, "smart_context.manifest",
|
|
[]string{"id", "kind", "name", "path", "line", "tier", "relation", "distance", "sibling_count", "compressed", "sig", "source"},
|
|
"token_budget", str(mani["token_budget"]),
|
|
"tokens_used", str(mani["tokens_used"]),
|
|
"omitted", str(mani["omitted"]),
|
|
)
|
|
for _, e := range entries {
|
|
if err := manEnc.WriteRow(
|
|
str(e["id"]),
|
|
str(e["kind"]),
|
|
str(e["name"]),
|
|
str(e["file_path"]),
|
|
e["start_line"],
|
|
str(e["tier"]),
|
|
str(e["relation"]),
|
|
e["distance"],
|
|
e["sibling_count"],
|
|
str(e["compressed"]),
|
|
str(e["signature"]),
|
|
str(e["source"]),
|
|
); err != nil {
|
|
return nil, err
|
|
}
|
|
}
|
|
if err := manEnc.Close(); err != nil {
|
|
return nil, err
|
|
}
|
|
}
|
|
|
|
if crossRepo, ok := result["cross_repo_dependencies"].([]map[string]any); ok && len(crossRepo) > 0 {
|
|
enc := newGCX(&buf, "smart_context.cross_repo",
|
|
[]string{"id", "kind", "name", "path", "repo", "edge_kind", "sig"},
|
|
"count", fmt.Sprintf("%d", len(crossRepo)),
|
|
)
|
|
for _, d := range crossRepo {
|
|
if err := enc.WriteRow(
|
|
str(d["id"]),
|
|
str(d["kind"]),
|
|
str(d["name"]),
|
|
str(d["file_path"]),
|
|
str(d["repo_prefix"]),
|
|
str(d["edge_kind"]),
|
|
str(d["signature"]),
|
|
); err != nil {
|
|
return nil, err
|
|
}
|
|
}
|
|
if err := enc.Close(); err != nil {
|
|
return nil, err
|
|
}
|
|
}
|
|
|
|
if entryFile, ok := result["entry_file_symbols"].([]string); ok && len(entryFile) > 0 {
|
|
enc := newGCX(&buf, "smart_context.entry_file",
|
|
[]string{"desc"},
|
|
"count", fmt.Sprintf("%d", len(entryFile)),
|
|
)
|
|
for _, d := range entryFile {
|
|
if err := enc.WriteRow(d); err != nil {
|
|
return nil, err
|
|
}
|
|
}
|
|
if err := enc.Close(); err != nil {
|
|
return nil, err
|
|
}
|
|
}
|
|
|
|
if callers, ok := result["callers"].([]string); ok && len(callers) > 0 {
|
|
enc := newGCX(&buf, "smart_context.callers",
|
|
[]string{"id"},
|
|
"count", fmt.Sprintf("%d", len(callers)),
|
|
)
|
|
for _, id := range callers {
|
|
if err := enc.WriteRow(id); err != nil {
|
|
return nil, err
|
|
}
|
|
}
|
|
if err := enc.Close(); err != nil {
|
|
return nil, err
|
|
}
|
|
}
|
|
|
|
if callees, ok := result["callees"].([]string); ok && len(callees) > 0 {
|
|
enc := newGCX(&buf, "smart_context.callees",
|
|
[]string{"id"},
|
|
"count", fmt.Sprintf("%d", len(callees)),
|
|
)
|
|
for _, id := range callees {
|
|
if err := enc.WriteRow(id); err != nil {
|
|
return nil, err
|
|
}
|
|
}
|
|
if err := enc.Close(); err != nil {
|
|
return nil, err
|
|
}
|
|
}
|
|
|
|
if tests, ok := result["related_test_files"].([]string); ok && len(tests) > 0 {
|
|
enc := newGCX(&buf, "smart_context.tests",
|
|
[]string{"path"},
|
|
"count", fmt.Sprintf("%d", len(tests)),
|
|
)
|
|
for _, p := range tests {
|
|
if err := enc.WriteRow(p); err != nil {
|
|
return nil, err
|
|
}
|
|
}
|
|
if err := enc.Close(); err != nil {
|
|
return nil, err
|
|
}
|
|
}
|
|
|
|
if files, ok := result["files_to_edit"].([]string); ok && len(files) > 0 {
|
|
enc := newGCX(&buf, "smart_context.files",
|
|
[]string{"path"},
|
|
"count", fmt.Sprintf("%d", len(files)),
|
|
)
|
|
for _, p := range files {
|
|
if err := enc.WriteRow(p); err != nil {
|
|
return nil, err
|
|
}
|
|
}
|
|
if err := enc.Close(); err != nil {
|
|
return nil, err
|
|
}
|
|
}
|
|
|
|
if ws, ok := result["working_set"].([]map[string]any); ok && len(ws) > 0 {
|
|
enc := newGCX(&buf, "smart_context.working_set",
|
|
[]string{"file", "is_test", "symbols"},
|
|
"count", fmt.Sprintf("%d", len(ws)),
|
|
)
|
|
for _, c := range ws {
|
|
ids, _ := c["symbols"].([]string)
|
|
if err := enc.WriteRow(str(c["file"]), c["is_test"], strings.Join(ids, ",")); err != nil {
|
|
return nil, err
|
|
}
|
|
}
|
|
if err := enc.Close(); err != nil {
|
|
return nil, err
|
|
}
|
|
}
|
|
|
|
if br, ok := result["blast_radius"].(map[string]any); ok {
|
|
warning := str(br["warning"])
|
|
callerGroups, _ := br["callers_by_file"].([]map[string]any)
|
|
callerEnc := newGCX(&buf, "smart_context.blast_callers",
|
|
[]string{"file", "callers"},
|
|
"count", fmt.Sprintf("%d", len(callerGroups)),
|
|
"warning", warning,
|
|
)
|
|
for _, g := range callerGroups {
|
|
ids, _ := g["callers"].([]string)
|
|
if err := callerEnc.WriteRow(str(g["file"]), strings.Join(ids, ",")); err != nil {
|
|
return nil, err
|
|
}
|
|
}
|
|
if err := callerEnc.Close(); err != nil {
|
|
return nil, err
|
|
}
|
|
|
|
tests, _ := br["covering_tests"].([]map[string]any)
|
|
testEnc := newGCX(&buf, "smart_context.blast_tests",
|
|
[]string{"file", "function"},
|
|
"count", fmt.Sprintf("%d", len(tests)),
|
|
)
|
|
for _, tr := range tests {
|
|
if err := testEnc.WriteRow(str(tr["file"]), str(tr["function"])); err != nil {
|
|
return nil, err
|
|
}
|
|
}
|
|
if err := testEnc.Close(); err != nil {
|
|
return nil, err
|
|
}
|
|
}
|
|
|
|
if inPack, ok := result["in_pack"].(map[string]any); ok {
|
|
if cps, ok := inPack["call_paths"].([]map[string]any); ok && len(cps) > 0 {
|
|
cpEnc := newGCX(&buf, "smart_context.call_paths",
|
|
[]string{"root", "anchor", "length", "confidence", "nodes"},
|
|
"count", fmt.Sprintf("%d", len(cps)),
|
|
)
|
|
for _, cp := range cps {
|
|
nodes := ""
|
|
if ns, ok := cp["nodes"].([]string); ok {
|
|
nodes = strings.Join(ns, ">")
|
|
}
|
|
if err := cpEnc.WriteRow(
|
|
str(cp["root"]),
|
|
str(cp["anchor"]),
|
|
cp["length"],
|
|
cp["confidence"],
|
|
nodes,
|
|
); err != nil {
|
|
return nil, err
|
|
}
|
|
}
|
|
if err := cpEnc.Close(); err != nil {
|
|
return nil, err
|
|
}
|
|
}
|
|
if flows, ok := inPack["flows"].(map[string]any); ok {
|
|
if spine, ok := flows["spine"].([]string); ok && len(spine) > 0 {
|
|
spEnc := newGCX(&buf, "smart_context.flow_spine",
|
|
[]string{"nodes"},
|
|
"length", fmt.Sprintf("%d", len(spine)),
|
|
)
|
|
if err := spEnc.WriteRow(strings.Join(spine, ">")); err != nil {
|
|
return nil, err
|
|
}
|
|
if err := spEnc.Close(); err != nil {
|
|
return nil, err
|
|
}
|
|
}
|
|
if bs, ok := flows["boundaries"].([]map[string]any); ok && len(bs) > 0 {
|
|
bEnc := newGCX(&buf, "smart_context.flow_boundaries",
|
|
[]string{"from", "target", "reason"},
|
|
"count", fmt.Sprintf("%d", len(bs)),
|
|
)
|
|
for _, b := range bs {
|
|
if err := bEnc.WriteRow(str(b["from"]), str(b["target"]), str(b["reason"])); err != nil {
|
|
return nil, err
|
|
}
|
|
}
|
|
if err := bEnc.Close(); err != nil {
|
|
return nil, err
|
|
}
|
|
}
|
|
}
|
|
if conf, ok := inPack["confidence"].(map[string]any); ok && len(conf) > 0 {
|
|
cEnc := newGCX(&buf, "smart_context.confidence",
|
|
[]string{"verdict", "top1", "top2", "ratio_top1_top2", "k", "std_dev"},
|
|
)
|
|
if err := cEnc.WriteRow(
|
|
str(conf["verdict"]),
|
|
conf["top1"],
|
|
conf["top2"],
|
|
conf["ratio_top1_top2"],
|
|
conf["k"],
|
|
conf["std_dev"],
|
|
); err != nil {
|
|
return nil, err
|
|
}
|
|
if err := cEnc.Close(); err != nil {
|
|
return nil, err
|
|
}
|
|
}
|
|
}
|
|
|
|
if rec, ok := result["recovered_edges"].([]map[string]any); ok && len(rec) > 0 {
|
|
rEnc := newGCX(&buf, "smart_context.recovered_edges",
|
|
[]string{"from", "to", "kind"},
|
|
"count", fmt.Sprintf("%d", len(rec)),
|
|
)
|
|
for _, e := range rec {
|
|
if err := rEnc.WriteRow(str(e["from"]), str(e["to"]), str(e["kind"])); err != nil {
|
|
return nil, err
|
|
}
|
|
}
|
|
if err := rEnc.Close(); err != nil {
|
|
return nil, err
|
|
}
|
|
}
|
|
|
|
if sibs, ok := result["hierarchy_siblings"].([]map[string]any); ok && len(sibs) > 0 {
|
|
sEnc := newGCX(&buf, "smart_context.hierarchy_siblings",
|
|
[]string{"id", "name", "kind", "parent"},
|
|
"count", fmt.Sprintf("%d", len(sibs)),
|
|
)
|
|
for _, sb := range sibs {
|
|
if err := sEnc.WriteRow(str(sb["id"]), str(sb["name"]), str(sb["kind"]), str(sb["parent"])); err != nil {
|
|
return nil, err
|
|
}
|
|
}
|
|
if err := sEnc.Close(); err != nil {
|
|
return nil, err
|
|
}
|
|
}
|
|
|
|
return buf.Bytes(), nil
|
|
}
|
|
|
|
// encodeSmartContextEstimate encodes the dry-run token-cost projection
|
|
// of smart_context as a single-row GCX section.
|
|
func encodeSmartContextEstimate(result map[string]any) ([]byte, error) {
|
|
var buf bytes.Buffer
|
|
est, _ := result["estimate"].(map[string]any)
|
|
enc := newGCX(&buf, "smart_context.estimate",
|
|
[]string{"fidelity", "symbol_count", "projected_tokens", "token_budget", "focus", "ring", "outline", "omitted"},
|
|
"count", "1",
|
|
)
|
|
if err := enc.WriteRow(
|
|
str(est["fidelity"]),
|
|
str(est["symbol_count"]),
|
|
str(est["projected_tokens"]),
|
|
str(est["token_budget"]),
|
|
str(est["focus"]),
|
|
str(est["ring"]),
|
|
str(est["outline"]),
|
|
str(est["omitted"]),
|
|
); err != nil {
|
|
return nil, err
|
|
}
|
|
if err := enc.Close(); err != nil {
|
|
return nil, err
|
|
}
|
|
return buf.Bytes(), nil
|
|
}
|
|
|
|
// --------------------------------------------------------------------
|
|
// prefetch_context, explain_change_impact, check_guards, feedback.query
|
|
// --------------------------------------------------------------------
|
|
|
|
// encodePrefetchContext emits one row per ranked candidate with the
|
|
// per-axis score contributions inlined (search/proximity/community)
|
|
// so consumers can recover the attribution without a nested object.
|
|
// Source — when include_source was set on the request — rides as the
|
|
// last field, escaped via the wire encoder so newlines stay one row.
|
|
func encodePrefetchContext(candidates []prefetchCandidate, total int, truncated bool, includeSource bool) ([]byte, error) {
|
|
fields := []string{"id", "kind", "name", "path", "line", "confidence", "search", "proximity", "community", "reason"}
|
|
if includeSource {
|
|
fields = append(fields, "source")
|
|
}
|
|
var buf bytes.Buffer
|
|
enc := newGCX(&buf, "prefetch_context",
|
|
fields,
|
|
"total", fmt.Sprintf("%d", total),
|
|
"truncated", boolString(truncated),
|
|
)
|
|
if err := enc.WriteComment(fmt.Sprintf("%d candidate(s)", len(candidates))); err != nil {
|
|
return nil, err
|
|
}
|
|
for _, c := range candidates {
|
|
row := []any{
|
|
c.ID,
|
|
c.Kind,
|
|
nodeShort(c.Node),
|
|
c.FilePath,
|
|
c.StartLine,
|
|
roundFloat(c.Confidence),
|
|
roundFloat(c.SearchRelevance),
|
|
roundFloat(c.GraphProximity),
|
|
roundFloat(c.CommunityBonus),
|
|
c.Reason,
|
|
}
|
|
if includeSource {
|
|
row = append(row, c.Source)
|
|
}
|
|
if err := enc.WriteRow(row...); err != nil {
|
|
return nil, err
|
|
}
|
|
}
|
|
return buf.Bytes(), enc.Close()
|
|
}
|
|
|
|
// encodeChangeImpact emits a multi-section envelope for the
|
|
// explain_change_impact tool. Section layout:
|
|
//
|
|
// - explain_change_impact.summary (one row): risk, total, communities/processes
|
|
// joined with `,` so the section stays one row regardless of fan-out.
|
|
// - explain_change_impact.entries (one row per affected symbol): the
|
|
// by_depth + by_repo maps are flattened into a single table — `repo`
|
|
// is empty unless the change crosses repos.
|
|
// - explain_change_impact.contracts (one row when contract_impact is set):
|
|
// breaking/warning/info counters + the validate-pass risk upgrade flag.
|
|
func encodeChangeImpact(result map[string]any) ([]byte, error) {
|
|
var buf bytes.Buffer
|
|
|
|
risk, _ := result["risk"].(analysis.RiskLevel)
|
|
summaryStr, _ := result["summary"].(string)
|
|
totalAffected, _ := result["total_affected"].(int)
|
|
crossRepo, _ := result["cross_repo_impact"].(bool)
|
|
processes, _ := result["affected_processes"].([]string)
|
|
communities, _ := result["affected_communities"].([]string)
|
|
testFiles, _ := result["test_files"].([]string)
|
|
warning, _ := result["cross_community_warning"].(string)
|
|
communityNote, _ := result["community_note"].(string)
|
|
contractRiskUpgrade, _ := result["contract_risk_upgrade"].(string)
|
|
|
|
sumEnc := newGCX(&buf, "explain_change_impact.summary",
|
|
[]string{"risk", "summary", "total_affected", "cross_repo", "processes", "communities", "test_files", "warning", "note", "risk_upgrade"},
|
|
)
|
|
if err := sumEnc.WriteRow(
|
|
string(risk),
|
|
summaryStr,
|
|
totalAffected,
|
|
boolString(crossRepo),
|
|
strings.Join(processes, ","),
|
|
strings.Join(communities, ","),
|
|
strings.Join(testFiles, ","),
|
|
warning,
|
|
communityNote,
|
|
contractRiskUpgrade,
|
|
); err != nil {
|
|
return nil, err
|
|
}
|
|
if err := sumEnc.Close(); err != nil {
|
|
return nil, err
|
|
}
|
|
|
|
// Flatten by_depth (and by_repo when cross-repo) into a single
|
|
// rows section. We carry depth + repo so consumers can regroup.
|
|
entryFields := []string{"depth", "repo", "id", "name", "kind", "path", "line", "edge_confidence", "confidence_label"}
|
|
entryEnc := newGCX(&buf, "explain_change_impact.entries", entryFields)
|
|
totalRows := 0
|
|
if byDepth, ok := result["by_depth"].(map[int][]analysis.ImpactEntry); ok {
|
|
// Sort depths so output is deterministic.
|
|
depths := make([]int, 0, len(byDepth))
|
|
for d := range byDepth {
|
|
depths = append(depths, d)
|
|
}
|
|
sort.Ints(depths)
|
|
for _, d := range depths {
|
|
for _, e := range byDepth[d] {
|
|
if err := entryEnc.WriteRow(
|
|
d,
|
|
e.RepoPrefix,
|
|
e.ID,
|
|
e.Name,
|
|
e.Kind,
|
|
e.FilePath,
|
|
e.Line,
|
|
roundFloat(e.EdgeConfidence),
|
|
e.ConfidenceLabel,
|
|
); err != nil {
|
|
return nil, err
|
|
}
|
|
totalRows++
|
|
}
|
|
}
|
|
}
|
|
if err := entryEnc.Close(); err != nil {
|
|
return nil, err
|
|
}
|
|
|
|
if ci, ok := result["contract_impact"].(*contractImpact); ok && ci != nil {
|
|
ciEnc := newGCX(&buf, "explain_change_impact.contracts",
|
|
[]string{"breaking", "warning", "info", "affected"},
|
|
)
|
|
if err := ciEnc.WriteRow(ci.Breaking, ci.Warning, ci.Info, len(ci.Affected)); err != nil {
|
|
return nil, err
|
|
}
|
|
if err := ciEnc.Close(); err != nil {
|
|
return nil, err
|
|
}
|
|
}
|
|
|
|
// zero_impact_caveat — one row per input symbol whose empty blast
|
|
// radius could not be told apart from an extraction gap.
|
|
if caveats, ok := result["zero_impact_caveat"].([]graph.ZeroImpactCaveat); ok && len(caveats) > 0 {
|
|
cavEnc := newGCX(&buf, "explain_change_impact.caveats",
|
|
[]string{"id", "class", "message"},
|
|
)
|
|
for _, c := range caveats {
|
|
if err := cavEnc.WriteRow(c.ID, string(c.Class), c.Message); err != nil {
|
|
return nil, err
|
|
}
|
|
}
|
|
if err := cavEnc.Close(); err != nil {
|
|
return nil, err
|
|
}
|
|
}
|
|
|
|
// boundaries — the epistemic lower-bound diagnosis. Emitted only when the
|
|
// blast radius crossed a dynamic-dispatch / interface site, so results
|
|
// with none keep their existing wire bytes. The section's presence (and
|
|
// its lower_bound meta flag) signals the count is a floor.
|
|
if boundaries, ok := result["boundaries"].([]graph.EpistemicBoundary); ok && len(boundaries) > 0 {
|
|
lb, _ := result["lower_bound"].(bool)
|
|
bEnc := newGCX(&buf, "explain_change_impact.boundaries",
|
|
[]string{"seed_id", "seed_name", "target", "edge_kind", "reason", "direction"},
|
|
"count", fmt.Sprintf("%d", len(boundaries)),
|
|
"lower_bound", boolString(lb),
|
|
)
|
|
for _, b := range boundaries {
|
|
if err := bEnc.WriteRow(b.SeedID, b.SeedName, b.Target, b.EdgeKind, string(b.Reason), b.Direction); err != nil {
|
|
return nil, err
|
|
}
|
|
}
|
|
if err := bEnc.Close(); err != nil {
|
|
return nil, err
|
|
}
|
|
}
|
|
return buf.Bytes(), nil
|
|
}
|
|
|
|
// encodeAPIImpact emits the fused route report as three GCX sections:
|
|
// api_impact.routes (one row per route), api_impact.consumers and
|
|
// api_impact.mismatches (one row each, carrying the route id so they regroup).
|
|
func encodeAPIImpact(reports []apiImpactReport) ([]byte, error) {
|
|
var buf bytes.Buffer
|
|
routeEnc := newGCX(&buf, "api_impact.routes",
|
|
[]string{"route", "method", "path", "handler", "repo", "response_success", "response_error", "middleware", "risk", "direct_consumers", "affected_callers", "affected_flows", "test_files", "warning", "contract_risk_upgrade"},
|
|
"count", fmt.Sprintf("%d", len(reports)),
|
|
)
|
|
for _, r := range reports {
|
|
mw := strings.Join(r.Middleware, ",")
|
|
if mw == "" {
|
|
mw = r.MiddlewareDetection
|
|
}
|
|
if err := routeEnc.WriteRow(
|
|
r.Route, r.Method, r.Path, r.Handler, r.Repo,
|
|
strings.Join(r.ResponseShape.Success, ","),
|
|
strings.Join(r.ResponseShape.Error, ","),
|
|
mw,
|
|
r.ImpactSummary.RiskLevel,
|
|
r.ImpactSummary.DirectConsumers,
|
|
r.ImpactSummary.AffectedCallers,
|
|
r.ImpactSummary.AffectedFlows,
|
|
strings.Join(r.ImpactSummary.TestFilesToRun, ","),
|
|
r.ImpactSummary.Warning,
|
|
r.ImpactSummary.ContractRiskUpgrade,
|
|
); err != nil {
|
|
return nil, err
|
|
}
|
|
}
|
|
if err := routeEnc.Close(); err != nil {
|
|
return nil, err
|
|
}
|
|
|
|
consEnc := newGCX(&buf, "api_impact.consumers",
|
|
[]string{"route", "name", "file", "repo", "accesses", "attribution_note"})
|
|
for _, r := range reports {
|
|
for _, c := range r.Consumers {
|
|
if err := consEnc.WriteRow(r.Route, c.Name, c.File, c.Repo, strings.Join(c.Accesses, ","), c.AttributionNote); err != nil {
|
|
return nil, err
|
|
}
|
|
}
|
|
}
|
|
if err := consEnc.Close(); err != nil {
|
|
return nil, err
|
|
}
|
|
|
|
mmEnc := newGCX(&buf, "api_impact.mismatches",
|
|
[]string{"route", "consumer", "field", "reason", "confidence"})
|
|
for _, r := range reports {
|
|
for _, m := range r.Mismatches {
|
|
if err := mmEnc.WriteRow(r.Route, m.Consumer, m.Field, m.Reason, m.Confidence); err != nil {
|
|
return nil, err
|
|
}
|
|
}
|
|
}
|
|
return buf.Bytes(), mmEnc.Close()
|
|
}
|
|
|
|
// encodeCheckGuards emits one row per guard violation. The empty case
|
|
// (no rules / no violations) still produces a valid envelope so agents
|
|
// can rely on the GCX1 header to differentiate from an error payload —
|
|
// the meta `status=no_rules_configured` flag carries the "no rules"
|
|
// hint as a single token (the decoder splits the header on raw spaces,
|
|
// so multi-word meta values must stay tokenised).
|
|
func encodeCheckGuards(violations []analysis.GuardViolation, noRulesConfigured bool) ([]byte, error) {
|
|
var buf bytes.Buffer
|
|
meta := []string{"total", fmt.Sprintf("%d", len(violations))}
|
|
if noRulesConfigured {
|
|
meta = append(meta, "status", "no_rules_configured")
|
|
}
|
|
enc := newGCX(&buf, "check_guards",
|
|
[]string{"rule_name", "kind", "description"},
|
|
meta...,
|
|
)
|
|
for _, v := range violations {
|
|
if err := enc.WriteRow(v.RuleName, v.Kind, v.Description); err != nil {
|
|
return nil, err
|
|
}
|
|
}
|
|
return buf.Bytes(), enc.Close()
|
|
}
|
|
|
|
// encodeFeedbackQuery emits a multi-section envelope:
|
|
//
|
|
// - feedback.summary (one row): total_entries, accuracy
|
|
// - feedback.most_useful, feedback.most_missed, feedback.most_demoted
|
|
// (one row per ranked symbol): id, score, count
|
|
//
|
|
// Empty lists still emit their section so consumers can iterate without
|
|
// branching on existence — the meta count is the source of truth.
|
|
func encodeFeedbackQuery(stats map[string]any) ([]byte, error) {
|
|
var buf bytes.Buffer
|
|
|
|
totalEntries := 0
|
|
if v, ok := stats["total_entries"].(int); ok {
|
|
totalEntries = v
|
|
}
|
|
accuracy := 0.0
|
|
if v, ok := stats["accuracy"].(float64); ok {
|
|
accuracy = v
|
|
}
|
|
sumEnc := newGCX(&buf, "feedback.summary",
|
|
[]string{"total_entries", "accuracy"},
|
|
)
|
|
if err := sumEnc.WriteRow(totalEntries, roundFloat(accuracy)); err != nil {
|
|
return nil, err
|
|
}
|
|
if err := sumEnc.Close(); err != nil {
|
|
return nil, err
|
|
}
|
|
|
|
emit := func(section string, key string) error {
|
|
enc := newGCX(&buf, section,
|
|
[]string{"id", "score", "count"},
|
|
)
|
|
// AggregatedStats produces an unexported `ranked` slice we
|
|
// can only see through reflection. Coerce via JSON shape:
|
|
// the published schema is {id, score, count}, so we read
|
|
// it through a generic any-decoder.
|
|
rows, _ := stats[key].([]any)
|
|
if rows == nil {
|
|
// Strongly-typed path — re-marshal whatever AggregatedStats
|
|
// returned and decode into a slice of {id, score, count}.
|
|
if raw, err := json.Marshal(stats[key]); err == nil {
|
|
var coerced []struct {
|
|
ID string `json:"id"`
|
|
Score float64 `json:"score"`
|
|
Count int `json:"count"`
|
|
}
|
|
_ = json.Unmarshal(raw, &coerced)
|
|
for _, r := range coerced {
|
|
if err := enc.WriteRow(r.ID, roundFloat(r.Score), r.Count); err != nil {
|
|
return err
|
|
}
|
|
}
|
|
return enc.Close()
|
|
}
|
|
}
|
|
for _, row := range rows {
|
|
m, _ := row.(map[string]any)
|
|
id, _ := m["id"].(string)
|
|
score, _ := m["score"].(float64)
|
|
count := 0
|
|
if v, ok := m["count"].(int); ok {
|
|
count = v
|
|
} else if v, ok := m["count"].(float64); ok {
|
|
count = int(v)
|
|
}
|
|
if err := enc.WriteRow(id, roundFloat(score), count); err != nil {
|
|
return err
|
|
}
|
|
}
|
|
return enc.Close()
|
|
}
|
|
if err := emit("feedback.most_useful", "most_useful"); err != nil {
|
|
return nil, err
|
|
}
|
|
if err := emit("feedback.most_missed", "most_missed"); err != nil {
|
|
return nil, err
|
|
}
|
|
if err := emit("feedback.most_demoted", "most_demoted"); err != nil {
|
|
return nil, err
|
|
}
|
|
return buf.Bytes(), nil
|
|
}
|
|
|
|
// --------------------------------------------------------------------
|
|
// small utilities
|
|
// --------------------------------------------------------------------
|
|
|
|
func boolString(b bool) string {
|
|
if b {
|
|
return "true"
|
|
}
|
|
return "false"
|
|
}
|
|
|
|
func str(v any) string {
|
|
if v == nil {
|
|
return ""
|
|
}
|
|
if s, ok := v.(string); ok {
|
|
return s
|
|
}
|
|
return fmt.Sprint(v)
|
|
}
|
|
|
|
func indexNodes(nodes []*graph.Node) map[string]*graph.Node {
|
|
m := make(map[string]*graph.Node, len(nodes))
|
|
for _, n := range nodes {
|
|
m[n.ID] = n
|
|
}
|
|
return m
|
|
}
|
|
|
|
// encodePRRisk emits the pr_risk report as two GCX sections:
|
|
// - pr_risk.summary (one row): the composite score, risk label, supporting
|
|
// counts (total_affected / uncovered / community_span / changed_symbols)
|
|
// and the joined security hits.
|
|
// - pr_risk.priorities (one row per axis): the ordered review_priorities —
|
|
// axis, 0-100 score, and the human-readable reason.
|
|
//
|
|
// The map shape is whatever prRiskPayload built, so JSON and GCX stay a single
|
|
// source of truth for field names.
|
|
func encodePRRisk(result map[string]any) ([]byte, error) {
|
|
var buf bytes.Buffer
|
|
|
|
score, _ := result["score"].(float64)
|
|
risk, _ := result["risk"].(string)
|
|
totalAffected, _ := result["total_affected"].(int)
|
|
uncovered, _ := result["uncovered_symbols"].(int)
|
|
communitySpan, _ := result["community_span"].(int)
|
|
changedSymbols, _ := result["changed_symbols"].(int)
|
|
hits, _ := result["security_hits"].([]string)
|
|
|
|
sumEnc := newGCX(&buf, "pr_risk.summary",
|
|
[]string{"score", "risk", "total_affected", "uncovered_symbols", "community_span", "changed_symbols", "security_hits"},
|
|
)
|
|
if err := sumEnc.WriteRow(
|
|
roundFloat(score),
|
|
risk,
|
|
totalAffected,
|
|
uncovered,
|
|
communitySpan,
|
|
changedSymbols,
|
|
strings.Join(hits, ","),
|
|
); err != nil {
|
|
return nil, err
|
|
}
|
|
if err := sumEnc.Close(); err != nil {
|
|
return nil, err
|
|
}
|
|
|
|
prEnc := newGCX(&buf, "pr_risk.priorities", []string{"axis", "score", "reason"})
|
|
if priorities, ok := result["review_priorities"].([]map[string]any); ok {
|
|
for _, p := range priorities {
|
|
axis, _ := p["axis"].(string)
|
|
pscore, _ := p["score"].(float64)
|
|
reason, _ := p["reason"].(string)
|
|
if err := prEnc.WriteRow(axis, roundFloat(pscore), reason); err != nil {
|
|
return nil, err
|
|
}
|
|
}
|
|
}
|
|
if err := prEnc.Close(); err != nil {
|
|
return nil, err
|
|
}
|
|
|
|
return buf.Bytes(), nil
|
|
}
|
|
|
|
// encodeSuggestReviewers encodes the suggest_reviewers payload as GCX1: a
|
|
// one-row summary section plus a per-reviewer row section. The map shape is
|
|
// whatever suggestReviewersPayload built, so JSON and GCX stay a single source
|
|
// of truth for field names.
|
|
func encodeSuggestReviewers(result map[string]any) ([]byte, error) {
|
|
var buf bytes.Buffer
|
|
|
|
total, _ := result["total"].(int)
|
|
changedFiles, _ := result["changed_files"].(int)
|
|
codeownersFound, _ := result["codeowners_found"].(bool)
|
|
|
|
sumEnc := newGCX(&buf, "suggest_reviewers.summary",
|
|
[]string{"total", "changed_files", "codeowners_found"},
|
|
)
|
|
if err := sumEnc.WriteRow(total, changedFiles, codeownersFound); err != nil {
|
|
return nil, err
|
|
}
|
|
if err := sumEnc.Close(); err != nil {
|
|
return nil, err
|
|
}
|
|
|
|
revEnc := newGCX(&buf, "suggest_reviewers.reviewers",
|
|
[]string{"reviewer", "kind", "score", "reasons", "matched_files"},
|
|
)
|
|
if reviewers, ok := result["reviewers"].([]map[string]any); ok {
|
|
for _, r := range reviewers {
|
|
reviewer, _ := r["reviewer"].(string)
|
|
kind, _ := r["kind"].(string)
|
|
score, _ := r["score"].(int)
|
|
reasons, _ := r["reasons"].([]string)
|
|
matched, _ := r["matched_files"].([]string)
|
|
if err := revEnc.WriteRow(
|
|
reviewer,
|
|
kind,
|
|
score,
|
|
strings.Join(reasons, "; "),
|
|
strings.Join(matched, ","),
|
|
); err != nil {
|
|
return nil, err
|
|
}
|
|
}
|
|
}
|
|
if err := revEnc.Close(); err != nil {
|
|
return nil, err
|
|
}
|
|
|
|
return buf.Bytes(), nil
|
|
}
|
|
|
|
// encodeReviewQuestions encodes the suggested_review_questions payload as
|
|
// GCX1: a one-row summary section (total + truncated + per-category
|
|
// counts) followed by a per-question row section. Severity / score ride
|
|
// as row values so the prioritised order is reconstructable from the
|
|
// wire form, and signals are joined with ";" since the row is
|
|
// tab-delimited.
|
|
func encodeReviewQuestions(questions []reviewQuestion, byCategory map[string]int, truncated bool) ([]byte, error) {
|
|
var buf bytes.Buffer
|
|
|
|
sumEnc := newGCX(&buf, "suggested_review_questions.summary",
|
|
[]string{"total", "truncated", "bridge", "hub_risk", "surprising", "thin_community", "untested_hotspot"},
|
|
)
|
|
if err := sumEnc.WriteRow(
|
|
len(questions),
|
|
truncated,
|
|
byCategory[rqCatBridge],
|
|
byCategory[rqCatHubRisk],
|
|
byCategory[rqCatSurprising],
|
|
byCategory[rqCatThinCommunity],
|
|
byCategory[rqCatUntestedHotspot],
|
|
); err != nil {
|
|
return nil, err
|
|
}
|
|
if err := sumEnc.Close(); err != nil {
|
|
return nil, err
|
|
}
|
|
|
|
qEnc := newGCX(&buf, "suggested_review_questions.questions",
|
|
[]string{"id", "category", "severity", "score", "symbol_id", "symbol_name", "file", "line", "question", "evidence", "signals"},
|
|
)
|
|
for _, q := range questions {
|
|
if err := qEnc.WriteRow(
|
|
q.ID,
|
|
q.Category,
|
|
q.Severity,
|
|
roundFloat(q.Score),
|
|
q.SymbolID,
|
|
q.SymbolName,
|
|
q.File,
|
|
q.Line,
|
|
q.Question,
|
|
q.Evidence,
|
|
strings.Join(q.Signals, ";"),
|
|
); err != nil {
|
|
return nil, err
|
|
}
|
|
}
|
|
if err := qEnc.Close(); err != nil {
|
|
return nil, err
|
|
}
|
|
|
|
return buf.Bytes(), nil
|
|
}
|
|
|
|
// encodeListPRs encodes the list_prs payload as GCX1: a one-row summary
|
|
// (total) plus a per-PR row section. The map shape mirrors listPRsPayload
|
|
// so JSON and GCX share one set of field names. A degradation payload
|
|
// (error/hint) is never routed here — those go through the JSON/TOON path.
|
|
func encodeListPRs(result map[string]any) ([]byte, error) {
|
|
var buf bytes.Buffer
|
|
|
|
total, _ := result["total"].(int)
|
|
sumEnc := newGCX(&buf, "list_prs.summary", []string{"total"})
|
|
if err := sumEnc.WriteRow(total); err != nil {
|
|
return nil, err
|
|
}
|
|
if err := sumEnc.Close(); err != nil {
|
|
return nil, err
|
|
}
|
|
|
|
prEnc := newGCX(&buf, "list_prs.prs",
|
|
[]string{"number", "title", "author", "age_days", "ci", "review", "state", "blockers"},
|
|
)
|
|
if prs, ok := result["prs"].([]map[string]any); ok {
|
|
for _, p := range prs {
|
|
number, _ := p["number"].(int)
|
|
title, _ := p["title"].(string)
|
|
author, _ := p["author"].(string)
|
|
ageDays, _ := p["age_days"].(int)
|
|
ci, _ := p["ci"].(string)
|
|
review, _ := p["review"].(string)
|
|
state, _ := p["state"].(string)
|
|
blockers, _ := p["blockers"].([]string)
|
|
if err := prEnc.WriteRow(
|
|
number, title, author, ageDays, ci, review, state,
|
|
strings.Join(blockers, ","),
|
|
); err != nil {
|
|
return nil, err
|
|
}
|
|
}
|
|
}
|
|
if err := prEnc.Close(); err != nil {
|
|
return nil, err
|
|
}
|
|
|
|
return buf.Bytes(), nil
|
|
}
|
|
|
|
// encodePRImpact encodes the get_pr_impact payload as GCX1: a one-row
|
|
// summary, a review-priorities section, and a changed-symbols section.
|
|
// The bulky blast map is omitted from the compact form — callers that
|
|
// need the full blast grouping request json. The map shape mirrors
|
|
// prImpactForNumber.
|
|
func encodePRImpact(result map[string]any) ([]byte, error) {
|
|
var buf bytes.Buffer
|
|
|
|
number, _ := result["number"].(int)
|
|
risk, _ := result["risk"].(string)
|
|
score, _ := result["score"].(float64)
|
|
changedFiles, _ := result["changed_files"].([]string)
|
|
communities, _ := result["communities"].([]string)
|
|
|
|
sumEnc := newGCX(&buf, "get_pr_impact.summary",
|
|
[]string{"number", "risk", "score", "changed_files", "communities"},
|
|
)
|
|
if err := sumEnc.WriteRow(
|
|
number, risk, roundFloat(score), len(changedFiles), len(communities),
|
|
); err != nil {
|
|
return nil, err
|
|
}
|
|
if err := sumEnc.Close(); err != nil {
|
|
return nil, err
|
|
}
|
|
|
|
prEnc := newGCX(&buf, "get_pr_impact.priorities", []string{"axis", "score", "reason"})
|
|
if priorities, ok := result["review_priorities"].([]map[string]any); ok {
|
|
for _, p := range priorities {
|
|
axis, _ := p["axis"].(string)
|
|
pscore, _ := p["score"].(float64)
|
|
reason, _ := p["reason"].(string)
|
|
if err := prEnc.WriteRow(axis, roundFloat(pscore), reason); err != nil {
|
|
return nil, err
|
|
}
|
|
}
|
|
}
|
|
if err := prEnc.Close(); err != nil {
|
|
return nil, err
|
|
}
|
|
|
|
symEnc := newGCX(&buf, "get_pr_impact.changed_symbols", []string{"id", "name", "kind", "file"})
|
|
if syms, ok := result["changed_symbols"].([]map[string]any); ok {
|
|
for _, sym := range syms {
|
|
id, _ := sym["id"].(string)
|
|
name, _ := sym["name"].(string)
|
|
kind, _ := sym["kind"].(string)
|
|
file, _ := sym["file"].(string)
|
|
if err := symEnc.WriteRow(id, name, kind, file); err != nil {
|
|
return nil, err
|
|
}
|
|
}
|
|
}
|
|
if err := symEnc.Close(); err != nil {
|
|
return nil, err
|
|
}
|
|
|
|
return buf.Bytes(), nil
|
|
}
|
|
|
|
// encodeTriagePRs encodes the triage_prs payload as GCX1: a one-row
|
|
// summary plus the score-ranked per-PR rows. The map shape mirrors the
|
|
// triage_prs payload.
|
|
func encodeTriagePRs(result map[string]any) ([]byte, error) {
|
|
var buf bytes.Buffer
|
|
|
|
total, _ := result["total"].(int)
|
|
sumEnc := newGCX(&buf, "triage_prs.summary", []string{"total"})
|
|
if err := sumEnc.WriteRow(total); err != nil {
|
|
return nil, err
|
|
}
|
|
if err := sumEnc.Close(); err != nil {
|
|
return nil, err
|
|
}
|
|
|
|
rankEnc := newGCX(&buf, "triage_prs.ranked",
|
|
[]string{"number", "title", "author", "risk", "score"},
|
|
)
|
|
if ranked, ok := result["ranked"].([]map[string]any); ok {
|
|
for _, r := range ranked {
|
|
number, _ := r["number"].(int)
|
|
title, _ := r["title"].(string)
|
|
author, _ := r["author"].(string)
|
|
risk, _ := r["risk"].(string)
|
|
score, _ := r["score"].(float64)
|
|
if err := rankEnc.WriteRow(number, title, author, risk, roundFloat(score)); err != nil {
|
|
return nil, err
|
|
}
|
|
}
|
|
}
|
|
if err := rankEnc.Close(); err != nil {
|
|
return nil, err
|
|
}
|
|
|
|
return buf.Bytes(), nil
|
|
}
|
|
|
|
// encodeConflictsPRs encodes the conflicts_prs payload as GCX1: a one-row
|
|
// summary plus one row per conflict cluster — the community, its size, the
|
|
// colliding PR numbers, a suggested merge order, and the conflict-risk
|
|
// score. The PR-number lists are flattened to comma-joined strings, the
|
|
// same shape list_prs uses for its blocker list.
|
|
func encodeConflictsPRs(result map[string]any) ([]byte, error) {
|
|
var buf bytes.Buffer
|
|
|
|
total, _ := result["total"].(int)
|
|
sumEnc := newGCX(&buf, "conflicts_prs.summary", []string{"total"})
|
|
if err := sumEnc.WriteRow(total); err != nil {
|
|
return nil, err
|
|
}
|
|
if err := sumEnc.Close(); err != nil {
|
|
return nil, err
|
|
}
|
|
|
|
conEnc := newGCX(&buf, "conflicts_prs.conflicts",
|
|
[]string{"community", "size", "prs", "suggested_order", "risk"},
|
|
)
|
|
if conflicts, ok := result["conflicts"].([]map[string]any); ok {
|
|
for _, c := range conflicts {
|
|
community, _ := c["community"].(string)
|
|
size, _ := c["size"].(int)
|
|
prs, _ := c["prs"].([]int)
|
|
order, _ := c["suggested_order"].([]int)
|
|
risk, _ := c["risk"].(float64)
|
|
if err := conEnc.WriteRow(
|
|
community, size, joinInts(prs), joinInts(order), roundFloat(risk),
|
|
); err != nil {
|
|
return nil, err
|
|
}
|
|
}
|
|
}
|
|
if err := conEnc.Close(); err != nil {
|
|
return nil, err
|
|
}
|
|
|
|
return buf.Bytes(), nil
|
|
}
|
|
|
|
// encodeSiblingDiffContext encodes the sibling_diff_context payload as GCX1: a
|
|
// one-row summary (total + focus list) plus a per-sibling section carrying the
|
|
// relation tag, relatedness score, and the raw diff text. The map shape mirrors
|
|
// siblingDiffPayload so JSON and GCX share one set of field names.
|
|
func encodeSiblingDiffContext(result map[string]any) ([]byte, error) {
|
|
var buf bytes.Buffer
|
|
|
|
total, _ := result["total"].(int)
|
|
focus, _ := result["focus"].([]string)
|
|
sumEnc := newGCX(&buf, "sibling_diff_context.summary", []string{"total", "focus"})
|
|
if err := sumEnc.WriteRow(total, strings.Join(focus, ",")); err != nil {
|
|
return nil, err
|
|
}
|
|
if err := sumEnc.Close(); err != nil {
|
|
return nil, err
|
|
}
|
|
|
|
sibEnc := newGCX(&buf, "sibling_diff_context.siblings",
|
|
[]string{"file", "relation", "score", "diff"},
|
|
)
|
|
if siblings, ok := result["siblings"].([]map[string]any); ok {
|
|
for _, sib := range siblings {
|
|
file, _ := sib["file"].(string)
|
|
relation, _ := sib["relation"].(string)
|
|
score, _ := sib["score"].(float64)
|
|
diff, _ := sib["diff"].(string)
|
|
if err := sibEnc.WriteRow(file, relation, roundFloat(score), diff); err != nil {
|
|
return nil, err
|
|
}
|
|
}
|
|
}
|
|
if err := sibEnc.Close(); err != nil {
|
|
return nil, err
|
|
}
|
|
|
|
return buf.Bytes(), nil
|
|
}
|
|
|
|
// encodeReview renders the review tool's verdict envelope + line-anchored
|
|
// inline comments + per-file risk into GCX1: a one-row summary section, a
|
|
// comments row-section, and a file-risk row-section.
|
|
func encodeReview(result map[string]any) ([]byte, error) {
|
|
var buf bytes.Buffer
|
|
|
|
verdict, _ := result["verdict"].(string)
|
|
summary, _ := result["summary"].(string)
|
|
total, _ := result["total"].(int)
|
|
depth, _ := result["depth"].(string)
|
|
sumEnc := newGCX(&buf, "review.summary", []string{"verdict", "total", "depth", "summary"})
|
|
if err := sumEnc.WriteRow(verdict, total, depth, summary); err != nil {
|
|
return nil, err
|
|
}
|
|
if err := sumEnc.Close(); err != nil {
|
|
return nil, err
|
|
}
|
|
|
|
comEnc := newGCX(&buf, "review.comments",
|
|
[]string{"file", "line", "severity", "category", "rule", "source", "message"},
|
|
)
|
|
if comments, ok := result["comments"].([]map[string]any); ok {
|
|
for _, c := range comments {
|
|
file, _ := c["file"].(string)
|
|
line, _ := c["line"].(int)
|
|
severity, _ := c["severity"].(string)
|
|
category, _ := c["category"].(string)
|
|
rule, _ := c["rule"].(string)
|
|
source, _ := c["source"].(string)
|
|
message, _ := c["message"].(string)
|
|
if err := comEnc.WriteRow(file, line, severity, category, rule, source, message); err != nil {
|
|
return nil, err
|
|
}
|
|
}
|
|
}
|
|
if err := comEnc.Close(); err != nil {
|
|
return nil, err
|
|
}
|
|
|
|
riskEnc := newGCX(&buf, "review.file_risk", []string{"file", "risk", "findings", "affected", "symbols", "uncovered"})
|
|
if risks, ok := result["file_risk"].([]map[string]any); ok {
|
|
for _, r := range risks {
|
|
file, _ := r["file"].(string)
|
|
risk, _ := r["risk"].(string)
|
|
findings, _ := r["findings"].(int)
|
|
affected, _ := r["affected"].(int)
|
|
symbols, _ := r["symbols"].(int)
|
|
uncovered, _ := r["uncovered"].(int)
|
|
if err := riskEnc.WriteRow(file, risk, findings, affected, symbols, uncovered); err != nil {
|
|
return nil, err
|
|
}
|
|
}
|
|
}
|
|
if err := riskEnc.Close(); err != nil {
|
|
return nil, err
|
|
}
|
|
|
|
return buf.Bytes(), nil
|
|
}
|
|
|
|
// encodePRReviewContext renders the deterministic PR-review rollup into GCX1:
|
|
// a one-row summary (verdict + changed-file / changed-symbol counts), a gates
|
|
// row-section (one row per evaluated section with its status + detail), and a
|
|
// diff_context row-section carrying each changed symbol's risk + fan counts.
|
|
// The append-only section layout keeps the encoder forward compatible — a new
|
|
// section adds a row-section, never reorders an existing one.
|
|
func encodePRReviewContext(out prReviewContext) ([]byte, error) {
|
|
var buf bytes.Buffer
|
|
|
|
sumEnc := newGCX(&buf, "pr_review_context.summary",
|
|
[]string{"verdict", "changed_files", "changed_symbols"},
|
|
)
|
|
if err := sumEnc.WriteRow(out.Verdict, len(out.ChangedFiles), out.ChangedSymbols); err != nil {
|
|
return nil, err
|
|
}
|
|
if err := sumEnc.Close(); err != nil {
|
|
return nil, err
|
|
}
|
|
|
|
gateEnc := newGCX(&buf, "pr_review_context.gates",
|
|
[]string{"name", "status", "detail"},
|
|
)
|
|
for _, g := range out.Gates {
|
|
if err := gateEnc.WriteRow(g.Name, g.Status, g.Detail); err != nil {
|
|
return nil, err
|
|
}
|
|
}
|
|
if err := gateEnc.Close(); err != nil {
|
|
return nil, err
|
|
}
|
|
|
|
dcEnc := newGCX(&buf, "pr_review_context.diff_context",
|
|
[]string{"id", "kind", "risk", "callers", "callees", "signature"},
|
|
)
|
|
for _, sym := range out.DiffContext {
|
|
if err := dcEnc.WriteRow(sym.ID, sym.Kind, sym.Risk, len(sym.Callers), len(sym.Callees), sym.Signature); err != nil {
|
|
return nil, err
|
|
}
|
|
}
|
|
if err := dcEnc.Close(); err != nil {
|
|
return nil, err
|
|
}
|
|
|
|
return buf.Bytes(), nil
|
|
}
|
|
|
|
// encodeCritiqueReview renders the self-critique result into GCX1: a one-row
|
|
// summary (revised verdict + kept/dropped/uncertain counts + whether the LLM
|
|
// adjudicated), a kept-findings row-section, and a dropped-findings row-section
|
|
// carrying the critique verdict + reason per finding. The append-only section
|
|
// layout keeps the encoder forward compatible — a new section adds a row-section,
|
|
// never reorders an existing one.
|
|
func encodeCritiqueReview(out critiqueReviewResult) ([]byte, error) {
|
|
var buf bytes.Buffer
|
|
|
|
sumEnc := newGCX(&buf, "critique_review.summary",
|
|
[]string{"verdict", "kept", "dropped", "uncertain", "total", "llm_used", "elapsed_ms", "summary"},
|
|
)
|
|
if err := sumEnc.WriteRow(out.Verdict, out.KeptCount, len(out.Dropped), out.Uncertain,
|
|
out.Total, out.LLMUsed, out.ElapsedMs, out.Summary); err != nil {
|
|
return nil, err
|
|
}
|
|
if err := sumEnc.Close(); err != nil {
|
|
return nil, err
|
|
}
|
|
|
|
keptEnc := newGCX(&buf, "critique_review.kept",
|
|
[]string{"file", "line", "severity", "category", "rule", "message"},
|
|
)
|
|
for _, f := range out.Kept {
|
|
line := f.Line
|
|
if line == 0 {
|
|
line = f.StartLine
|
|
}
|
|
if err := keptEnc.WriteRow(f.File, line, string(f.Severity), f.Category, f.Rule, f.Message); err != nil {
|
|
return nil, err
|
|
}
|
|
}
|
|
if err := keptEnc.Close(); err != nil {
|
|
return nil, err
|
|
}
|
|
|
|
dropEnc := newGCX(&buf, "critique_review.dropped",
|
|
[]string{"file", "line", "severity", "category", "rule", "critique_verdict", "critique_reason", "message"},
|
|
)
|
|
for _, d := range out.Dropped {
|
|
f := d.Finding
|
|
line := f.Line
|
|
if line == 0 {
|
|
line = f.StartLine
|
|
}
|
|
if err := dropEnc.WriteRow(f.File, line, string(f.Severity), f.Category, f.Rule,
|
|
string(d.Verdict), d.Reason, f.Message); err != nil {
|
|
return nil, err
|
|
}
|
|
}
|
|
if err := dropEnc.Close(); err != nil {
|
|
return nil, err
|
|
}
|
|
|
|
return buf.Bytes(), nil
|
|
}
|
|
|
|
// encodeReviewPack renders the packaged review envelope into GCX1: a one-row
|
|
// summary (verdict + the gate rollups), a changed-symbol classification
|
|
// row-section, a per-file risk row-section, a findings row-section, and a guards
|
|
// row-section. The append-only section layout keeps the encoder forward
|
|
// compatible — a new gate adds a section, never reorders an existing one.
|
|
func encodeReviewPack(result map[string]any) ([]byte, error) {
|
|
var buf bytes.Buffer
|
|
|
|
verdict, _ := result["verdict"].(string)
|
|
summary, _ := result["summary"].(string)
|
|
verCmd, _ := result["verification_command"].(string)
|
|
total, _ := result["total"].(int)
|
|
depth, _ := result["depth"].(string)
|
|
guards, _ := result["guards"].([]analysis.GuardViolation)
|
|
breaking := 0
|
|
if ci, ok := result["contracts"].(*contractImpact); ok && ci != nil {
|
|
breaking = ci.Breaking
|
|
}
|
|
sumEnc := newGCX(&buf, "review_pack.summary",
|
|
[]string{"verdict", "findings", "depth", "guard_violations", "contract_breaking", "verification_command", "summary"},
|
|
)
|
|
if err := sumEnc.WriteRow(verdict, total, depth, len(guards), breaking, verCmd, summary); err != nil {
|
|
return nil, err
|
|
}
|
|
if err := sumEnc.Close(); err != nil {
|
|
return nil, err
|
|
}
|
|
|
|
symEnc := newGCX(&buf, "review_pack.changed_symbols",
|
|
[]string{"id", "name", "class", "risk"},
|
|
)
|
|
if syms, ok := result["changed_symbols"].([]map[string]any); ok {
|
|
for _, sym := range syms {
|
|
id, _ := sym["id"].(string)
|
|
name, _ := sym["name"].(string)
|
|
class, _ := sym["class"].(string)
|
|
risk, _ := sym["risk"].(string)
|
|
if err := symEnc.WriteRow(id, name, class, risk); err != nil {
|
|
return nil, err
|
|
}
|
|
}
|
|
}
|
|
if err := symEnc.Close(); err != nil {
|
|
return nil, err
|
|
}
|
|
|
|
riskEnc := newGCX(&buf, "review_pack.file_risk", []string{"file", "risk", "findings", "affected", "symbols", "uncovered"})
|
|
if risks, ok := result["file_risk"].([]map[string]any); ok {
|
|
for _, r := range risks {
|
|
file, _ := r["file"].(string)
|
|
risk, _ := r["risk"].(string)
|
|
findings, _ := r["findings"].(int)
|
|
affected, _ := r["affected"].(int)
|
|
symbols, _ := r["symbols"].(int)
|
|
uncovered, _ := r["uncovered"].(int)
|
|
if err := riskEnc.WriteRow(file, risk, findings, affected, symbols, uncovered); err != nil {
|
|
return nil, err
|
|
}
|
|
}
|
|
}
|
|
if err := riskEnc.Close(); err != nil {
|
|
return nil, err
|
|
}
|
|
|
|
findEnc := newGCX(&buf, "review_pack.findings",
|
|
[]string{"file", "line", "severity", "category", "rule", "source", "message"},
|
|
)
|
|
if findings, ok := result["findings"].([]map[string]any); ok {
|
|
for _, f := range findings {
|
|
file, _ := f["file"].(string)
|
|
line, _ := f["line"].(int)
|
|
severity, _ := f["severity"].(string)
|
|
category, _ := f["category"].(string)
|
|
rule, _ := f["rule"].(string)
|
|
source, _ := f["source"].(string)
|
|
message, _ := f["message"].(string)
|
|
if err := findEnc.WriteRow(file, line, severity, category, rule, source, message); err != nil {
|
|
return nil, err
|
|
}
|
|
}
|
|
}
|
|
if err := findEnc.Close(); err != nil {
|
|
return nil, err
|
|
}
|
|
|
|
guardEnc := newGCX(&buf, "review_pack.guards",
|
|
[]string{"rule_name", "kind", "description"},
|
|
)
|
|
for _, v := range guards {
|
|
if err := guardEnc.WriteRow(v.RuleName, v.Kind, v.Description); err != nil {
|
|
return nil, err
|
|
}
|
|
}
|
|
if err := guardEnc.Close(); err != nil {
|
|
return nil, err
|
|
}
|
|
|
|
return buf.Bytes(), nil
|
|
}
|
|
|
|
// joinInts renders a slice of ints as a comma-joined string for GCX1
|
|
// scalar columns that carry a small list (e.g. colliding PR numbers).
|
|
func joinInts(xs []int) string {
|
|
if len(xs) == 0 {
|
|
return ""
|
|
}
|
|
parts := make([]string, len(xs))
|
|
for i, x := range xs {
|
|
parts[i] = strconv.Itoa(x)
|
|
}
|
|
return strings.Join(parts, ",")
|
|
}
|