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

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, ",")
}