chore: import upstream snapshot with attribution
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
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
This commit is contained in:
@@ -0,0 +1,54 @@
|
||||
// Package excludes provides a unified path-exclusion matcher used by
|
||||
// both the indexer's initial walk and the watcher's live event filter.
|
||||
// Patterns follow .gitignore semantics (via go-gitignore): leading '/'
|
||||
// anchors at the root, trailing '/' restricts to directories, '!' negates,
|
||||
// '**' matches any number of path segments.
|
||||
package excludes
|
||||
|
||||
// Builtin is the superset of directory/file patterns that Gortex always
|
||||
// excludes, regardless of user config. It merges what the indexer and
|
||||
// watcher used to maintain as two divergent hardcoded lists.
|
||||
//
|
||||
// Users can re-include an entry by listing "!pattern" in global,
|
||||
// RepoEntry, or workspace config.
|
||||
var Builtin = []string{
|
||||
".git/",
|
||||
".hg/",
|
||||
".svn/",
|
||||
".terraform/",
|
||||
".gortex-cache/",
|
||||
".gortex/", // Gortex's per-repo state dir (quarantine, merkle tree)
|
||||
".claude/",
|
||||
".kiro/",
|
||||
"node_modules/",
|
||||
"vendor/",
|
||||
".venv/",
|
||||
"venv/",
|
||||
"__pycache__/",
|
||||
".mypy_cache/",
|
||||
".tox/",
|
||||
".next/",
|
||||
"target/",
|
||||
"build/",
|
||||
"dist/",
|
||||
// Package-manager + build dirs for non-JS/non-Go ecosystems. These
|
||||
// are indexed-by-default without these entries, which pollutes the
|
||||
// graph with upstream code (e.g. CocoaPods' sqlite3.c — 150k+ lines)
|
||||
// that users can't act on. Names are unambiguous — no first-party
|
||||
// project uses `Pods/` or `.dart_tool/` for its own source.
|
||||
"Pods/", // CocoaPods (iOS/macOS)
|
||||
".gradle/", // Gradle build cache (Android/JVM)
|
||||
".bundle/", // Ruby Bundler cache
|
||||
".dart_tool/", // Dart/Flutter build cache
|
||||
".pub-cache/", // Dart global pub cache, occasionally vendored
|
||||
"*.tmp",
|
||||
// Editor scratch/backup files. Vim cycles swap suffixes backward
|
||||
// through the alphabet (.swp → .swo → .swn → ...); neovim writes a
|
||||
// .swpx auxiliary alongside .swp on some platforms.
|
||||
"*.swp",
|
||||
"*.swo",
|
||||
"*.swn",
|
||||
"*.swm",
|
||||
"*.swpx",
|
||||
"*~",
|
||||
}
|
||||
@@ -0,0 +1,170 @@
|
||||
package excludes
|
||||
|
||||
import (
|
||||
"path"
|
||||
"strings"
|
||||
)
|
||||
|
||||
// generatedSuffixes lists file-name suffixes that mark generated code
|
||||
// across the languages Gortex indexes. Kept here (a leaf package) so
|
||||
// both the MCP response-envelope notes and the search rerank pipeline
|
||||
// share one source of truth without an import cycle.
|
||||
var generatedSuffixes = []string{
|
||||
// Protobuf / gRPC stubs.
|
||||
".pb.go", ".pb.cc", ".pb.h", ".pb.swift",
|
||||
"_pb2.py", "_pb2_grpc.py", "_pb2.pyi",
|
||||
"_pb.ts", "_pb.js", "_grpc_pb.ts", "_grpc_pb.js",
|
||||
// Go generators.
|
||||
"_gen.go", ".gen.go", "_generated.go", ".generated.go",
|
||||
// TS / JS generators.
|
||||
".generated.ts", ".generated.tsx", ".generated.js", ".generated.jsx",
|
||||
".gen.ts", ".gen.tsx", ".gen.js", ".gen.jsx",
|
||||
// Rust.
|
||||
".generated.rs",
|
||||
// Dart generators (build_runner, freezed, protobuf, chopper, auto_route, …).
|
||||
".g.dart", ".freezed.dart", ".pb.dart", ".pbgrpc.dart", ".pbenum.dart",
|
||||
".pbjson.dart", ".pbserver.dart", ".chopper.dart", ".config.dart", ".gr.dart",
|
||||
// C#.
|
||||
".g.cs", ".designer.cs",
|
||||
}
|
||||
|
||||
// IsGenerated reports whether a file name matches a common
|
||||
// code-generation convention — protobuf stubs, *_gen.go, mocks,
|
||||
// Kubernetes zz_generated deepcopy, Dart/C# generators, and friends.
|
||||
// Edits to such files are overwritten by their generator, so callers
|
||||
// (omission notes, retrieval ranking) treat them as second-class.
|
||||
func IsGenerated(p string) bool {
|
||||
if p == "" {
|
||||
return false
|
||||
}
|
||||
base := strings.ToLower(path.Base(filepathToSlash(p)))
|
||||
for _, suf := range generatedSuffixes {
|
||||
if strings.HasSuffix(base, suf) {
|
||||
return true
|
||||
}
|
||||
}
|
||||
if strings.HasPrefix(base, "zz_generated") {
|
||||
return true
|
||||
}
|
||||
if strings.HasSuffix(base, ".go") &&
|
||||
(strings.HasPrefix(base, "mock_") || strings.HasSuffix(base, "_mock.go") ||
|
||||
strings.HasSuffix(base, "_mocks.go") || strings.HasSuffix(base, ".pulsar.go")) {
|
||||
return true
|
||||
}
|
||||
// Java protobuf / gRPC generated classes: FooOuterClass.java, FooGrpc.java.
|
||||
if strings.HasSuffix(base, "grpc.java") || strings.HasSuffix(base, "outerclass.java") {
|
||||
return true
|
||||
}
|
||||
// Minified bundles: not generated-from-source, but "don't edit, don't rank
|
||||
// highly" the same way — codegraph groups them here.
|
||||
if strings.HasSuffix(base, ".min.js") || strings.HasSuffix(base, ".min.mjs") {
|
||||
return true
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
// GeneratedPeerPaths returns the plausible hand-written peer file
|
||||
// paths a generated file shadows — the "same-named implementation"
|
||||
// gate the retrieval ranker uses before down-ranking a generated
|
||||
// file. For foo.pb.go the peer is foo.go; for mock_user.go it is
|
||||
// user.go; for user_pb2.py it is user.py.
|
||||
//
|
||||
// Returns nil when no clean peer name can be derived (e.g.
|
||||
// zz_generated.deepcopy.go has no same-named hand-written twin). A
|
||||
// nil result means "do not gate" — i.e. leave the generated file
|
||||
// un-penalised, which is the safe default: a generated file that is
|
||||
// the only definition should not be demoted into oblivion.
|
||||
func GeneratedPeerPaths(p string) []string {
|
||||
if p == "" {
|
||||
return nil
|
||||
}
|
||||
norm := filepathToSlash(p)
|
||||
dir := path.Dir(norm)
|
||||
base := path.Base(norm)
|
||||
lower := strings.ToLower(base)
|
||||
|
||||
join := func(name string) string {
|
||||
if dir == "." || dir == "" {
|
||||
return name
|
||||
}
|
||||
return dir + "/" + name
|
||||
}
|
||||
|
||||
// Suffix markers: strip the generated marker, swap in the
|
||||
// hand-written extension. Ordered longest-first so _pb2_grpc.py
|
||||
// wins over _pb2.py and .designer.cs over .cs.
|
||||
suffixRules := []struct{ suf, ext string }{
|
||||
// Python.
|
||||
{"_pb2_grpc.py", ".py"},
|
||||
{"_pb2.pyi", ".py"},
|
||||
{"_pb2.py", ".py"},
|
||||
// Go.
|
||||
{".pb.go", ".go"},
|
||||
{"_generated.go", ".go"},
|
||||
{".generated.go", ".go"},
|
||||
{"_gen.go", ".go"},
|
||||
{".gen.go", ".go"},
|
||||
{"_mocks.go", ".go"},
|
||||
{"_mock.go", ".go"},
|
||||
{".pulsar.go", ".go"},
|
||||
// TS / JS — `_grpc_pb` before `_pb`, longer `.generated`/`.gen` first.
|
||||
{"_grpc_pb.ts", ".ts"},
|
||||
{"_grpc_pb.js", ".js"},
|
||||
{"_pb.ts", ".ts"},
|
||||
{"_pb.js", ".js"},
|
||||
{".generated.tsx", ".tsx"},
|
||||
{".generated.jsx", ".jsx"},
|
||||
{".generated.ts", ".ts"},
|
||||
{".generated.js", ".js"},
|
||||
{".gen.tsx", ".tsx"},
|
||||
{".gen.jsx", ".jsx"},
|
||||
{".gen.ts", ".ts"},
|
||||
{".gen.js", ".js"},
|
||||
{".min.mjs", ".mjs"},
|
||||
{".min.js", ".js"},
|
||||
// Rust.
|
||||
{".generated.rs", ".rs"},
|
||||
// Dart.
|
||||
{".pbserver.dart", ".dart"},
|
||||
{".pbgrpc.dart", ".dart"},
|
||||
{".pbenum.dart", ".dart"},
|
||||
{".pbjson.dart", ".dart"},
|
||||
{".chopper.dart", ".dart"},
|
||||
{".freezed.dart", ".dart"},
|
||||
{".config.dart", ".dart"},
|
||||
{".pb.dart", ".dart"},
|
||||
{".gr.dart", ".dart"},
|
||||
{".g.dart", ".dart"},
|
||||
// C#.
|
||||
{".designer.cs", ".cs"},
|
||||
{".g.cs", ".cs"},
|
||||
// Java generated classes (no separator before the marker).
|
||||
{"grpc.java", ".java"},
|
||||
{"outerclass.java", ".java"},
|
||||
}
|
||||
for _, r := range suffixRules {
|
||||
if strings.HasSuffix(lower, r.suf) {
|
||||
stem := base[:len(base)-len(r.suf)]
|
||||
if stem == "" {
|
||||
return nil
|
||||
}
|
||||
return []string{join(stem + r.ext)}
|
||||
}
|
||||
}
|
||||
|
||||
// Prefix marker: mock_user.go shadows user.go.
|
||||
if strings.HasPrefix(lower, "mock_") && strings.HasSuffix(lower, ".go") {
|
||||
rest := base[len("mock_"):]
|
||||
if rest != "" && rest != ".go" {
|
||||
return []string{join(rest)}
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// filepathToSlash normalises backslashes to forward slashes without
|
||||
// pulling in path/filepath (which would make the leaf package
|
||||
// OS-aware). Graph paths are always stored forward-slashed.
|
||||
func filepathToSlash(p string) string {
|
||||
return strings.ReplaceAll(p, "\\", "/")
|
||||
}
|
||||
@@ -0,0 +1,112 @@
|
||||
package excludes
|
||||
|
||||
import (
|
||||
"reflect"
|
||||
"testing"
|
||||
)
|
||||
|
||||
func TestIsGenerated(t *testing.T) {
|
||||
cases := []struct {
|
||||
path string
|
||||
want bool
|
||||
}{
|
||||
{"api/user.pb.go", true},
|
||||
{"api/user.pb.cc", true},
|
||||
{"api/user.pb.h", true},
|
||||
{"api/user.pb.swift", true},
|
||||
{"proto/user_pb2.py", true},
|
||||
{"proto/user_pb2_grpc.py", true},
|
||||
{"x_gen.go", true},
|
||||
{"x.gen.go", true},
|
||||
{"x_generated.go", true},
|
||||
{"x.generated.go", true},
|
||||
{"model.g.dart", true},
|
||||
{"model.freezed.dart", true},
|
||||
{"View.g.cs", true},
|
||||
{"View.designer.cs", true},
|
||||
{"zz_generated.deepcopy.go", true},
|
||||
{"store/mock_store.go", true},
|
||||
{"store/store_mock.go", true},
|
||||
// Go — additional generators.
|
||||
{"store/store_mocks.go", true},
|
||||
{"topic.pulsar.go", true},
|
||||
{"api/user_grpc.pb.go", true},
|
||||
// TS / JS.
|
||||
{"src/api.generated.ts", true},
|
||||
{"src/api.generated.tsx", true},
|
||||
{"src/api.gen.js", true},
|
||||
{"src/api.gen.jsx", true},
|
||||
{"proto/svc_pb.ts", true},
|
||||
{"proto/svc_grpc_pb.js", true},
|
||||
{"dist/bundle.min.js", true},
|
||||
{"dist/bundle.min.mjs", true},
|
||||
// Rust / Python.
|
||||
{"src/schema.generated.rs", true},
|
||||
{"proto/user_pb2.pyi", true},
|
||||
// Dart.
|
||||
{"m.pb.dart", true},
|
||||
{"m.pbgrpc.dart", true},
|
||||
{"m.chopper.dart", true},
|
||||
{"m.gr.dart", true},
|
||||
{"m.config.dart", true},
|
||||
// Java.
|
||||
{"pkg/UserServiceGrpc.java", true},
|
||||
{"pkg/UserOuterClass.java", true},
|
||||
// Windows separators normalise.
|
||||
{`api\user.pb.go`, true},
|
||||
// Negatives.
|
||||
{"api/user.go", false},
|
||||
{"store/store.go", false},
|
||||
{"genuine.go", false}, // "gen" prefix is not a marker
|
||||
{"dist/app.js", false},
|
||||
{"src/UserService.java", false},
|
||||
{"", false},
|
||||
}
|
||||
for _, c := range cases {
|
||||
if got := IsGenerated(c.path); got != c.want {
|
||||
t.Errorf("IsGenerated(%q) = %v, want %v", c.path, got, c.want)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestGeneratedPeerPaths(t *testing.T) {
|
||||
cases := []struct {
|
||||
path string
|
||||
want []string
|
||||
}{
|
||||
{"api/user.pb.go", []string{"api/user.go"}},
|
||||
{"proto/user_pb2.py", []string{"proto/user.py"}},
|
||||
{"proto/user_pb2_grpc.py", []string{"proto/user.py"}},
|
||||
{"x_gen.go", []string{"x.go"}},
|
||||
{"x.generated.go", []string{"x.go"}},
|
||||
{"model.freezed.dart", []string{"model.dart"}},
|
||||
{"View.designer.cs", []string{"View.cs"}},
|
||||
{"store/mock_store.go", []string{"store/store.go"}},
|
||||
{"store/store_mock.go", []string{"store/store.go"}},
|
||||
// New suffixes derive their hand-written peer.
|
||||
{"store/store_mocks.go", []string{"store/store.go"}},
|
||||
{"topic.pulsar.go", []string{"topic.go"}},
|
||||
{"src/api.generated.ts", []string{"src/api.ts"}},
|
||||
{"src/api.generated.tsx", []string{"src/api.tsx"}},
|
||||
{"src/api.gen.js", []string{"src/api.js"}},
|
||||
{"proto/svc_pb.ts", []string{"proto/svc.ts"}},
|
||||
{"proto/svc_grpc_pb.js", []string{"proto/svc.js"}},
|
||||
{"dist/bundle.min.js", []string{"dist/bundle.js"}},
|
||||
{"src/schema.generated.rs", []string{"src/schema.rs"}},
|
||||
{"proto/user_pb2.pyi", []string{"proto/user.py"}},
|
||||
{"m.pbgrpc.dart", []string{"m.dart"}},
|
||||
{"m.chopper.dart", []string{"m.dart"}},
|
||||
{"pkg/UserServiceGrpc.java", []string{"pkg/UserService.java"}},
|
||||
{"pkg/UserOuterClass.java", []string{"pkg/User.java"}},
|
||||
// No clean peer.
|
||||
{"zz_generated.deepcopy.go", nil},
|
||||
{"api/user.go", nil},
|
||||
{"", nil},
|
||||
}
|
||||
for _, c := range cases {
|
||||
got := GeneratedPeerPaths(c.path)
|
||||
if !reflect.DeepEqual(got, c.want) {
|
||||
t.Errorf("GeneratedPeerPaths(%q) = %v, want %v", c.path, got, c.want)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,185 @@
|
||||
package excludes
|
||||
|
||||
import (
|
||||
"bufio"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
"sync"
|
||||
)
|
||||
|
||||
// Hierarchical matches paths against per-directory ignore files
|
||||
// discovered along the chain from a repo root down to each path's
|
||||
// parent directory. Unlike the repo-root-only .gitignore handling, an
|
||||
// ignore file placed in any directory is honored, with its patterns
|
||||
// anchored at the directory that contains it — a pattern in
|
||||
// <root>/sub/.gortexignore constrains only paths under <root>/sub.
|
||||
//
|
||||
// Each directory's ignore files are read and compiled once, on first
|
||||
// request, and cached. A full index walk therefore pays one read per
|
||||
// directory regardless of how deep the tree is. Hierarchical is safe
|
||||
// for concurrent use.
|
||||
type Hierarchical struct {
|
||||
root string
|
||||
filenames []string
|
||||
|
||||
mu sync.RWMutex
|
||||
cache map[string]*Matcher // abs dir -> compiled matcher; nil value = directory has no ignore files
|
||||
}
|
||||
|
||||
// NewHierarchical builds a per-directory ignore matcher rooted at root.
|
||||
// filenames are the ignore-file basenames honored in every directory
|
||||
// (e.g. ".gortexignore"). An empty filename list yields a matcher that
|
||||
// excludes nothing.
|
||||
func NewHierarchical(root string, filenames ...string) *Hierarchical {
|
||||
if abs, err := filepath.Abs(root); err == nil {
|
||||
root = abs
|
||||
}
|
||||
return &Hierarchical{
|
||||
root: filepath.Clean(root),
|
||||
filenames: filenames,
|
||||
cache: make(map[string]*Matcher),
|
||||
}
|
||||
}
|
||||
|
||||
// Match reports whether an absolute path is excluded by an ignore file
|
||||
// in any ancestor directory between the root and the path's parent.
|
||||
// When isDir is true the path is treated as a directory, so trailing-
|
||||
// slash patterns prune the whole subtree. A path outside the root is
|
||||
// never excluded.
|
||||
func (h *Hierarchical) Match(absPath string, isDir bool) bool {
|
||||
if h == nil || len(h.filenames) == 0 {
|
||||
return false
|
||||
}
|
||||
absPath = filepath.Clean(absPath)
|
||||
rel, err := filepath.Rel(h.root, absPath)
|
||||
if err != nil {
|
||||
return false
|
||||
}
|
||||
rel = filepath.ToSlash(rel)
|
||||
if rel == "." || rel == "" || rel == ".." || strings.HasPrefix(rel, "../") {
|
||||
return false
|
||||
}
|
||||
|
||||
// Test the path against the root's ignore matcher and that of every
|
||||
// ancestor directory down to (but excluding) the path itself. Any
|
||||
// level that excludes the path wins; a file's own directory cannot
|
||||
// exclude the file from itself.
|
||||
dir := h.root
|
||||
if h.dirMatcher(dir).MatchAbsDir(absPath, dir, isDir) {
|
||||
return true
|
||||
}
|
||||
segs := strings.Split(rel, "/")
|
||||
for _, seg := range segs[:len(segs)-1] {
|
||||
dir = filepath.Join(dir, seg)
|
||||
if h.dirMatcher(dir).MatchAbsDir(absPath, dir, isDir) {
|
||||
return true
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
// HasNegatedDescendant reports whether any per-directory ignore file
|
||||
// along the chain from the root down to absDir carries a re-include
|
||||
// ("!") pattern that could match a path strictly beneath absDir. The
|
||||
// index walk uses it to keep descending an excluded directory whose
|
||||
// subtree a negation could resurrect, instead of pruning it outright —
|
||||
// the per-directory counterpart of Matcher.HasNegatedDescendant. absDir
|
||||
// is an absolute directory path; a path outside the root never matches.
|
||||
func (h *Hierarchical) HasNegatedDescendant(absDir string) bool {
|
||||
if h == nil || len(h.filenames) == 0 {
|
||||
return false
|
||||
}
|
||||
absDir = filepath.Clean(absDir)
|
||||
rel, err := filepath.Rel(h.root, absDir)
|
||||
if err != nil {
|
||||
return false
|
||||
}
|
||||
rel = filepath.ToSlash(rel)
|
||||
if rel == ".." || strings.HasPrefix(rel, "../") {
|
||||
return false
|
||||
}
|
||||
|
||||
// Each directory's ignore patterns are anchored at that directory, so
|
||||
// the question "is there a negated descendant of absDir?" is asked of
|
||||
// every ancestor matcher (and absDir's own) with absDir expressed
|
||||
// relative to that ancestor.
|
||||
dir := h.root
|
||||
if h.dirMatcher(dir).HasNegatedDescendant(relUnder(dir, absDir)) {
|
||||
return true
|
||||
}
|
||||
if rel == "." || rel == "" {
|
||||
return false
|
||||
}
|
||||
for _, seg := range strings.Split(rel, "/") {
|
||||
dir = filepath.Join(dir, seg)
|
||||
if h.dirMatcher(dir).HasNegatedDescendant(relUnder(dir, absDir)) {
|
||||
return true
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
// relUnder returns child expressed relative to dir, in forward-slash
|
||||
// form. It returns "" when child is dir itself.
|
||||
func relUnder(dir, child string) string {
|
||||
rel, err := filepath.Rel(dir, child)
|
||||
if err != nil {
|
||||
return ""
|
||||
}
|
||||
rel = filepath.ToSlash(rel)
|
||||
if rel == "." {
|
||||
return ""
|
||||
}
|
||||
return rel
|
||||
}
|
||||
|
||||
// dirMatcher returns the compiled ignore matcher for one directory,
|
||||
// reading and parsing its ignore files on first request. A directory
|
||||
// with no ignore files (or only empty ones) caches a nil matcher; the
|
||||
// *Matcher methods are nil-safe so callers need no guard.
|
||||
func (h *Hierarchical) dirMatcher(dir string) *Matcher {
|
||||
h.mu.RLock()
|
||||
m, ok := h.cache[dir]
|
||||
h.mu.RUnlock()
|
||||
if ok {
|
||||
return m
|
||||
}
|
||||
|
||||
var patterns []string
|
||||
for _, name := range h.filenames {
|
||||
patterns = append(patterns, readIgnoreFile(filepath.Join(dir, name))...)
|
||||
}
|
||||
if len(patterns) > 0 {
|
||||
m = New(patterns)
|
||||
}
|
||||
|
||||
h.mu.Lock()
|
||||
h.cache[dir] = m
|
||||
h.mu.Unlock()
|
||||
return m
|
||||
}
|
||||
|
||||
// readIgnoreFile reads one ignore file and returns its non-blank,
|
||||
// non-comment lines as gitignore-syntax patterns. A missing or
|
||||
// unreadable file yields nil — honoring ignore files is a convenience,
|
||||
// never a hard requirement, so a missing or permission-denied file
|
||||
// silently no-ops.
|
||||
func readIgnoreFile(path string) []string {
|
||||
f, err := os.Open(path)
|
||||
if err != nil {
|
||||
return nil
|
||||
}
|
||||
defer f.Close()
|
||||
|
||||
var patterns []string
|
||||
scanner := bufio.NewScanner(f)
|
||||
for scanner.Scan() {
|
||||
line := strings.TrimSpace(scanner.Text())
|
||||
if line == "" || strings.HasPrefix(line, "#") {
|
||||
continue
|
||||
}
|
||||
patterns = append(patterns, line)
|
||||
}
|
||||
return patterns
|
||||
}
|
||||
@@ -0,0 +1,184 @@
|
||||
package excludes
|
||||
|
||||
import (
|
||||
"os"
|
||||
"path/filepath"
|
||||
"testing"
|
||||
)
|
||||
|
||||
func mkIgnore(t *testing.T, path, content string) {
|
||||
t.Helper()
|
||||
if err := os.MkdirAll(filepath.Dir(path), 0o755); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := os.WriteFile(path, []byte(content), 0o644); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestHierarchical_RootLevel(t *testing.T) {
|
||||
root := t.TempDir()
|
||||
mkIgnore(t, filepath.Join(root, ".gortexignore"), "*.gen.go\nbuild/\n")
|
||||
h := NewHierarchical(root, ".gortexignore")
|
||||
|
||||
if !h.Match(filepath.Join(root, "foo.gen.go"), false) {
|
||||
t.Error("root .gortexignore should exclude foo.gen.go")
|
||||
}
|
||||
if h.Match(filepath.Join(root, "foo.go"), false) {
|
||||
t.Error("foo.go should not be excluded")
|
||||
}
|
||||
if !h.Match(filepath.Join(root, "build"), true) {
|
||||
t.Error("build/ directory should be excluded so the subtree is pruned")
|
||||
}
|
||||
if !h.Match(filepath.Join(root, "pkg", "sub", "foo.gen.go"), false) {
|
||||
t.Error("root pattern should reach a deeply nested foo.gen.go")
|
||||
}
|
||||
}
|
||||
|
||||
func TestHierarchical_PerDirectory(t *testing.T) {
|
||||
root := t.TempDir()
|
||||
// An ignore file deep in the tree scopes only to its own subtree.
|
||||
mkIgnore(t, filepath.Join(root, "internal", ".gortexignore"), "secret.go\n")
|
||||
h := NewHierarchical(root, ".gortexignore")
|
||||
|
||||
if !h.Match(filepath.Join(root, "internal", "secret.go"), false) {
|
||||
t.Error("internal/.gortexignore should exclude internal/secret.go")
|
||||
}
|
||||
if !h.Match(filepath.Join(root, "internal", "sub", "secret.go"), false) {
|
||||
t.Error("internal/.gortexignore should reach internal/sub/secret.go")
|
||||
}
|
||||
if h.Match(filepath.Join(root, "secret.go"), false) {
|
||||
t.Error("internal/.gortexignore must not affect a root-level secret.go")
|
||||
}
|
||||
if h.Match(filepath.Join(root, "cmd", "secret.go"), false) {
|
||||
t.Error("internal/.gortexignore must not affect a sibling directory")
|
||||
}
|
||||
}
|
||||
|
||||
func TestHierarchical_Nested(t *testing.T) {
|
||||
root := t.TempDir()
|
||||
mkIgnore(t, filepath.Join(root, ".gortexignore"), "*.tmp\n")
|
||||
mkIgnore(t, filepath.Join(root, "pkg", ".gortexignore"), "local.go\nvendored/\n")
|
||||
h := NewHierarchical(root, ".gortexignore")
|
||||
|
||||
// Both the root rule and the pkg rule apply under pkg/.
|
||||
if !h.Match(filepath.Join(root, "pkg", "x.tmp"), false) {
|
||||
t.Error("root rule should still apply inside pkg/")
|
||||
}
|
||||
if !h.Match(filepath.Join(root, "pkg", "local.go"), false) {
|
||||
t.Error("pkg/.gortexignore should exclude pkg/local.go")
|
||||
}
|
||||
if !h.Match(filepath.Join(root, "pkg", "vendored"), true) {
|
||||
t.Error("pkg/.gortexignore should prune the pkg/vendored/ subtree")
|
||||
}
|
||||
if h.Match(filepath.Join(root, "local.go"), false) {
|
||||
t.Error("pkg/.gortexignore must not reach the root")
|
||||
}
|
||||
}
|
||||
|
||||
func TestHierarchical_Negation(t *testing.T) {
|
||||
root := t.TempDir()
|
||||
mkIgnore(t, filepath.Join(root, ".gortexignore"), "*.log\n!keep.log\n")
|
||||
h := NewHierarchical(root, ".gortexignore")
|
||||
|
||||
if !h.Match(filepath.Join(root, "debug.log"), false) {
|
||||
t.Error("*.log should exclude debug.log")
|
||||
}
|
||||
if h.Match(filepath.Join(root, "keep.log"), false) {
|
||||
t.Error("!keep.log should re-include keep.log")
|
||||
}
|
||||
}
|
||||
|
||||
func TestHierarchical_HasNegatedDescendant(t *testing.T) {
|
||||
root := t.TempDir()
|
||||
// A blanket "dir/*" plus a re-include of one child, expressed in a
|
||||
// per-directory ignore file. The walk must keep descending pkg/ so
|
||||
// the re-included pkg/keep/ subtree is reached.
|
||||
mkIgnore(t, filepath.Join(root, ".gortexignore"), "pkg/*\n!pkg/keep/\n")
|
||||
h := NewHierarchical(root, ".gortexignore")
|
||||
|
||||
if !h.HasNegatedDescendant(filepath.Join(root, "pkg")) {
|
||||
t.Error("pkg/ has a re-included child and must not be pruned")
|
||||
}
|
||||
if h.HasNegatedDescendant(filepath.Join(root, "pkg", "other")) {
|
||||
t.Error("pkg/other has no re-included descendant and is prunable")
|
||||
}
|
||||
if h.HasNegatedDescendant(filepath.Join(root, "unrelated")) {
|
||||
t.Error("an unrelated directory has no re-included descendant")
|
||||
}
|
||||
}
|
||||
|
||||
func TestHierarchical_HasNegatedDescendant_Anchored(t *testing.T) {
|
||||
root := t.TempDir()
|
||||
// The negation lives in a nested ignore file, anchored at internal/.
|
||||
mkIgnore(t, filepath.Join(root, "internal", ".gortexignore"), "build/*\n!build/keep/\n")
|
||||
h := NewHierarchical(root, ".gortexignore")
|
||||
|
||||
if !h.HasNegatedDescendant(filepath.Join(root, "internal", "build")) {
|
||||
t.Error("internal/build has a re-included child via the nested ignore file")
|
||||
}
|
||||
if h.HasNegatedDescendant(filepath.Join(root, "build")) {
|
||||
t.Error("a root-level build/ is unaffected by internal/.gortexignore")
|
||||
}
|
||||
}
|
||||
|
||||
func TestHierarchical_HasNegatedDescendant_NilAndEmpty(t *testing.T) {
|
||||
var h *Hierarchical
|
||||
if h.HasNegatedDescendant("/anything") {
|
||||
t.Error("nil Hierarchical should report no negated descendants")
|
||||
}
|
||||
root := t.TempDir()
|
||||
mkIgnore(t, filepath.Join(root, ".gortexignore"), "pkg/*\n!pkg/keep/\n")
|
||||
empty := NewHierarchical(root) // no filenames configured
|
||||
if empty.HasNegatedDescendant(filepath.Join(root, "pkg")) {
|
||||
t.Error("an empty filename list should report no negated descendants")
|
||||
}
|
||||
}
|
||||
|
||||
func TestHierarchical_NoFiles(t *testing.T) {
|
||||
root := t.TempDir()
|
||||
mkIgnore(t, filepath.Join(root, "foo.go"), "package x\n")
|
||||
h := NewHierarchical(root, ".gortexignore")
|
||||
if h.Match(filepath.Join(root, "foo.go"), false) {
|
||||
t.Error("with no ignore files, nothing should be excluded")
|
||||
}
|
||||
}
|
||||
|
||||
func TestHierarchical_OutsideRoot(t *testing.T) {
|
||||
root := t.TempDir()
|
||||
mkIgnore(t, filepath.Join(root, ".gortexignore"), "*\n")
|
||||
h := NewHierarchical(root, ".gortexignore")
|
||||
if h.Match(filepath.Join(filepath.Dir(root), "elsewhere.go"), false) {
|
||||
t.Error("a path outside the root must never be excluded")
|
||||
}
|
||||
}
|
||||
|
||||
func TestHierarchical_NilAndEmpty(t *testing.T) {
|
||||
var h *Hierarchical
|
||||
if h.Match("/anything", false) {
|
||||
t.Error("nil Hierarchical should match nothing")
|
||||
}
|
||||
root := t.TempDir()
|
||||
mkIgnore(t, filepath.Join(root, ".gortexignore"), "*\n")
|
||||
empty := NewHierarchical(root) // no filenames configured
|
||||
if empty.Match(filepath.Join(root, "foo.go"), false) {
|
||||
t.Error("an empty filename list should match nothing")
|
||||
}
|
||||
}
|
||||
|
||||
func TestHierarchical_MultipleFilenames(t *testing.T) {
|
||||
root := t.TempDir()
|
||||
mkIgnore(t, filepath.Join(root, ".gortexignore"), "a.go\n")
|
||||
mkIgnore(t, filepath.Join(root, ".ignore"), "b.go\n")
|
||||
h := NewHierarchical(root, ".gortexignore", ".ignore")
|
||||
|
||||
if !h.Match(filepath.Join(root, "a.go"), false) {
|
||||
t.Error(".gortexignore pattern should apply")
|
||||
}
|
||||
if !h.Match(filepath.Join(root, "b.go"), false) {
|
||||
t.Error(".ignore pattern should apply")
|
||||
}
|
||||
if h.Match(filepath.Join(root, "c.go"), false) {
|
||||
t.Error("c.go should not be excluded")
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,174 @@
|
||||
package excludes
|
||||
|
||||
import (
|
||||
"path/filepath"
|
||||
"strings"
|
||||
|
||||
ignore "github.com/sabhiram/go-gitignore"
|
||||
|
||||
"github.com/zzet/gortex/internal/pathkey"
|
||||
)
|
||||
|
||||
// Matcher tests whether a path should be excluded from indexing/watching.
|
||||
// It is safe for concurrent reads after construction.
|
||||
type Matcher struct {
|
||||
ign *ignore.GitIgnore
|
||||
patterns []string
|
||||
}
|
||||
|
||||
// New compiles the given patterns into a Matcher. A nil/empty list is
|
||||
// valid and will match nothing.
|
||||
//
|
||||
// Patterns are folded to Unicode NFC so a pattern naming a non-ASCII
|
||||
// directory matches paths regardless of which Unicode form the
|
||||
// filesystem walk produced — MatchRel folds the candidate path to the
|
||||
// same form before testing it.
|
||||
func New(patterns []string) *Matcher {
|
||||
cleaned := make([]string, 0, len(patterns))
|
||||
for _, p := range patterns {
|
||||
p = strings.TrimSpace(p)
|
||||
if p == "" || strings.HasPrefix(p, "#") {
|
||||
continue
|
||||
}
|
||||
cleaned = append(cleaned, pathkey.Normalize(p))
|
||||
}
|
||||
return &Matcher{
|
||||
ign: ignore.CompileIgnoreLines(cleaned...),
|
||||
patterns: cleaned,
|
||||
}
|
||||
}
|
||||
|
||||
// Patterns returns the cleaned pattern list (empties and comments removed).
|
||||
func (m *Matcher) Patterns() []string {
|
||||
if m == nil {
|
||||
return nil
|
||||
}
|
||||
out := make([]string, len(m.patterns))
|
||||
copy(out, m.patterns)
|
||||
return out
|
||||
}
|
||||
|
||||
// MatchRel reports whether a repo-root-relative path is excluded.
|
||||
// Path separators are normalised to forward slashes and the path is
|
||||
// folded to Unicode NFC — matching how New normalised the patterns —
|
||||
// before matching, so a non-ASCII path component compares equal to its
|
||||
// pattern whether the OS supplied it decomposed (macOS NFD) or
|
||||
// precomposed (Linux / git NFC).
|
||||
func (m *Matcher) MatchRel(relPath string) bool {
|
||||
if m == nil || m.ign == nil {
|
||||
return false
|
||||
}
|
||||
rel := pathkey.Normalize(filepath.ToSlash(relPath))
|
||||
rel = strings.TrimPrefix(rel, "./")
|
||||
if rel == "" || rel == "." {
|
||||
return false
|
||||
}
|
||||
return m.ign.MatchesPath(rel)
|
||||
}
|
||||
|
||||
// MatchAbs reports whether an absolute path under root is excluded.
|
||||
// Returns false if path is not under root.
|
||||
func (m *Matcher) MatchAbs(absPath, root string) bool {
|
||||
return m.MatchAbsDir(absPath, root, false)
|
||||
}
|
||||
|
||||
// MatchAbsDir reports whether an absolute path under root is excluded.
|
||||
// When isDir is true the path is treated as a directory, so a pattern
|
||||
// written with a trailing slash (e.g. "build/") matches the directory
|
||||
// itself — letting the caller prune the whole subtree instead of
|
||||
// descending it and re-testing every file. Returns false if path is
|
||||
// not under root.
|
||||
func (m *Matcher) MatchAbsDir(absPath, root string, isDir bool) bool {
|
||||
if m == nil || m.ign == nil {
|
||||
return false
|
||||
}
|
||||
rel, err := filepath.Rel(root, absPath)
|
||||
if err != nil {
|
||||
return false
|
||||
}
|
||||
if isDir {
|
||||
rel += "/"
|
||||
}
|
||||
return m.MatchRel(rel)
|
||||
}
|
||||
|
||||
// HasNegatedDescendant reports whether any re-include ("!") pattern in
|
||||
// the matcher could match a path strictly beneath relDir.
|
||||
//
|
||||
// The index walk prunes an excluded directory with filepath.SkipDir so
|
||||
// it never descends a subtree it would only throw away. But go-gitignore
|
||||
// treats "*" as matching across "/", so a blanket like "a/b/*" reports
|
||||
// the directory "a/b" itself as excluded — pruning it would skip a later
|
||||
// "!a/b/keep/" re-include before the walk ever reaches the child. This
|
||||
// lets the walk ask "could a negation resurrect something under here?"
|
||||
// and keep descending when the answer is yes, mirroring git, which never
|
||||
// prunes a directory a negation could re-include a child from.
|
||||
//
|
||||
// relDir is a repo-root-relative, forward-slash directory path (a
|
||||
// trailing slash and a leading "./" are tolerated). The check is
|
||||
// deliberately conservative: an unanchored or wildcard-leading negation
|
||||
// can match at varying depths, so it is treated as "could be under
|
||||
// anything" and the directory is kept rather than pruned.
|
||||
func (m *Matcher) HasNegatedDescendant(relDir string) bool {
|
||||
if m == nil {
|
||||
return false
|
||||
}
|
||||
relDir = pathkey.Normalize(filepath.ToSlash(relDir))
|
||||
relDir = strings.TrimPrefix(relDir, "./")
|
||||
relDir = strings.TrimSuffix(relDir, "/")
|
||||
if relDir == "." {
|
||||
relDir = ""
|
||||
}
|
||||
for _, p := range m.patterns {
|
||||
if !strings.HasPrefix(p, "!") {
|
||||
continue
|
||||
}
|
||||
np := strings.TrimSpace(p[1:])
|
||||
np = strings.TrimPrefix(np, "/")
|
||||
np = strings.TrimSuffix(np, "/")
|
||||
if np == "" {
|
||||
continue
|
||||
}
|
||||
// A negation with no internal slash is unanchored: gitignore
|
||||
// matches it at any depth, so it can re-include something under
|
||||
// any directory. Keep descending.
|
||||
if !strings.Contains(np, "/") {
|
||||
return true
|
||||
}
|
||||
anchor := literalAnchor(np)
|
||||
if anchor == "" {
|
||||
// First segment is itself a wildcard ("*/...", "**/..."): it
|
||||
// can match at varying depths, so stay conservative.
|
||||
return true
|
||||
}
|
||||
// At the root, every anchored negation lives somewhere beneath us.
|
||||
if relDir == "" {
|
||||
return true
|
||||
}
|
||||
// The negation's match-set intersects relDir's subtree when its
|
||||
// literal anchor sits at or under relDir, or relDir sits under the
|
||||
// anchor (a wildcard tail can then still reach into relDir).
|
||||
if anchor == relDir ||
|
||||
strings.HasPrefix(anchor, relDir+"/") ||
|
||||
strings.HasPrefix(relDir, anchor+"/") {
|
||||
return true
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
// literalAnchor returns the leading path segments of a slash-bearing
|
||||
// gitignore pattern up to (but excluding) the first segment that holds a
|
||||
// wildcard meta-character. It returns "" when the first segment is
|
||||
// itself a wildcard ("*", "**", "?foo", ...).
|
||||
func literalAnchor(pattern string) string {
|
||||
segs := strings.Split(pattern, "/")
|
||||
lit := make([]string, 0, len(segs))
|
||||
for _, s := range segs {
|
||||
if strings.ContainsAny(s, "*?[") {
|
||||
break
|
||||
}
|
||||
lit = append(lit, s)
|
||||
}
|
||||
return strings.Join(lit, "/")
|
||||
}
|
||||
@@ -0,0 +1,151 @@
|
||||
package excludes
|
||||
|
||||
import (
|
||||
"testing"
|
||||
)
|
||||
|
||||
func TestMatcher_Nil(t *testing.T) {
|
||||
var m *Matcher
|
||||
if m.MatchRel("anything") {
|
||||
t.Fatal("nil matcher should never match")
|
||||
}
|
||||
}
|
||||
|
||||
func TestMatcher_Builtin(t *testing.T) {
|
||||
m := New(Builtin)
|
||||
cases := []struct {
|
||||
path string
|
||||
want bool
|
||||
}{
|
||||
{".git/HEAD", true},
|
||||
{"pkg/.git/HEAD", true},
|
||||
{"src/node_modules/foo/index.js", true},
|
||||
{"vendor/lib/x.go", true},
|
||||
{"pkg/foo.go", false},
|
||||
{"README.md", false},
|
||||
{"tmp.tmp", true},
|
||||
{"deep/nested/file.swp", true},
|
||||
{".Makefile.swpx", true},
|
||||
{"src/.foo.swo", true},
|
||||
{"src/.foo.swn", true},
|
||||
{"backup~", true},
|
||||
}
|
||||
for _, tc := range cases {
|
||||
if got := m.MatchRel(tc.path); got != tc.want {
|
||||
t.Errorf("MatchRel(%q) = %v, want %v", tc.path, got, tc.want)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestMatcher_Negation(t *testing.T) {
|
||||
// Exclude all of dist, except dist/keep
|
||||
m := New([]string{"dist/", "!dist/keep/**"})
|
||||
if !m.MatchRel("dist/junk.txt") {
|
||||
t.Error("dist/junk.txt should be excluded")
|
||||
}
|
||||
if m.MatchRel("dist/keep/foo.txt") {
|
||||
t.Error("dist/keep/foo.txt should be re-included")
|
||||
}
|
||||
}
|
||||
|
||||
func TestMatcher_HasNegatedDescendant(t *testing.T) {
|
||||
cases := []struct {
|
||||
name string
|
||||
patterns []string
|
||||
relDir string
|
||||
want bool
|
||||
}{
|
||||
{
|
||||
// Issue #113: a blanket "dir/*" makes go-gitignore report the
|
||||
// parent directory itself as excluded; the re-include lives
|
||||
// beneath it, so the parent must keep being descended.
|
||||
name: "blanket plus reinclude under parent",
|
||||
patterns: []string{"wp-content/plugins/*", "!wp-content/plugins/foo/"},
|
||||
relDir: "wp-content/plugins",
|
||||
want: true,
|
||||
},
|
||||
{
|
||||
name: "sibling without reinclude is prunable",
|
||||
patterns: []string{"wp-content/plugins/*", "!wp-content/plugins/foo/"},
|
||||
relDir: "wp-content/plugins/other",
|
||||
want: false,
|
||||
},
|
||||
{
|
||||
name: "unrelated dir is prunable",
|
||||
patterns: []string{"wp-content/plugins/*", "!wp-content/plugins/foo/"},
|
||||
relDir: "node_modules",
|
||||
want: false,
|
||||
},
|
||||
{
|
||||
name: "reinclude two levels down keeps grandparent",
|
||||
patterns: []string{"a/*", "!a/b/c/"},
|
||||
relDir: "a",
|
||||
want: true,
|
||||
},
|
||||
{
|
||||
name: "reinclude two levels down keeps parent",
|
||||
patterns: []string{"a/*", "!a/b/c/"},
|
||||
relDir: "a/b",
|
||||
want: true,
|
||||
},
|
||||
{
|
||||
name: "reinclude two levels down prunes uninvolved sibling",
|
||||
patterns: []string{"a/*", "!a/b/c/", "!a/x"},
|
||||
relDir: "a/y",
|
||||
want: false,
|
||||
},
|
||||
{
|
||||
name: "unanchored basename negation keeps every dir",
|
||||
patterns: []string{"build/", "!keep.txt"},
|
||||
relDir: "build",
|
||||
want: true,
|
||||
},
|
||||
{
|
||||
name: "wildcard-leading negation keeps every dir",
|
||||
patterns: []string{"build/", "!**/keep/"},
|
||||
relDir: "build",
|
||||
want: true,
|
||||
},
|
||||
{
|
||||
name: "no negations never keeps",
|
||||
patterns: []string{"build/", "dist/"},
|
||||
relDir: "build",
|
||||
want: false,
|
||||
},
|
||||
{
|
||||
name: "trailing-slash relDir is tolerated",
|
||||
patterns: []string{"a/*", "!a/b/c/"},
|
||||
relDir: "a/b/",
|
||||
want: true,
|
||||
},
|
||||
{
|
||||
name: "leading-dot-slash relDir is tolerated",
|
||||
patterns: []string{"a/*", "!a/b/c/"},
|
||||
relDir: "./a",
|
||||
want: true,
|
||||
},
|
||||
}
|
||||
for _, tc := range cases {
|
||||
t.Run(tc.name, func(t *testing.T) {
|
||||
m := New(tc.patterns)
|
||||
if got := m.HasNegatedDescendant(tc.relDir); got != tc.want {
|
||||
t.Errorf("HasNegatedDescendant(%q) = %v, want %v", tc.relDir, got, tc.want)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestMatcher_HasNegatedDescendant_Nil(t *testing.T) {
|
||||
var m *Matcher
|
||||
if m.HasNegatedDescendant("anything") {
|
||||
t.Fatal("nil matcher should report no negated descendants")
|
||||
}
|
||||
}
|
||||
|
||||
func TestMatcher_CommentsAndEmpty(t *testing.T) {
|
||||
m := New([]string{"", "# comment", "foo/"})
|
||||
pats := m.Patterns()
|
||||
if len(pats) != 1 || pats[0] != "foo/" {
|
||||
t.Errorf("expected [foo/], got %v", pats)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,50 @@
|
||||
package excludes
|
||||
|
||||
import (
|
||||
"path"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
)
|
||||
|
||||
// agentConfigRoots are editor/agent state directories that Builtin
|
||||
// excludes wholesale (their bulk content — settings, transcripts, skills —
|
||||
// is noise) but which may also carry an MCP server config the
|
||||
// MCP-config-as-graph feature wants in the graph. Callers descend these
|
||||
// subtrees and index only the MCP config files within them; see the
|
||||
// indexer's shouldExclude.
|
||||
var agentConfigRoots = []string{".claude", ".kiro"}
|
||||
|
||||
// mcpConfigExactBasenames and mcpConfigSuffix mirror the MCP config
|
||||
// extractor's Extensions() (internal/parser/languages/mcp_config.go) —
|
||||
// keep them in sync. ".mcp.json" is matched as a suffix (so a repo-root
|
||||
// ".mcp.json" or "x.mcp.json" qualifies); the rest are exact basenames.
|
||||
var mcpConfigExactBasenames = map[string]bool{
|
||||
"mcp.json": true,
|
||||
"claude_desktop_config.json": true,
|
||||
}
|
||||
|
||||
const mcpConfigSuffix = ".mcp.json"
|
||||
|
||||
// InAgentConfigDir reports whether a repo-root-relative path is the root
|
||||
// of, or lives under, one of the agent-config directories (.claude/,
|
||||
// .kiro/). Used to carve a descend-but-only-MCP-configs exception out of
|
||||
// the wholesale Builtin exclusion of those directories.
|
||||
func InAgentConfigDir(relPath string) bool {
|
||||
rel := strings.TrimPrefix(filepath.ToSlash(relPath), "./")
|
||||
for _, root := range agentConfigRoots {
|
||||
if rel == root || strings.HasPrefix(rel, root+"/") {
|
||||
return true
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
// IsMCPConfigFile reports whether a path's basename is a recognised MCP
|
||||
// server config file name.
|
||||
func IsMCPConfigFile(relPath string) bool {
|
||||
base := path.Base(filepath.ToSlash(relPath))
|
||||
if mcpConfigExactBasenames[base] {
|
||||
return true
|
||||
}
|
||||
return strings.HasSuffix(base, mcpConfigSuffix)
|
||||
}
|
||||
@@ -0,0 +1,33 @@
|
||||
package excludes
|
||||
|
||||
import "testing"
|
||||
|
||||
func TestInAgentConfigDir(t *testing.T) {
|
||||
in := []string{".claude", ".claude/", ".claude/mcp.json", ".claude/skills/x.md", ".kiro", ".kiro/settings/mcp.json"}
|
||||
out := []string{"", ".", "src/main.go", "claude/x", ".cursor/mcp.json", "a/.claude/mcp.json"}
|
||||
for _, p := range in {
|
||||
if !InAgentConfigDir(p) {
|
||||
t.Errorf("InAgentConfigDir(%q) = false, want true", p)
|
||||
}
|
||||
}
|
||||
for _, p := range out {
|
||||
if InAgentConfigDir(p) {
|
||||
t.Errorf("InAgentConfigDir(%q) = true, want false", p)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestIsMCPConfigFile(t *testing.T) {
|
||||
yes := []string{"mcp.json", ".mcp.json", ".cursor/mcp.json", ".kiro/settings/mcp.json", "claude_desktop_config.json", "foo.mcp.json"}
|
||||
no := []string{"settings.json", "package.json", "mcp.yaml", "x.md", "mcp.json.bak"}
|
||||
for _, p := range yes {
|
||||
if !IsMCPConfigFile(p) {
|
||||
t.Errorf("IsMCPConfigFile(%q) = false, want true", p)
|
||||
}
|
||||
}
|
||||
for _, p := range no {
|
||||
if IsMCPConfigFile(p) {
|
||||
t.Errorf("IsMCPConfigFile(%q) = true, want false", p)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,45 @@
|
||||
package excludes
|
||||
|
||||
import (
|
||||
"path/filepath"
|
||||
"strings"
|
||||
)
|
||||
|
||||
// vendorDirNames is the set of directory names that denote vendored
|
||||
// dependencies or build outputs, derived from Builtin at init time.
|
||||
// Glob entries (e.g. "*.tmp") are dropped — they identify files, not trees.
|
||||
var vendorDirNames = func() map[string]struct{} {
|
||||
m := make(map[string]struct{}, len(Builtin))
|
||||
for _, p := range Builtin {
|
||||
if !strings.HasSuffix(p, "/") {
|
||||
continue
|
||||
}
|
||||
name := strings.TrimSuffix(p, "/")
|
||||
if strings.ContainsAny(name, "*?!/") {
|
||||
continue
|
||||
}
|
||||
m[name] = struct{}{}
|
||||
}
|
||||
return m
|
||||
}()
|
||||
|
||||
// IsVendored reports whether a path lives inside a vendored or build-output
|
||||
// directory tracked by Builtin (vendor/, node_modules/, Pods/, target/,
|
||||
// .venv/, ...). It lets callers outside the indexer — e.g. the MCP contracts
|
||||
// tool — apply the same "not our code" judgment without rolling a new list.
|
||||
//
|
||||
// Accepts any of: repo-root-relative ("pkg/x.go"), repo-prefixed
|
||||
// ("repo/pkg/x.go"), or absolute ("/abs/path/pkg/x.go"). The match is
|
||||
// path-component-wise because the repo root may not be known to the caller.
|
||||
func IsVendored(path string) bool {
|
||||
if path == "" {
|
||||
return false
|
||||
}
|
||||
p := filepath.ToSlash(path)
|
||||
for _, seg := range strings.Split(p, "/") {
|
||||
if _, ok := vendorDirNames[seg]; ok {
|
||||
return true
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
@@ -0,0 +1,38 @@
|
||||
package excludes
|
||||
|
||||
import "testing"
|
||||
|
||||
func TestIsVendored(t *testing.T) {
|
||||
tests := []struct {
|
||||
path string
|
||||
want bool
|
||||
}{
|
||||
// Repo-root-relative
|
||||
{"vendor/github.com/aws/aws-sdk-go/service.go", true},
|
||||
{"node_modules/react/index.js", true},
|
||||
{"Pods/AFNetworking/AFNetworking.m", true},
|
||||
{".venv/lib/python3.11/site-packages/requests/__init__.py", true},
|
||||
{"target/classes/Main.class", true},
|
||||
// Repo-prefixed
|
||||
{"myrepo/vendor/lib/x.go", true},
|
||||
{"tuck_app/Pods/sqlite3.c", true},
|
||||
{"daedalus/node_modules/foo/index.ts", true},
|
||||
// Absolute
|
||||
{"/Users/me/code/proj/vendor/x/y.go", true},
|
||||
// Not vendored
|
||||
{"internal/mcp/tools_enhancements.go", false},
|
||||
{"cmd/gortex/main.go", false},
|
||||
{"pkg/server.go", false},
|
||||
{"", false},
|
||||
// Partial name match must not trigger
|
||||
{"vendoring/notes.md", false},
|
||||
{"node_modulesx/foo.go", false},
|
||||
// Glob-derived entries must not match (*.tmp isn't a dir)
|
||||
{"foo.tmp", false},
|
||||
}
|
||||
for _, tt := range tests {
|
||||
if got := IsVendored(tt.path); got != tt.want {
|
||||
t.Errorf("IsVendored(%q) = %v, want %v", tt.path, got, tt.want)
|
||||
}
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user