Files
wehub-resource-sync a06f331eb8
CI / benchmark (push) Has been skipped
install-script / posix-syntax (push) Successful in 6m1s
CI / build-onnx (push) Failing after 6m43s
init-smoke / dry-run (push) Failing after 15m57s
security / govulncheck (push) Has been cancelled
security / trivy-fs (push) Has been cancelled
CI / test (1.26, ubuntu-latest) (push) Has been cancelled
Scorecard supply-chain security / Scorecard analysis (push) Has been cancelled
CI / test (1.26, macos-latest) (push) Has been cancelled
CI / build-windows (push) Has been cancelled
CI / lint (push) Has been cancelled
install-script / powershell-syntax (push) Has been cancelled
install-script / install (macos-14) (push) Has been cancelled
install-script / install (ubuntu-latest) (push) Has been cancelled
chore: import upstream snapshot with attribution
2026-07-13 12:33:42 +08:00

395 lines
12 KiB
Go

package hooks
import (
"encoding/json"
"fmt"
"regexp"
"sort"
"strconv"
"strings"
)
// postHookInput is the PostToolUse payload Claude Code sends. It differs
// from PreToolUse only by carrying tool_response — the textual output
// the tool produced. We deliberately decode tool_response as `any` because
// each tool returns a different shape (Grep emits text with file:line
// matches, Glob emits a newline-separated file list, Read emits raw file
// content). The shape-specific handlers below normalise to a string.
type postHookInput struct {
HookEventName string `json:"hook_event_name"`
ToolName string `json:"tool_name"`
ToolInput map[string]any `json:"tool_input"`
ToolResponse any `json:"tool_response"`
CWD string `json:"cwd"`
}
// runPostToolUse parses the PostToolUse payload and appends graph
// enrichment as additionalContext when the tool was a Grep / Glob / Read
// that touched the indexed graph. Other tools fall through to a no-op —
// PostToolUse must never block the run.
//
// The handler is shape-aware:
// - Grep: parse "path:line:text" lines, look up the enclosing symbol
// in the graph for the first few hits, append name + kind so the
// agent sees graph-grade follow-ups inline.
// - Glob: count the matched files, look up symbol counts per file,
// append a short summary so the agent can pick the right one without
// a follow-up tool call.
// - Read: look up the file's symbol count and importer count so the
// agent knows where the file lives before deciding to act.
//
// Every graph lookup goes over the daemon's AF_UNIX MCP socket (see
// fileSummaryViaDaemon), the same channel the PreToolUse file-indexed
// probe uses. An earlier revision hit an HTTP :8765 /api/graph/* API that
// was removed when the web surface migrated to the daemon, so these
// lookups silently returned nothing regardless of configuration (#241).
func runPostToolUse(data []byte) {
var input postHookInput
if err := json.Unmarshal(data, &input); err != nil {
return
}
if input.HookEventName != "PostToolUse" {
return
}
var ctx string
switch input.ToolName {
case "Grep":
ctx = postGrep(input)
case "Glob":
ctx = postGlob(input)
case "Read":
ctx = postRead(input)
}
if ctx == "" {
return
}
output := HookOutput{
HookSpecificOutput: &HookSpecificOutput{
HookEventName: "PostToolUse",
AdditionalContext: ctx,
},
}
out, err := json.Marshal(output)
if err != nil {
return
}
fmt.Print(string(out))
}
// grepHitLineRe matches the leading "<path>:<line>" of a ripgrep-style
// hit (Claude Code's Grep tool uses ripgrep underneath). Captures the
// path and line number; the rest of the line — the matched text — is
// discarded.
var grepHitLineRe = regexp.MustCompile(`^([^:]+):(\d+):`)
// postGrep parses ripgrep-style match lines from tool_response and adds
// "enclosing symbol" lookups for the first few hits so the agent doesn't
// have to follow up with find_usages / get_callers manually. Summaries
// are memoised per file so several hits in one file cost one socket call.
func postGrep(input postHookInput) string {
body := responseText(input.ToolResponse)
if body == "" {
return ""
}
hits := parseGrepHits(body)
if len(hits) == 0 {
return ""
}
const maxLookup = 5
// Cache summaries (including misses, cached as nil) so repeated hits
// in the same file don't re-dial the daemon.
cache := make(map[string]*hookFileSummary)
enriched := make([]string, 0, maxLookup)
for _, h := range hits {
if len(enriched) >= maxLookup {
break
}
summary, seen := cache[h.path]
if !seen {
summary, _ = fileSummaryFn(input.CWD, h.path)
cache[h.path] = summary
}
if summary == nil {
continue
}
n := enclosingNode(summary.Symbols, h.line)
if n == nil {
continue
}
label := n.Name
if n.Kind != "" {
label = n.Kind + " " + n.Name
}
enriched = append(enriched, fmt.Sprintf(" %s:%d → %s", h.path, h.line, label))
}
if len(enriched) == 0 {
return ""
}
var b strings.Builder
fmt.Fprintf(&b, "[Gortex] Graph context for %d of %d Grep hit(s):\n", len(enriched), len(hits))
for _, line := range enriched {
b.WriteString(line + "\n")
}
b.WriteString("Follow-up: pass any symbol ID to `find_usages` / `get_callers` for blast radius without re-Grep.\n")
return b.String()
}
// postGlob counts the matched files and looks up symbol counts per file
// so the agent can rank them by relevance without another Read/Grep
// roundtrip. Files that aren't indexed are still counted but only
// reported in aggregate ("12 indexed / 3 unindexed").
func postGlob(input postHookInput) string {
body := responseText(input.ToolResponse)
if body == "" {
return ""
}
paths := parseGlobPaths(body)
if len(paths) == 0 {
return ""
}
type fileSummary struct {
path string
symbols int
}
const maxFiles = 8
indexed := make([]fileSummary, 0, maxFiles)
unindexed := 0
for _, p := range paths {
summary, ok := fileSummaryFn(input.CWD, p)
if !ok || summary == nil || len(summary.Symbols) == 0 {
unindexed++
continue
}
if len(indexed) < maxFiles {
indexed = append(indexed, fileSummary{path: p, symbols: len(summary.Symbols)})
}
}
if len(indexed) == 0 {
return ""
}
// Sort by symbol count desc — the largest files are usually the
// most interesting for "where is logic concentrated?" queries.
sort.Slice(indexed, func(i, j int) bool { return indexed[i].symbols > indexed[j].symbols })
var b strings.Builder
fmt.Fprintf(&b, "[Gortex] Indexed %d/%d Glob match(es):\n", len(paths)-unindexed, len(paths))
for _, f := range indexed {
fmt.Fprintf(&b, " %s — %d symbol(s)\n", f.path, f.symbols)
}
if len(paths)-unindexed > len(indexed) {
fmt.Fprintf(&b, " ... and %d more indexed file(s)\n", (len(paths)-unindexed)-len(indexed))
}
b.WriteString("Follow-up: `get_file_summary` for any single file; `get_repo_outline` for the whole workspace shape.\n")
return b.String()
}
// postRead enriches a Read by reporting the file's graph footprint —
// symbol count and how many other files import it — so the agent sees
// where the file sits in the codebase. Files outside the graph are
// silently skipped.
func postRead(input postHookInput) string {
filePath, _ := input.ToolInput["file_path"].(string)
if filePath == "" {
return ""
}
summary, ok := fileSummaryFn(input.CWD, filePath)
if !ok || summary == nil || len(summary.Symbols) == 0 {
return ""
}
var b strings.Builder
fmt.Fprintf(&b, "[Gortex] Graph footprint for %s:\n", filePath)
fmt.Fprintf(&b, " %d indexed symbol(s)\n", len(summary.Symbols))
if summary.Dependents > 0 {
fmt.Fprintf(&b, " %d file(s) import this one\n", summary.Dependents)
}
b.WriteString("Follow-up: `get_file_summary` / `get_editing_context` returns the same info plus signatures, no re-Read needed.\n")
return b.String()
}
// ---------------------------------------------------------------------------
// Response normalisation
// ---------------------------------------------------------------------------
// responseText extracts a plain string from tool_response regardless of
// the shape Claude Code sent: bare string, {"content": "..."}, or
// {"output": "..."}. Unknown shapes return "" — the handler then no-ops.
func responseText(v any) string {
switch r := v.(type) {
case string:
return r
case map[string]any:
for _, k := range []string{"content", "output", "stdout", "text"} {
if s, ok := r[k].(string); ok && s != "" {
return s
}
}
}
return ""
}
// parseGrepHits scans the response for "<path>:<line>:" prefixes. Lines
// that don't match the shape are ignored — Claude Code's Grep tool can
// emit summary lines (e.g. "Found 4 files") that aren't hits.
type grepHit struct {
path string
line int
}
func parseGrepHits(body string) []grepHit {
var out []grepHit
seen := make(map[string]bool)
for ln := range strings.SplitSeq(body, "\n") {
m := grepHitLineRe.FindStringSubmatch(ln)
if m == nil {
continue
}
path := m[1]
lineNum, err := strconv.Atoi(m[2])
if err != nil {
continue
}
key := path + ":" + m[2]
if seen[key] {
continue
}
seen[key] = true
out = append(out, grepHit{path: path, line: lineNum})
}
return out
}
// parseGlobPaths splits the response on newlines, trims whitespace, and
// keeps non-empty entries that look like file paths (skip summary
// strings). The Glob tool emits one path per line.
func parseGlobPaths(body string) []string {
var out []string
for ln := range strings.SplitSeq(body, "\n") {
p := strings.TrimSpace(ln)
if p == "" {
continue
}
// Skip "(no matches)" / "Found N files" preambles.
if strings.HasPrefix(p, "(") || strings.HasPrefix(p, "Found ") {
continue
}
// Must look like a path (have an extension or a slash). This
// is a conservative filter — anything else gets dropped so we
// don't try to graph-lookup commentary lines.
if !strings.Contains(p, "/") && !strings.Contains(p, ".") {
continue
}
out = append(out, p)
}
return out
}
// ---------------------------------------------------------------------------
// Graph lookups (over the daemon AF_UNIX MCP socket)
// ---------------------------------------------------------------------------
// summaryNode is the subset of a get_file_summary node the PostToolUse
// enrichment consumes: name/kind for the label, and the line span so the
// enclosing symbol for a grep hit can be resolved client-side.
type summaryNode struct {
Name string `json:"name"`
Kind string `json:"kind"`
StartLine int `json:"start_line"`
EndLine int `json:"end_line"`
}
// hookFileSummary is the parsed shape the PostToolUse handlers need from a
// get_file_summary response: the file's definition symbols plus a count of
// the files that import it.
type hookFileSummary struct {
Symbols []summaryNode
Dependents int
}
// fileSummaryFn is the seam tests stub; production routes through the
// daemon's MCP socket (fileSummaryViaDaemon). A false / nil return is the
// "no signal" case — daemon unreachable, malformed response, or the file
// genuinely not indexed; callers treat all three the same (no enrichment).
var fileSummaryFn = fileSummaryViaDaemon
// fileSummaryViaDaemon fetches get_file_summary for filePath over the
// daemon socket and parses out the definition symbols + importer count.
// Shares daemonFileSummaryRaw (dial + path resolution + frame exchange)
// with the PreToolUse file-indexed probe so both stay on one transport.
func fileSummaryViaDaemon(cwd, filePath string) (*hookFileSummary, bool) {
resp, ok := daemonFileSummaryRaw(cwd, filePath)
if !ok {
return nil, false
}
syms, dependents, ok := parseFileSummary(resp)
if !ok {
return nil, false
}
return &hookFileSummary{Symbols: syms, Dependents: dependents}, true
}
// parseFileSummary unwraps a get_file_summary tools/call response —
// JSON-RPC envelope → first content block (the JSON payload as text) →
// {nodes, dependents}. get_file_summary strips the file/import nodes, so
// nodes is the definition-symbol list; a not-indexed file comes back as a
// tool error / guidance text, which fails the parse → ok=false.
func parseFileSummary(resp []byte) (nodes []summaryNode, dependents int, ok bool) {
var rpc struct {
Result struct {
Content []struct {
Text string `json:"text"`
} `json:"content"`
IsError bool `json:"isError"`
} `json:"result"`
}
if err := json.Unmarshal(resp, &rpc); err != nil {
return nil, 0, false
}
if rpc.Result.IsError || len(rpc.Result.Content) == 0 {
return nil, 0, false
}
var summary struct {
Nodes []summaryNode `json:"nodes"`
Dependents []json.RawMessage `json:"dependents"`
}
if err := json.Unmarshal([]byte(rpc.Result.Content[0].Text), &summary); err != nil {
return nil, 0, false
}
if len(summary.Nodes) == 0 {
return nil, 0, false
}
return summary.Nodes, len(summary.Dependents), true
}
// enclosingNode returns the innermost definition node whose [start,end]
// line span contains line, or nil when no node covers it. Ties break to
// the smallest span so a method wins over the type/file that also spans
// the line. Nodes without an end line are treated as single-line spans.
func enclosingNode(nodes []summaryNode, line int) *summaryNode {
var best *summaryNode
bestSpan := int(^uint(0) >> 1) // max int
for i := range nodes {
n := &nodes[i]
if n.StartLine <= 0 {
continue
}
end := n.EndLine
if end < n.StartLine {
end = n.StartLine
}
if line < n.StartLine || line > end {
continue
}
if span := end - n.StartLine; best == nil || span < bestSpan {
best, bestSpan = n, span
}
}
return best
}