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
418 lines
14 KiB
Go
418 lines
14 KiB
Go
package mcp
|
|
|
|
import (
|
|
"strings"
|
|
|
|
"github.com/mark3labs/mcp-go/mcp"
|
|
|
|
"github.com/zzet/gortex/internal/graph"
|
|
)
|
|
|
|
// fieldQuery is a search query split into its free-text component and
|
|
// any field-qualified clauses (kind: / lang: / path: / repo: /
|
|
// project:) lifted out of the raw query string.
|
|
type fieldQuery struct {
|
|
Text string // residual free text after clauses are removed
|
|
Kind string // kind: clause — comma-separated node kinds
|
|
Flavor string // flavor: clause — comma-separated structural flavors
|
|
Lang string // lang: / language: clause — node language
|
|
Path string // path: clause — file-path substring
|
|
Repo string // repo: clause — repository prefix
|
|
Project string // project: clause — project slug
|
|
Name string // name: clause — symbol-name substring post-filter
|
|
}
|
|
|
|
// hasFieldFilters reports whether any post-filter clause (kind / lang
|
|
// / path / repo) was supplied. project: is excluded — it merges into
|
|
// the query scope rather than acting as a post-filter.
|
|
func (fq fieldQuery) hasFieldFilters() bool {
|
|
repo := strings.TrimSpace(fq.Repo)
|
|
return fq.Kind != "" || fq.Flavor != "" || fq.Lang != "" || fq.Path != "" || (repo != "" && repo != "*") || fq.Name != ""
|
|
}
|
|
|
|
// parseFieldQuery splits a raw search string into its free text and
|
|
// field-qualified clauses. A whitespace-delimited token of the form
|
|
// `field:value` is lifted into a clause when `field` is one of the
|
|
// recognised names (kind, lang/language, path, repo, project) and the
|
|
// value is non-empty; every other token — including identifiers that
|
|
// merely contain a colon, such as `pkg::Type` or a URL — stays in the
|
|
// free text verbatim. Field names are case-insensitive; a field that
|
|
// appears more than once keeps the last value.
|
|
func parseFieldQuery(raw string) fieldQuery {
|
|
var fq fieldQuery
|
|
var text []string
|
|
for _, tok := range strings.Fields(raw) {
|
|
name, value, ok := strings.Cut(tok, ":")
|
|
if !ok || value == "" {
|
|
text = append(text, tok)
|
|
continue
|
|
}
|
|
switch strings.ToLower(name) {
|
|
case "kind":
|
|
fq.Kind = value
|
|
case "flavor":
|
|
fq.Flavor = value
|
|
case "lang", "language":
|
|
fq.Lang = value
|
|
case "path":
|
|
fq.Path = value
|
|
case "repo":
|
|
fq.Repo = value
|
|
case "project":
|
|
fq.Project = value
|
|
case "name":
|
|
fq.Name = value
|
|
default:
|
|
text = append(text, tok)
|
|
}
|
|
}
|
|
fq.Text = strings.Join(text, " ")
|
|
return fq
|
|
}
|
|
|
|
// requestWithInlineScopeClauses returns a request whose repo/project args
|
|
// include inline field-query scope clauses when the corresponding explicit
|
|
// request arg is absent. The original request is left untouched.
|
|
func requestWithInlineScopeClauses(req mcp.CallToolRequest, fq fieldQuery) mcp.CallToolRequest {
|
|
inlineRepo := strings.TrimSpace(fq.Repo)
|
|
inlineProject := strings.TrimSpace(fq.Project)
|
|
if inlineRepo == "" && inlineProject == "" {
|
|
return req
|
|
}
|
|
|
|
repoArg := strings.TrimSpace(req.GetString("repo", ""))
|
|
projectArg := strings.TrimSpace(req.GetString("project", ""))
|
|
if (repoArg != "" || inlineRepo == "") && (projectArg != "" || inlineProject == "") {
|
|
return req
|
|
}
|
|
|
|
merged := req
|
|
args := make(map[string]any, len(req.GetArguments())+2)
|
|
for k, v := range req.GetArguments() {
|
|
args[k] = v
|
|
}
|
|
if repoArg == "" && inlineRepo != "" {
|
|
args["repo"] = inlineRepo
|
|
}
|
|
if projectArg == "" && inlineProject != "" {
|
|
args["project"] = inlineProject
|
|
}
|
|
merged.Params.Arguments = args
|
|
return merged
|
|
}
|
|
|
|
// normalizeLang folds the common short language aliases (ts, js, py,
|
|
// …) onto the canonical language names the indexer stamps on nodes.
|
|
// An unrecognised value is returned lowercased and trimmed.
|
|
func normalizeLang(l string) string {
|
|
switch v := strings.ToLower(strings.TrimSpace(l)); v {
|
|
case "ts":
|
|
return "typescript"
|
|
case "js":
|
|
return "javascript"
|
|
case "py":
|
|
return "python"
|
|
case "rb":
|
|
return "ruby"
|
|
case "rs":
|
|
return "rust"
|
|
case "kt":
|
|
return "kotlin"
|
|
case "yml":
|
|
return "yaml"
|
|
default:
|
|
return v
|
|
}
|
|
}
|
|
|
|
// applyFieldFilters narrows a node slice by the lang / path / repo
|
|
// clauses of a field query. The kind clause is applied separately via
|
|
// filterNodesByKind so it can merge with the explicit kind argument.
|
|
// Language matching is exact (after alias folding); path matches as a
|
|
// case-insensitive substring of the node's file path; repo matches
|
|
// the node's repository prefix exactly. Empty clauses are skipped, and
|
|
// a node with no repo prefix (single-repo mode) is never dropped by a
|
|
// repo clause — mirroring filterNodes.
|
|
func applyFieldFilters(nodes []*graph.Node, fq fieldQuery) []*graph.Node {
|
|
lang := normalizeLang(fq.Lang)
|
|
path := strings.ToLower(strings.TrimSpace(fq.Path))
|
|
repo := strings.TrimSpace(fq.Repo)
|
|
if repo == "*" {
|
|
repo = ""
|
|
}
|
|
name := strings.ToLower(strings.TrimSpace(fq.Name))
|
|
if lang == "" && path == "" && repo == "" && name == "" {
|
|
return nodes
|
|
}
|
|
out := make([]*graph.Node, 0, len(nodes))
|
|
for _, n := range nodes {
|
|
if lang != "" && strings.ToLower(n.Language) != lang {
|
|
continue
|
|
}
|
|
if path != "" && !strings.Contains(strings.ToLower(n.FilePath), path) {
|
|
continue
|
|
}
|
|
if repo != "" && n.RepoPrefix != "" && n.RepoPrefix != repo {
|
|
continue
|
|
}
|
|
// name: is a case-insensitive substring post-filter on the symbol's
|
|
// own name — narrows "search for X but only nodes whose name contains Y".
|
|
if name != "" && !strings.Contains(strings.ToLower(n.Name), name) {
|
|
continue
|
|
}
|
|
out = append(out, n)
|
|
}
|
|
return out
|
|
}
|
|
|
|
// applyPathFilter narrows a node slice to those whose file path sits
|
|
// under one of the given sub-paths. Unlike the inline `path:` clause
|
|
// (a loose substring match via applyFieldFilters), this is an
|
|
// ANCHORED, slash-segment-normalised prefix test: the path
|
|
// "services/billing" matches "services/billing/invoice.go" but NOT
|
|
// "other/services/billingX/y.go" -- the prefix must align on a
|
|
// directory boundary at the start of the path.
|
|
//
|
|
// In multi-repo mode a node's FilePath is repo-prefixed
|
|
// ("<repo>/services/billing/x.go"); the repo prefix is stripped
|
|
// before matching so a sub-path is expressed relative to the repo
|
|
// root regardless of repo mode.
|
|
//
|
|
// An empty paths slice is a no-op (every node passes). A node passes
|
|
// when it matches ANY of the paths.
|
|
func applyPathFilter(nodes []*graph.Node, paths []string) []*graph.Node {
|
|
norm := normalizePathPrefixes(paths)
|
|
if len(norm) == 0 {
|
|
return nodes
|
|
}
|
|
out := make([]*graph.Node, 0, len(nodes))
|
|
for _, n := range nodes {
|
|
if pathMatchesAnyPrefix(repoRelativePath(n), norm) {
|
|
out = append(out, n)
|
|
}
|
|
}
|
|
return out
|
|
}
|
|
|
|
// repoRelativePath returns a node's file path with its repo prefix
|
|
// stripped and back-slashes normalised to forward slashes, so a
|
|
// sub-path filter is always expressed relative to the repo root.
|
|
func repoRelativePath(n *graph.Node) string {
|
|
p := strings.ReplaceAll(n.FilePath, "\\", "/")
|
|
if n.RepoPrefix != "" {
|
|
p = strings.TrimPrefix(p, n.RepoPrefix+"/")
|
|
}
|
|
return p
|
|
}
|
|
|
|
// normalizePathPrefixes cleans a set of sub-path filters: trims
|
|
// whitespace, normalises separators, strips a leading "./" and any
|
|
// leading/trailing slashes, and drops empties and duplicates.
|
|
func normalizePathPrefixes(paths []string) []string {
|
|
var out []string
|
|
seen := map[string]struct{}{}
|
|
for _, p := range paths {
|
|
p = strings.ReplaceAll(strings.TrimSpace(p), "\\", "/")
|
|
p = strings.TrimPrefix(p, "./")
|
|
p = strings.Trim(p, "/")
|
|
if p == "" {
|
|
continue
|
|
}
|
|
if _, dup := seen[p]; dup {
|
|
continue
|
|
}
|
|
seen[p] = struct{}{}
|
|
out = append(out, p)
|
|
}
|
|
return out
|
|
}
|
|
|
|
// pathMatchesAnyPrefix reports whether a repo-relative file path sits
|
|
// under any of the (already-normalised) sub-path prefixes. A prefix
|
|
// matches when the path equals it exactly or continues past it at a
|
|
// slash boundary -- so "services/billing" matches
|
|
// "services/billing/x.go" but not "services/billingX/x.go".
|
|
func pathMatchesAnyPrefix(path string, prefixes []string) bool {
|
|
for _, pre := range prefixes {
|
|
if path == pre {
|
|
return true
|
|
}
|
|
if strings.HasPrefix(path, pre+"/") {
|
|
return true
|
|
}
|
|
}
|
|
return false
|
|
}
|
|
|
|
// expandPathPrefixesWithRepos returns the normalised sub-path prefixes
|
|
// plus, for every non-empty repo prefix, the prefix-qualified form
|
|
// (<repoPrefix>/<subpath>). In multi-repo mode trigram match paths carry
|
|
// a repo prefix (gortex/internal/...) while callers pass repo-relative
|
|
// sub-paths (internal/...); expanding the filter lets the repo-relative
|
|
// form still match without loosening the anchored, segment-boundary
|
|
// matching of pathMatchesAnyPrefix. Returns norm unchanged when there
|
|
// are no repo prefixes (single-repo mode, where paths are unprefixed).
|
|
func expandPathPrefixesWithRepos(norm, repoPrefixes []string) []string {
|
|
if len(repoPrefixes) == 0 {
|
|
return norm
|
|
}
|
|
out := make([]string, 0, len(norm)*(1+len(repoPrefixes)))
|
|
for _, p := range norm {
|
|
out = append(out, p)
|
|
for _, rp := range repoPrefixes {
|
|
if rp = strings.Trim(rp, "/"); rp != "" {
|
|
out = append(out, rp+"/"+p)
|
|
}
|
|
}
|
|
}
|
|
return out
|
|
}
|
|
|
|
// resolvePathFilter collects the sub-path filters that apply to a
|
|
// search request, from three additive sources:
|
|
//
|
|
// - the explicit `path` request argument (comma-separated),
|
|
// - the inline `path:` clause already lifted into fq.Path,
|
|
// - the Paths of any `scope:`-named SavedScope.
|
|
//
|
|
// It is the path-scoping sibling of resolveRepoFilter -- a separate
|
|
// function rather than a signature change, so resolveRepoFilter's
|
|
// existing callers are untouched. The returned slice is the union of
|
|
// all three sources (deduplication happens downstream in
|
|
// applyPathFilter); an empty slice means "no sub-path filter".
|
|
//
|
|
// fq may be the zero fieldQuery when the caller has no inline clause.
|
|
func (s *Server) resolvePathFilter(req mcp.CallToolRequest, fq fieldQuery) []string {
|
|
var paths []string
|
|
for _, p := range strings.Split(req.GetString("path", ""), ",") {
|
|
if p = strings.TrimSpace(p); p != "" {
|
|
paths = append(paths, p)
|
|
}
|
|
}
|
|
if p := strings.TrimSpace(fq.Path); p != "" {
|
|
paths = append(paths, p)
|
|
}
|
|
if scopeArg := strings.TrimSpace(req.GetString("scope", "")); scopeArg != "" {
|
|
if sc, ok := s.lookupScope(scopeArg); ok {
|
|
paths = append(paths, sc.Paths...)
|
|
}
|
|
}
|
|
return paths
|
|
}
|
|
|
|
// knownFlavorValues is the closed structural-flavor vocabulary that
|
|
// producers stamp onto Meta["type_flavor"] (plus the cross-key
|
|
// "component" bridge). It is what the codegraph-compat shim consults
|
|
// to tell a flavor value apart from a real node kind.
|
|
var knownFlavorValues = map[string]struct{}{
|
|
"class": {}, "struct": {}, "enum": {}, "interface": {}, "trait": {},
|
|
"protocol": {}, "object": {}, "record": {}, "type_alias": {}, "newtype": {},
|
|
"anonymous_class": {}, "component": {}, "message": {}, "service": {},
|
|
"table": {}, "view": {}, "module": {}, "signature": {}, "type_def": {},
|
|
"instance": {}, "hook": {}, "play": {}, "typedef": {},
|
|
}
|
|
|
|
// isKnownFlavor reports whether v (case-insensitive) is in the closed
|
|
// flavor vocabulary.
|
|
func isKnownFlavor(v string) bool {
|
|
_, ok := knownFlavorValues[strings.ToLower(strings.TrimSpace(v))]
|
|
return ok
|
|
}
|
|
|
|
// splitFlavors splits a comma-separated flavor argument into its
|
|
// non-empty, trimmed values (the OR set).
|
|
func splitFlavors(arg string) []string {
|
|
var out []string
|
|
for _, f := range strings.Split(arg, ",") {
|
|
if f = strings.TrimSpace(f); f != "" {
|
|
out = append(out, f)
|
|
}
|
|
}
|
|
return out
|
|
}
|
|
|
|
// nodeMatchesFlavor reports whether a node matches any of the given
|
|
// flavor values (case-insensitive on both sides). The value
|
|
// "component" is a special bridging sentinel: it matches any node
|
|
// carrying a non-empty Meta["ui_component"] OR a
|
|
// Meta["type_flavor"]=="component" — so a React function component
|
|
// (KindFunction, ui_component=react, no type_flavor) and a Svelte SFC
|
|
// type (type_flavor=component) both match. Every other value is an
|
|
// exact, case-insensitive match against Meta["type_flavor"].
|
|
func nodeMatchesFlavor(n *graph.Node, flavors []string) bool {
|
|
if n == nil || n.Meta == nil {
|
|
return false
|
|
}
|
|
tf, _ := n.Meta["type_flavor"].(string)
|
|
uc, _ := n.Meta["ui_component"].(string)
|
|
return flavorMatchesResolved(tf, uc, flavors)
|
|
}
|
|
|
|
// flavorMatchesResolved is the matcher core shared by nodeMatchesFlavor
|
|
// (type_flavor + ui_component read off one node) and the find_usages
|
|
// owner-resolution path (type flavor read off the FROM node's enclosing
|
|
// owner type, ui_component off the FROM node itself). Both values are
|
|
// matched case-insensitively; "component" is the cross-key bridge.
|
|
func flavorMatchesResolved(typeFlavor, uiComponent string, flavors []string) bool {
|
|
tf := strings.ToLower(typeFlavor)
|
|
for _, f := range flavors {
|
|
f = strings.ToLower(strings.TrimSpace(f))
|
|
if f == "" {
|
|
continue
|
|
}
|
|
if f == "component" {
|
|
if uiComponent != "" || tf == "component" {
|
|
return true
|
|
}
|
|
continue
|
|
}
|
|
if tf == f {
|
|
return true
|
|
}
|
|
}
|
|
return false
|
|
}
|
|
|
|
// applyFlavorFilter narrows a node slice to those whose structural
|
|
// flavor matches the comma-separated flavor argument (union / OR). An
|
|
// empty argument is a no-op.
|
|
func applyFlavorFilter(nodes []*graph.Node, flavorArg string) []*graph.Node {
|
|
flavors := splitFlavors(flavorArg)
|
|
if len(flavors) == 0 {
|
|
return nodes
|
|
}
|
|
out := make([]*graph.Node, 0, len(nodes))
|
|
for _, n := range nodes {
|
|
if nodeMatchesFlavor(n, flavors) {
|
|
out = append(out, n)
|
|
}
|
|
}
|
|
return out
|
|
}
|
|
|
|
// reclassifyKindFlavor is the codegraph-compatibility shim. codegraph
|
|
// exposes structural kinds as kind:class|struct|enum|component, but
|
|
// those are flavor values, not gortex node kinds — a bare
|
|
// filterNodesByKind on them returns empty. This splits a
|
|
// comma-separated kind argument into the values that are real node
|
|
// kinds (returned as kinds) and the values that are flavor-only
|
|
// (returned as flavors), so the caller can route each to the right
|
|
// filter. A value that is both a kind and a flavor (interface, table,
|
|
// module) keeps its node-kind meaning. Returns the original kindArg
|
|
// and an empty flavors when nothing reclassifies.
|
|
func reclassifyKindFlavor(kindArg string) (kinds string, flavors string) {
|
|
var ks, fs []string
|
|
for _, v := range strings.Split(kindArg, ",") {
|
|
t := strings.TrimSpace(v)
|
|
if t == "" {
|
|
continue
|
|
}
|
|
if !graph.IsValidNodeKind(strings.ToLower(t)) && isKnownFlavor(t) {
|
|
fs = append(fs, t)
|
|
} else {
|
|
ks = append(ks, t)
|
|
}
|
|
}
|
|
return strings.Join(ks, ","), strings.Join(fs, ",")
|
|
}
|