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

553 lines
17 KiB
Go

// Package astquery is the structural code-search engine behind the
// `search_ast` MCP tool. It runs tree-sitter pattern queries (raw
// S-expressions or one of the bundled named detectors) against
// already-indexed files and returns each match enriched with the
// enclosing symbol from the graph, the captured nodes, and the
// detector metadata.
//
// The package is deliberately graph-aware: results carry a
// `SymbolID` field so a caller can chain straight into
// `find_usages`, `verify_change`, or `apply_code_action` without a
// second graph walk. The pre-filter side of the same coin is on the
// caller — the MCP wrapper decides which (file, language) targets to
// hand the engine, scoping by repo / project / community / churn /
// fan-in / path-prefix before the engine spends a single tree-sitter
// parse. That two-layer split keeps astquery independently testable
// (no graph dependency) while still letting the wrapper exploit
// graph predicates ast-grep can't express.
package astquery
import (
"context"
"errors"
"fmt"
"os"
"path/filepath"
"runtime"
"sort"
"strings"
"sync"
"github.com/zzet/gortex/internal/parser"
sitter "github.com/zzet/gortex/internal/parser/tsitter"
)
// Target points the engine at one indexed file. Both the on-disk path
// (for parsing) and the repo-prefixed graph path (for result
// enrichment and SymbolLookup) are needed because the graph speaks
// repo-relative IDs and the filesystem speaks absolute paths.
type Target struct {
AbsPath string
GraphPath string
Language string
}
// SymbolLookup resolves the enclosing function/method/closure at a
// 1-based line in a graph-relative file. The MCP layer wires this
// against `*graph.Graph`; tests can pass a stub. Returning empty
// strings is fine — matches simply ship without symbol enrichment.
type SymbolLookup func(graphPath string, line int) (symbolID, symbolName string)
// LanguageResolver maps a language name (as stored on KindFile nodes —
// "go", "python", "typescript", …) to its tree-sitter binding. The
// engine ships a default resolver covering the languages used by the
// bundled detectors; consumers can pass an extended resolver to
// support raw-pattern queries against any language Gortex indexes.
type LanguageResolver func(name string) *sitter.Language
// Options are the engine's run knobs. Pattern XOR Detector must be
// set; the engine does not invent a detector when both are blank.
type Options struct {
// Pattern is a raw tree-sitter S-expression query. Used when
// Detector is empty. The pattern's language is inferred from
// each Target's Language field — one compiled query per
// distinct (pattern, language) pair, cached for the run.
Pattern string
// Detector is the name of a bundled detector. When set, Pattern
// and Language are ignored; the engine picks the per-language
// pattern from the detector definition.
Detector string
// Language pre-filters Targets when Pattern is set: only
// targets whose Language matches this string are processed.
// Empty = no language filter.
Language string
// Targets is the file set to scan. The MCP wrapper builds this
// from the graph after applying scope predicates (repo /
// project / community / fan-in / churn / path-prefix); the
// engine itself does no graph walk.
Targets []Target
// SymbolLookup is consulted after a match to enrich the row
// with the enclosing function. Optional; nil leaves SymbolID
// blank.
SymbolLookup SymbolLookup
// Resolver maps language names to tree-sitter bindings.
// Required for both raw-pattern and detector runs (a detector's
// per-language patterns still need the binding to compile).
// Pass DefaultLanguageResolver for the bundled set.
Resolver LanguageResolver
// Limit caps the total returned matches. 0 means use Default.
Limit int
// MaxMatchText truncates each row's `MatchText` to this many
// bytes (utf-8 safe — we cut on rune boundaries). 0 → 200.
MaxMatchText int
// ExcludeTests drops matches whose target file path looks like
// a test (per IsTestPath). Detectors default this to true; raw
// patterns default to false because the user typed the query.
ExcludeTests bool
// Concurrency caps the worker pool. 0 → runtime.GOMAXPROCS(0).
Concurrency int
}
// Match is a single hit. The fields are deliberately flat so the MCP
// layer can hand them to the standard wire-format encoders without
// translation.
type Match struct {
File string `json:"file"`
Line int `json:"line"`
EndLine int `json:"end_line"`
Column int `json:"column"`
EndCol int `json:"end_col"`
SymbolID string `json:"symbol_id,omitempty"`
SymbolName string `json:"symbol_name,omitempty"`
Detector string `json:"detector,omitempty"`
Severity string `json:"severity,omitempty"`
Language string `json:"language"`
Text string `json:"text"`
Captures map[string]string `json:"captures,omitempty"`
}
// Result wraps the match list with summary counts. `Truncated` is set
// when Limit cut the response short — useful for the MCP layer to
// decide whether to surface a "narrow your filter" hint.
type Result struct {
Matches []Match `json:"matches"`
Total int `json:"total"`
Truncated bool `json:"truncated,omitempty"`
FilesWalked int `json:"files_walked"`
Errors []string `json:"errors,omitempty"`
}
const (
defaultLimit = 50
defaultMaxMatchText = 200
defaultMaxFileSize = 4 * 1024 * 1024
)
var errNoQuery = errors.New("astquery: pattern or detector is required")
// Run executes the configured query across all Targets and returns
// the assembled Result. The function is safe to call concurrently.
//
// Compilation strategy: each (pattern, language) pair is compiled
// once for the run via per-language `*parser.PreparedQuery` instances
// kept in a local cache. After Run returns the cache is closed so we
// don't leak C resources across runs.
func Run(ctx context.Context, opts Options) (Result, error) {
if opts.Pattern == "" && opts.Detector == "" {
return Result{}, errNoQuery
}
if opts.Resolver == nil {
opts.Resolver = DefaultLanguageResolver
}
if opts.Limit <= 0 {
opts.Limit = defaultLimit
}
if opts.MaxMatchText <= 0 {
opts.MaxMatchText = defaultMaxMatchText
}
if opts.Concurrency <= 0 {
opts.Concurrency = runtime.GOMAXPROCS(0)
}
plan, err := buildPlan(opts)
if err != nil {
return Result{}, err
}
defer plan.close()
if len(plan.targets) == 0 {
return Result{}, nil
}
jobs := make(chan Target, len(plan.targets))
results := make(chan []Match, opts.Concurrency)
errCh := make(chan string, opts.Concurrency)
var wg sync.WaitGroup
for i := 0; i < opts.Concurrency; i++ {
wg.Add(1)
go func() {
defer wg.Done()
for t := range jobs {
if ctx.Err() != nil {
return
}
m, e := plan.runTarget(ctx, t, opts)
if e != nil {
select {
case errCh <- fmt.Sprintf("%s: %v", t.GraphPath, e):
default:
}
}
if len(m) > 0 {
results <- m
}
}
}()
}
go func() {
for _, t := range plan.targets {
jobs <- t
}
close(jobs)
wg.Wait()
close(results)
close(errCh)
}()
out := Result{FilesWalked: len(plan.targets)}
for batch := range results {
out.Matches = append(out.Matches, batch...)
}
for e := range errCh {
out.Errors = append(out.Errors, e)
}
// Stable order: by file then line. Important for golden tests
// and for agent UX (consistent listings across reruns).
sort.Slice(out.Matches, func(i, j int) bool {
if out.Matches[i].File != out.Matches[j].File {
return out.Matches[i].File < out.Matches[j].File
}
return out.Matches[i].Line < out.Matches[j].Line
})
out.Total = len(out.Matches)
if out.Total > opts.Limit {
out.Matches = out.Matches[:opts.Limit]
out.Truncated = true
}
return out, ctx.Err()
}
// plan is the per-run state: one compiled query per language, the
// target list filtered by Language and ExcludeTests, and the detector
// definition (if any).
type plan struct {
queries map[string]*parser.PreparedQuery
detector *Detector
targets []Target
pattern string
}
func buildPlan(opts Options) (*plan, error) {
p := &plan{queries: make(map[string]*parser.PreparedQuery)}
// Detector mode: ignore Pattern + Language; the detector's
// per-language map drives both query compilation and target
// filtering.
if opts.Detector != "" {
d, ok := lookupDetector(opts.Detector)
if !ok {
return nil, fmt.Errorf("astquery: unknown detector %q (call ListDetectors to enumerate)", opts.Detector)
}
p.detector = d
for _, t := range opts.Targets {
if _, has := d.Languages[t.Language]; !has {
continue
}
if (opts.ExcludeTests || d.ExcludeTests) && IsTestPath(t.AbsPath) {
continue
}
p.targets = append(p.targets, t)
}
// Compile one query per language used by the detector,
// but only languages that actually have targets — saves
// CGO compile cost when the index is monolingual.
used := make(map[string]bool)
for _, t := range p.targets {
used[t.Language] = true
}
for lang := range used {
pat, ok := d.Languages[lang]
if !ok {
continue
}
tsLang := opts.Resolver(lang)
if tsLang == nil {
continue
}
q, err := parser.NewPreparedQuery(pat, tsLang)
if err != nil {
p.close()
return nil, fmt.Errorf("astquery: detector %q has invalid pattern for language %q: %w", d.Name, lang, err)
}
p.queries[lang] = q
}
return p, nil
}
// Raw-pattern mode: one query, one language.
p.pattern = opts.Pattern
for _, t := range opts.Targets {
if opts.Language != "" && t.Language != opts.Language {
continue
}
if opts.ExcludeTests && IsTestPath(t.AbsPath) {
continue
}
p.targets = append(p.targets, t)
}
used := make(map[string]bool)
for _, t := range p.targets {
used[t.Language] = true
}
for lang := range used {
tsLang := opts.Resolver(lang)
if tsLang == nil {
continue
}
q, err := parser.NewPreparedQuery(opts.Pattern, tsLang)
if err != nil {
p.close()
return nil, fmt.Errorf("astquery: pattern compile (lang=%s): %w", lang, err)
}
p.queries[lang] = q
}
if len(p.queries) == 0 {
return p, nil
}
return p, nil
}
func (p *plan) close() {
for _, q := range p.queries {
q.Close()
}
p.queries = nil
}
func (p *plan) runTarget(ctx context.Context, t Target, opts Options) ([]Match, error) {
src, err := readBoundedFile(t.AbsPath, defaultMaxFileSize)
if err != nil {
return nil, err
}
return p.runBytes(ctx, t, opts, src)
}
func (p *plan) runBytes(_ context.Context, t Target, opts Options, src []byte) ([]Match, error) {
q := p.queries[t.Language]
if q == nil {
return nil, nil
}
tsLang := opts.Resolver(t.Language)
if tsLang == nil {
return nil, fmt.Errorf("no tree-sitter binding for language %q", t.Language)
}
tree, err := parser.ParseFile(src, tsLang)
if err != nil {
return nil, err
}
defer tree.Close()
hits := parser.RunPrepared(q, tree.RootNode(), src)
if len(hits) == 0 {
return nil, nil
}
out := make([]Match, 0, len(hits))
for _, h := range hits {
// Prefer a "@match" capture as the row's anchor; fall back
// to the largest captured node when the user didn't tag
// one. This matches ast-grep's convention without making
// every detector spell @match explicitly.
anchor := pickAnchor(h.Captures)
if anchor == nil {
continue
}
text := truncateRune(anchor.Text, opts.MaxMatchText)
caps := make(map[string]string, len(h.Captures))
for name, cn := range h.Captures {
if name == "match" {
continue
}
caps[name] = truncateRune(cn.Text, opts.MaxMatchText)
}
m := Match{
File: t.GraphPath,
Line: anchor.StartLine + 1,
EndLine: anchor.EndLine + 1,
Column: anchor.StartCol,
EndCol: anchor.EndCol,
Language: t.Language,
Text: text,
Captures: caps,
}
if p.detector != nil {
m.Detector = p.detector.Name
m.Severity = p.detector.Severity
if p.detector.PostFilter != nil && !p.detector.PostFilter(h, src) {
continue
}
}
if opts.SymbolLookup != nil {
m.SymbolID, m.SymbolName = opts.SymbolLookup(t.GraphPath, m.Line)
}
out = append(out, m)
}
return out, nil
}
// pickAnchor selects the most useful capture as the result's anchor.
// Priority: explicit `@match` capture > longest captured span. The
// fallback is important for detectors that only define semantic
// captures and never an explicit `@match`.
func pickAnchor(caps map[string]*parser.CapturedNode) *parser.CapturedNode {
if m, ok := caps["match"]; ok {
return m
}
var best *parser.CapturedNode
bestLen := -1
for _, c := range caps {
l := len(c.Text)
if l > bestLen {
best = c
bestLen = l
}
}
return best
}
func readBoundedFile(path string, maxBytes int64) ([]byte, error) {
f, err := os.Open(path)
if err != nil {
return nil, err
}
defer f.Close()
st, err := f.Stat()
if err != nil {
return nil, err
}
if st.Size() > maxBytes {
return nil, fmt.Errorf("file too large (%d bytes; cap %d)", st.Size(), maxBytes)
}
buf := make([]byte, st.Size())
if _, err := f.Read(buf); err != nil && st.Size() != 0 {
return nil, err
}
return buf, nil
}
// truncateRune cuts s to the first n bytes that fall on a UTF-8
// boundary. Appends "…" when truncation actually fired so consumers
// don't mistake a snipped result for a complete one.
func truncateRune(s string, n int) string {
if len(s) <= n {
return s
}
// Walk back to a rune start.
for n > 0 && (s[n]&0xC0) == 0x80 {
n--
}
return s[:n] + "…"
}
// RunOnSource executes one query against in-memory source bytes
// without touching the filesystem. Useful for unit tests, for the
// "lint this buffer before commit" UX, and for any caller that
// already has the bytes in hand. The function builds a Plan with a
// single synthetic Target whose GraphPath equals filePath; pass an
// empty filePath when the caller doesn't care.
//
// Limit / MaxMatchText / ExcludeTests are honored. SymbolLookup is
// honored too so a caller threading a graph in can still get
// enclosing-symbol enrichment for buffer-side lints.
func RunOnSource(ctx context.Context, opts Options, filePath, language string, src []byte) (Result, error) {
if opts.Pattern == "" && opts.Detector == "" {
return Result{}, errNoQuery
}
if opts.Resolver == nil {
opts.Resolver = DefaultLanguageResolver
}
if opts.Limit <= 0 {
opts.Limit = defaultLimit
}
if opts.MaxMatchText <= 0 {
opts.MaxMatchText = defaultMaxMatchText
}
target := Target{
AbsPath: filePath,
GraphPath: filePath,
Language: language,
}
opts.Targets = []Target{target}
plan, err := buildPlan(opts)
if err != nil {
return Result{}, err
}
defer plan.close()
matches, runErr := plan.runBytes(ctx, target, opts, src)
out := Result{FilesWalked: 1}
if runErr != nil {
out.Errors = []string{fmt.Sprintf("%s: %v", filePath, runErr)}
}
out.Matches = matches
out.Total = len(matches)
if out.Total > opts.Limit {
out.Matches = out.Matches[:opts.Limit]
out.Truncated = true
}
return out, nil
}
// IsTestPath returns true when the file path looks like a test under
// any of Gortex's recognised conventions. The list is deliberately
// conservative; false negatives (a test we don't recognise) ship
// matches that the agent can drop, which is much better than false
// positives (skipping production code that happens to have "test" in
// the name).
func IsTestPath(absPath string) bool {
base := filepath.Base(absPath)
dir := filepath.ToSlash(filepath.Dir(absPath))
switch {
case strings.HasSuffix(base, "_test.go"):
return true
case strings.HasSuffix(base, ".test.ts"), strings.HasSuffix(base, ".test.tsx"),
strings.HasSuffix(base, ".test.js"), strings.HasSuffix(base, ".test.jsx"),
strings.HasSuffix(base, ".spec.ts"), strings.HasSuffix(base, ".spec.tsx"),
strings.HasSuffix(base, ".spec.js"), strings.HasSuffix(base, ".spec.jsx"):
return true
case strings.HasPrefix(base, "test_") && strings.HasSuffix(base, ".py"):
return true
case strings.HasSuffix(base, "_test.py"):
return true
case strings.HasSuffix(base, "_test.rb"), strings.HasSuffix(base, "_spec.rb"):
return true
case strings.HasSuffix(base, "Test.java"), strings.HasSuffix(base, "Tests.java"),
strings.HasSuffix(base, "IT.java"):
return true
case strings.Contains(dir, "/__tests__/") || strings.HasSuffix(dir, "/__tests__"):
return true
case strings.Contains(dir, "/test/") || strings.Contains(dir, "/tests/") || strings.Contains(dir, "/spec/"):
return true
}
return false
}
// Compile-time checks: the runQuery hot path takes a *parser.PreparedQuery
// pointer; ensure we never lose the reference and force-close it under
// us. The plan owns every query and closes them in plan.close().
var _ = parser.RunPrepared