mirror of
https://github.com/lukaszraczylo/filepuff-mcp.git
synced 2026-10-12 04:54:27 +00:00
Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
f62b7e0019 | ||
|
|
e98ec6032c | ||
|
|
5c9b0f967f | ||
|
|
cefd6ba7b1 | ||
|
|
d82479c02e | ||
|
|
f6ba02e892 | ||
|
|
2344b38d59 | ||
|
|
2158228ddb | ||
|
|
f4f5135ac2 | ||
|
|
964e987776 | ||
|
|
a7fde76871 | ||
|
|
ed8611d07b | ||
|
|
700c6e818c | ||
|
|
25ee197acd | ||
|
|
a7dcab5146 | ||
|
|
d9d8332f77 | ||
|
|
a4bfdefdab | ||
|
|
32b0a7f69a | ||
|
|
76316de768 | ||
|
|
9abab7a550 | ||
|
|
658c895318 | ||
|
|
d24c8218fd | ||
|
|
f4c34c41e4 | ||
|
|
749076518d | ||
|
|
664b7dac45 | ||
|
|
d0c07f0157 | ||
|
|
a841210092 | ||
|
|
98a499ab20 | ||
|
|
8d940022b7 |
@@ -1,4 +1,4 @@
|
||||
name: Autoupdate go.mod and go.sum
|
||||
name: Update dependencies
|
||||
|
||||
on:
|
||||
workflow_dispatch:
|
||||
@@ -6,16 +6,16 @@ on:
|
||||
- cron: "0 3 * * *"
|
||||
|
||||
permissions:
|
||||
contents: write
|
||||
actions: write
|
||||
contents: write
|
||||
issues: write
|
||||
pull-requests: write
|
||||
security-events: write
|
||||
|
||||
jobs:
|
||||
autoupdate:
|
||||
uses: lukaszraczylo/shared-actions/.github/workflows/go-autoupdate.yaml@main
|
||||
update:
|
||||
uses: lukaszraczylo/shared-actions/.github/workflows/renovate-autoupdate.yaml@main
|
||||
with:
|
||||
go-version: ">=1.24"
|
||||
release-workflow: "release.yaml"
|
||||
node: false
|
||||
release-workflow: release.yaml
|
||||
lfs: true
|
||||
secrets: inherit
|
||||
|
||||
@@ -30,7 +30,7 @@ jobs:
|
||||
- name: Set up Go
|
||||
uses: actions/setup-go@v5
|
||||
with:
|
||||
go-version: '1.25'
|
||||
go-version: '1.26'
|
||||
cache: true
|
||||
|
||||
- name: Run golangci-lint
|
||||
|
||||
@@ -18,7 +18,7 @@ jobs:
|
||||
release:
|
||||
uses: lukaszraczylo/shared-actions/.github/workflows/go-release-cgo.yaml@main
|
||||
with:
|
||||
go-version: "1.25"
|
||||
go-version: "1.26"
|
||||
rolling-release-tag: "v1"
|
||||
platforms: >-
|
||||
[
|
||||
|
||||
@@ -31,7 +31,7 @@ jobs:
|
||||
- name: Set up Go
|
||||
uses: actions/setup-go@v5
|
||||
with:
|
||||
go-version: '1.25'
|
||||
go-version: '1.26'
|
||||
cache: true
|
||||
|
||||
- name: Install govulncheck
|
||||
|
||||
+14
-20
@@ -2,7 +2,7 @@ version: "2"
|
||||
|
||||
run:
|
||||
timeout: 5m
|
||||
go: '1.25'
|
||||
go: '1.26'
|
||||
|
||||
linters:
|
||||
enable:
|
||||
@@ -13,32 +13,26 @@ linters:
|
||||
- unused # Check for unused code
|
||||
- gocyclo # Check cyclomatic complexity
|
||||
- misspell # Check for misspelled words
|
||||
settings:
|
||||
staticcheck:
|
||||
checks: ["all"]
|
||||
exclusions:
|
||||
rules:
|
||||
# Exclude some linters from running on tests
|
||||
- path: _test\.go
|
||||
linters:
|
||||
- gocyclo
|
||||
- errcheck
|
||||
- gosec
|
||||
|
||||
formatters:
|
||||
enable:
|
||||
- gofmt # Check formatting
|
||||
|
||||
linters-settings:
|
||||
gocyclo:
|
||||
min-complexity: 15
|
||||
errcheck:
|
||||
check-blank: true
|
||||
govet:
|
||||
enable-all: true
|
||||
staticcheck:
|
||||
checks: ["all"]
|
||||
|
||||
issues:
|
||||
exclude-rules:
|
||||
# Exclude some linters from running on tests
|
||||
- path: _test\.go
|
||||
linters:
|
||||
- gocyclo
|
||||
- errcheck
|
||||
- gosec
|
||||
|
||||
max-issues-per-linter: 0
|
||||
max-same-issues: 0
|
||||
|
||||
output:
|
||||
sort-results: true
|
||||
sort-order:
|
||||
- file
|
||||
|
||||
@@ -95,6 +95,24 @@ release:
|
||||
```
|
||||
|
||||
|
||||
homebrew_casks:
|
||||
- name: mcp-filepuff
|
||||
repository:
|
||||
owner: lukaszraczylo
|
||||
name: homebrew-taps
|
||||
token: "{{ .Env.HOMEBREW_TAP_TOKEN }}"
|
||||
directory: Casks
|
||||
homepage: https://github.com/lukaszraczylo/filepuff-mcp
|
||||
description: "AST-aware file operations and LSP integration MCP server for Claude Code"
|
||||
license: MIT
|
||||
hooks:
|
||||
post:
|
||||
install: |
|
||||
if OS.mac?
|
||||
system_command "/usr/bin/xattr",
|
||||
args: ["-dr", "com.apple.quarantine", "#{staged_path}/mcp-filepuff"]
|
||||
end
|
||||
|
||||
dockers:
|
||||
- image_templates:
|
||||
- "ghcr.io/lukaszraczylo/filepuff-mcp:{{ .Tag }}"
|
||||
|
||||
@@ -283,7 +283,7 @@ Search for text patterns in files using ripgrep.
|
||||
- `file_types`: File types to search (e.g., ["go", "ts", "py"])
|
||||
- `ignore_case`: Case insensitive search
|
||||
- `regex`: Treat pattern as regex (default: true)
|
||||
- `context_lines`: Number of context lines around matches (default: 2)
|
||||
- `context_lines`: Number of context lines around matches (default: 0)
|
||||
- `max_results`: Maximum number of results to return
|
||||
|
||||
---
|
||||
@@ -352,7 +352,7 @@ Search for AST patterns in code files using structural pattern matching.
|
||||
- `name_matches`: Regex pattern to filter by name
|
||||
- `name_exact`: Exact name to match
|
||||
- `kind_in`: Node types to match (e.g., function_declaration)
|
||||
- `max_results`: Maximum number of results (default: 100)
|
||||
- `max_results`: Maximum number of results (default: 50)
|
||||
|
||||
**Examples**:
|
||||
```json
|
||||
|
||||
+2
-2
@@ -70,7 +70,7 @@ Search for text patterns in files using ripgrep. Supports regex patterns, file t
|
||||
| `file_types` | `array[string]` | No | File types to search (e.g., ['go', 'ts', 'py']) |
|
||||
| `ignore_case` | `boolean` | No | Case insensitive search |
|
||||
| `regex` | `boolean` | No | Treat pattern as regex (default: true) |
|
||||
| `context_lines` | `number` | No | Number of context lines around matches (default: 2) |
|
||||
| `context_lines` | `number` | No | Number of context lines around matches (default: 0) |
|
||||
| `max_results` | `number` | No | Maximum number of results to return |
|
||||
|
||||
**Examples:**
|
||||
@@ -153,7 +153,7 @@ Search for AST patterns in code files. Use code patterns with $VAR placeholders
|
||||
| `name_matches` | `string` | No | Regex pattern to filter by name |
|
||||
| `name_exact` | `string` | No | Exact name to match |
|
||||
| `kind_in` | `array[string]` | No | Node types to match (e.g., function_declaration, class_declaration) |
|
||||
| `max_results` | `number` | No | Maximum number of results to return (default: 100) |
|
||||
| `max_results` | `number` | No | Maximum number of results to return (default: 50) |
|
||||
|
||||
**Examples:**
|
||||
|
||||
|
||||
@@ -1,12 +1,12 @@
|
||||
module github.com/lukaszraczylo/mcp-filepuff
|
||||
|
||||
go 1.25.5
|
||||
go 1.26.0
|
||||
|
||||
require (
|
||||
github.com/cespare/xxhash/v2 v2.3.0
|
||||
github.com/goccy/go-json v0.10.6
|
||||
github.com/goccy/go-json v0.11.2
|
||||
github.com/hashicorp/golang-lru/v2 v2.0.7
|
||||
github.com/mark3labs/mcp-go v0.55.0
|
||||
github.com/mark3labs/mcp-go v1.2.1
|
||||
github.com/sergi/go-diff v1.4.0
|
||||
github.com/smacker/go-tree-sitter v0.0.0-20240827094217-dd81d9e9be82
|
||||
gopkg.in/yaml.v3 v3.0.1
|
||||
@@ -15,8 +15,8 @@ require (
|
||||
require (
|
||||
github.com/google/jsonschema-go v0.4.3 // indirect
|
||||
github.com/google/uuid v1.6.0 // indirect
|
||||
github.com/santhosh-tekuri/jsonschema/v6 v6.0.2 // indirect
|
||||
github.com/santhosh-tekuri/jsonschema/v6 v6.0.3 // indirect
|
||||
github.com/spf13/cast v1.10.0 // indirect
|
||||
github.com/yosida95/uritemplate/v3 v3.0.2 // indirect
|
||||
golang.org/x/text v0.38.0 // indirect
|
||||
golang.org/x/text v0.43.0 // indirect
|
||||
)
|
||||
|
||||
@@ -7,8 +7,8 @@ github.com/dlclark/regexp2 v1.11.0 h1:G/nrcoOa7ZXlpoa/91N3X7mM3r8eIlMBBJZvsz/mxK
|
||||
github.com/dlclark/regexp2 v1.11.0/go.mod h1:DHkYz0B9wPfa6wondMfaivmHpzrQ3v9q8cnmRbL6yW8=
|
||||
github.com/frankban/quicktest v1.14.6 h1:7Xjx+VpznH+oBnejlPUj8oUpdxnVs4f8XU8WnHkI4W8=
|
||||
github.com/frankban/quicktest v1.14.6/go.mod h1:4ptaffx2x8+WTWXmUCuVU6aPUX1/Mz7zb5vbUoiM6w0=
|
||||
github.com/goccy/go-json v0.10.6 h1:p8HrPJzOakx/mn/bQtjgNjdTcN+/S6FcG2CTtQOrHVU=
|
||||
github.com/goccy/go-json v0.10.6/go.mod h1:oq7eo15ShAhp70Anwd5lgX2pLfOS3QCiwU/PULtXL6M=
|
||||
github.com/goccy/go-json v0.11.2 h1:jdZv93Tt4ioR8yW1CoNsvSxrcZlCXAUU1aZXN7gpXUA=
|
||||
github.com/goccy/go-json v0.11.2/go.mod h1:3NdmfEkZlB7YI5UFw/qdFKq8XN1aiWR0YyRPWZNQltY=
|
||||
github.com/google/go-cmp v0.7.0 h1:wk8382ETsv4JYUZwIsn6YpYiWiBsYLSJiTsyBybVuN8=
|
||||
github.com/google/go-cmp v0.7.0/go.mod h1:pXiqmnSA92OHEEa9HXL2W4E7lf9JzCmGVUdgjX3N/iU=
|
||||
github.com/google/jsonschema-go v0.4.3 h1:/DBOLZTfDow7pe2GmaJNhltueGTtDKICi8V8p+DQPd0=
|
||||
@@ -24,14 +24,14 @@ github.com/kr/pty v1.1.1/go.mod h1:pFQYn66WHrOpPYNljwOMqo10TkYh1fy3cYio2l3bCsQ=
|
||||
github.com/kr/text v0.1.0/go.mod h1:4Jbv+DJW3UT/LiOwJeYQe1efqtUx/iVham/4vfdArNI=
|
||||
github.com/kr/text v0.2.0 h1:5Nx0Ya0ZqY2ygV366QzturHI13Jq95ApcVaJBhpS+AY=
|
||||
github.com/kr/text v0.2.0/go.mod h1:eLer722TekiGuMkidMxC/pM04lWEeraHUUmBw8l2grE=
|
||||
github.com/mark3labs/mcp-go v0.55.0 h1:lJfz2aoctiwK+sI991+uIYwmKNIBciI+O7zsyDsa4U8=
|
||||
github.com/mark3labs/mcp-go v0.55.0/go.mod h1:+8WclSK1ZUweCP3hvktSji8n8ABG/95QaEkeVE/Uwas=
|
||||
github.com/mark3labs/mcp-go v1.2.1 h1:gd8VD3feMAVycwAM0x1nhv42Q1oLLjfOyowoUzHULB8=
|
||||
github.com/mark3labs/mcp-go v1.2.1/go.mod h1:r2fW4o3wsoJ7IMsx1Wuq5xeP8PRGXPDfNveoGAYbb/s=
|
||||
github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM=
|
||||
github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4=
|
||||
github.com/rogpeppe/go-internal v1.14.1 h1:UQB4HGPB6osV0SQTLymcB4TgvyWu6ZyliaW0tI/otEQ=
|
||||
github.com/rogpeppe/go-internal v1.14.1/go.mod h1:MaRKkUm5W0goXpeCfT7UZI6fk/L7L7so1lCWt35ZSgc=
|
||||
github.com/santhosh-tekuri/jsonschema/v6 v6.0.2 h1:KRzFb2m7YtdldCEkzs6KqmJw4nqEVZGK7IN2kJkjTuQ=
|
||||
github.com/santhosh-tekuri/jsonschema/v6 v6.0.2/go.mod h1:JXeL+ps8p7/KNMjDQk3TCwPpBy0wYklyWTfbkIzdIFU=
|
||||
github.com/santhosh-tekuri/jsonschema/v6 v6.0.3 h1:1EYB5IzjZawrrnELUi78f9fPu57HuXjmddZPjrls/28=
|
||||
github.com/santhosh-tekuri/jsonschema/v6 v6.0.3/go.mod h1:JXeL+ps8p7/KNMjDQk3TCwPpBy0wYklyWTfbkIzdIFU=
|
||||
github.com/sergi/go-diff v1.4.0 h1:n/SP9D5ad1fORl+llWyN+D6qoUETXNZARKjyY2/KVCw=
|
||||
github.com/sergi/go-diff v1.4.0/go.mod h1:A0bzQcvG0E7Rwjx0REVgAGH58e96+X0MeOfepqsbeW4=
|
||||
github.com/smacker/go-tree-sitter v0.0.0-20240827094217-dd81d9e9be82 h1:6C8qej6f1bStuePVkLSFxoU22XBS165D3klxlzRg8F4=
|
||||
@@ -44,8 +44,10 @@ github.com/stretchr/testify v1.11.1 h1:7s2iGBzp5EwR7/aIZr8ao5+dra3wiQyKjjFuvgVKu
|
||||
github.com/stretchr/testify v1.11.1/go.mod h1:wZwfW3scLgRK+23gO65QZefKpKQRnfz6sD981Nm4B6U=
|
||||
github.com/yosida95/uritemplate/v3 v3.0.2 h1:Ed3Oyj9yrmi9087+NczuL5BwkIc4wvTb5zIM+UJPGz4=
|
||||
github.com/yosida95/uritemplate/v3 v3.0.2/go.mod h1:ILOh0sOhIJR3+L/8afwt/kE++YT040gmv5BQTMR2HP4=
|
||||
golang.org/x/text v0.38.0 h1:sXmwo9DwP3OK9EZ7PqAdaooSGozfl/3a6/xJcbzPRhE=
|
||||
golang.org/x/text v0.38.0/go.mod h1:YXZt3QhHUKYT53r2lLKFIVi6Ao1jdzrTR/KQ09qyxF4=
|
||||
golang.org/x/text v0.43.0 h1:1QivrlhwAsnMOcqgOdl9my7s9OyZl8O5/UGUTJVXoWc=
|
||||
golang.org/x/text v0.43.0/go.mod h1:hbSIYA/amXcRXTFSZqh8tWHEoqRvvbKPJ2uz/KU+a+s=
|
||||
golang.org/x/tools v0.51.0 h1:k4Xc/1Om9jwkBJBo4NVLMSARBoWtK10mx+W5BnXCeAI=
|
||||
golang.org/x/tools v0.51.0/go.mod h1:9eEncMayCV6zRMGhR5eZEC2iBx98qWcF1HZ9Z7wJOoA=
|
||||
gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0=
|
||||
gopkg.in/check.v1 v1.0.0-20190902080502-41f04d3bba15/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0=
|
||||
gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c h1:Hei/4ADfdWqJk1ZMxUNpqntNwaWcugrBjAiHlqqRiVk=
|
||||
|
||||
@@ -33,7 +33,7 @@ const (
|
||||
DefaultSearchTimeout = 30 * time.Second
|
||||
DefaultMaxFileSize = 10 * 1024 * 1024 // 10 MB
|
||||
DefaultMaxParseSize = 10 * 1024 * 1024 // 10 MB
|
||||
DefaultMaxSearchResults = 1000
|
||||
DefaultMaxSearchResults = 50
|
||||
DefaultMaxEditSize = 100 * 1024 // 100 KB
|
||||
DefaultResourceLinkThresholdBytes = 64 * 1024 // 64 KiB
|
||||
)
|
||||
|
||||
+35
-31
@@ -63,17 +63,46 @@ type Engine struct {
|
||||
registry *parser.Registry
|
||||
dmp *diffmatchpatch.DiffMatchPatch
|
||||
fileLocks sync.Map // map[string]*sync.Mutex for per-file locking
|
||||
// pathAllowed re-checks that a target path is admissible (within the
|
||||
// workspace root) immediately before a write. Injected to avoid coupling
|
||||
// edit to config; nil disables the check (used in unit tests).
|
||||
pathAllowed func(string) bool
|
||||
}
|
||||
|
||||
// NewEngine creates a new edit engine.
|
||||
// NewEngine creates a new edit engine with no write-time path validation.
|
||||
func NewEngine(registry *parser.Registry) *Engine {
|
||||
return NewEngineWithValidator(registry, nil)
|
||||
}
|
||||
|
||||
// NewEngineWithValidator creates an edit engine that re-validates each target
|
||||
// path with pathAllowed immediately before writing it, closing most of the
|
||||
// time-of-check/time-of-use window between the handler's admission check and the
|
||||
// actual write (a symlink swapped in afterward is rejected).
|
||||
func NewEngineWithValidator(registry *parser.Registry, pathAllowed func(string) bool) *Engine {
|
||||
return &Engine{
|
||||
registry: registry,
|
||||
dmp: diffmatchpatch.New(),
|
||||
fileLocks: sync.Map{},
|
||||
registry: registry,
|
||||
dmp: diffmatchpatch.New(),
|
||||
fileLocks: sync.Map{},
|
||||
pathAllowed: pathAllowed,
|
||||
}
|
||||
}
|
||||
|
||||
// writeValidatedFile writes content to path, preserving the file's existing
|
||||
// permission bits, after re-checking that path is still admissible. The re-check
|
||||
// resolves symlinks at write time, so a symlink swapped in after the caller's
|
||||
// original validation cannot redirect the write outside the workspace root.
|
||||
func (e *Engine) writeValidatedFile(path string, content []byte) error {
|
||||
if e.pathAllowed != nil && !e.pathAllowed(path) {
|
||||
return errors.New(errors.ErrPathNotAllowed, "path is outside the workspace root").
|
||||
WithContext("path", path)
|
||||
}
|
||||
perm := os.FileMode(0o600) // default fallback when the file can't be stat'd
|
||||
if info, err := os.Stat(path); err == nil {
|
||||
perm = info.Mode().Perm()
|
||||
}
|
||||
return os.WriteFile(path, content, perm)
|
||||
}
|
||||
|
||||
// lockFile acquires a lock for the specified file and returns an unlock function.
|
||||
// This prevents concurrent edits to the same file which could cause corruption.
|
||||
func (e *Engine) lockFile(filePath string) func() {
|
||||
@@ -179,14 +208,7 @@ func (e *Engine) performASTEdit(ctx context.Context, edit *ASTEdit, apply bool)
|
||||
|
||||
// Apply changes if requested
|
||||
if apply {
|
||||
// Preserve original file permissions
|
||||
fileInfo, err := os.Stat(edit.File)
|
||||
perm := os.FileMode(0o600) // default fallback
|
||||
if err == nil {
|
||||
perm = fileInfo.Mode().Perm()
|
||||
}
|
||||
|
||||
if err := os.WriteFile(edit.File, newContent, perm); err != nil {
|
||||
if err := e.writeValidatedFile(edit.File, newContent); err != nil {
|
||||
structuredErr := errors.NewFileNotWritableError(edit.File, err)
|
||||
return &EditResult{
|
||||
Success: false,
|
||||
@@ -236,14 +258,7 @@ func (e *Engine) performTextEdit(_ context.Context, edit *ASTEdit, apply bool) (
|
||||
|
||||
// Apply changes if requested
|
||||
if apply {
|
||||
// Preserve original file permissions
|
||||
fileInfo, err := os.Stat(edit.File)
|
||||
perm := os.FileMode(0o600) // default fallback
|
||||
if err == nil {
|
||||
perm = fileInfo.Mode().Perm()
|
||||
}
|
||||
|
||||
if err := os.WriteFile(edit.File, newContent, perm); err != nil {
|
||||
if err := e.writeValidatedFile(edit.File, newContent); err != nil {
|
||||
structuredErr := errors.NewFileNotWritableError(edit.File, err)
|
||||
return &EditResult{
|
||||
Success: false,
|
||||
@@ -866,14 +881,3 @@ func truncateString(s string, maxLen int) string {
|
||||
}
|
||||
return s[:util.RuneBoundary(s, maxLen-3)] + "..."
|
||||
}
|
||||
|
||||
// ValidateLanguage checks if AST editing is supported for a file.
|
||||
// Returns nil for supported languages, error for unsupported.
|
||||
// Note: Text-based editing is always available regardless of this check.
|
||||
func ValidateLanguage(filename string) error {
|
||||
lang := protocol.DetectLanguage(filename)
|
||||
if lang == protocol.LangUnknown {
|
||||
return fmt.Errorf("unsupported file type for AST editing: %s (text-based editing is available)", filename)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
@@ -369,6 +369,73 @@ func Hello() {
|
||||
}
|
||||
}
|
||||
|
||||
// TestApplyRevalidatesPathBeforeWrite verifies the write-time admission re-check:
|
||||
// an Engine whose validator rejects the target must refuse the write and leave
|
||||
// the file untouched (the TOCTOU guard against a symlink swapped in after the
|
||||
// handler's original IsPathAllowed check).
|
||||
func TestApplyRevalidatesPathBeforeWrite(t *testing.T) {
|
||||
registry := parser.NewRegistry()
|
||||
defer registry.Close()
|
||||
|
||||
tmpDir := t.TempDir()
|
||||
tmpFile := filepath.Join(tmpDir, "test.go")
|
||||
original := "package main\n\nfunc Hello() {}\n"
|
||||
if err := os.WriteFile(tmpFile, []byte(original), 0o600); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
// Validator denies the path (as IsPathAllowed would for a path that now
|
||||
// resolves outside the workspace root).
|
||||
e := NewEngineWithValidator(registry, func(string) bool { return false })
|
||||
|
||||
result, err := e.Apply(context.Background(), &ASTEdit{
|
||||
File: tmpFile,
|
||||
Operation: EditReplace,
|
||||
Selector: ASTSelector{Kind: "function_declaration"},
|
||||
NewContent: "func Evil() {}",
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("apply returned transport error: %v", err)
|
||||
}
|
||||
if result.Success || result.Applied {
|
||||
t.Errorf("rejected-path edit must not apply (Success=%v Applied=%v)", result.Success, result.Applied)
|
||||
}
|
||||
if got, _ := os.ReadFile(tmpFile); string(got) != original {
|
||||
t.Errorf("file must be unchanged when path validation fails; got:\n%s", got)
|
||||
}
|
||||
}
|
||||
|
||||
// TestApplyPreservesFileMode verifies an edit keeps the file's existing
|
||||
// permission bits rather than resetting them.
|
||||
func TestApplyPreservesFileMode(t *testing.T) {
|
||||
registry := parser.NewRegistry()
|
||||
defer registry.Close()
|
||||
e := NewEngine(registry)
|
||||
|
||||
tmpDir := t.TempDir()
|
||||
tmpFile := filepath.Join(tmpDir, "script.go")
|
||||
if err := os.WriteFile(tmpFile, []byte("package main\n\nfunc Hello() {}\n"), 0o755); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
result, err := e.Apply(context.Background(), &ASTEdit{
|
||||
File: tmpFile,
|
||||
Operation: EditReplace,
|
||||
Selector: ASTSelector{Kind: "function_declaration"},
|
||||
NewContent: "func Hi() {}",
|
||||
})
|
||||
if err != nil || !result.Success {
|
||||
t.Fatalf("apply failed: err=%v result=%+v", err, result)
|
||||
}
|
||||
info, err := os.Stat(tmpFile)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if perm := info.Mode().Perm(); perm != 0o755 {
|
||||
t.Errorf("file mode not preserved: got %o, want 0755", perm)
|
||||
}
|
||||
}
|
||||
|
||||
// An edit on a CRLF file must normalize inserted LF content to CRLF so the file
|
||||
// keeps a single, consistent line-ending convention (a documented guarantee).
|
||||
func TestApplyPreservesCRLF(t *testing.T) {
|
||||
@@ -1751,3 +1818,80 @@ func TestRegressionLineMergeDelete(t *testing.T) {
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
// TestFindExactTextIndexAndOverlap pins selector_index resolution for literal
|
||||
// text matches, including overlapping matches and the out-of-range guard — the
|
||||
// range these return is the span that gets mutated, so wrong indices = wrong-
|
||||
// location writes.
|
||||
func TestFindExactTextIndexAndOverlap(t *testing.T) {
|
||||
e := NewEngine(parser.NewRegistry())
|
||||
content := []byte("aaaa") // "aa" matches at 0,1,2 (overlapping)
|
||||
|
||||
for i, want := range []struct{ start, end int }{{0, 2}, {1, 3}, {2, 4}} {
|
||||
start, end, err := e.findExactText(content, "aa", i)
|
||||
if err != nil {
|
||||
t.Fatalf("index %d: unexpected error: %v", i, err)
|
||||
}
|
||||
if start != want.start || end != want.end {
|
||||
t.Errorf("index %d: got [%d,%d), want [%d,%d)", i, start, end, want.start, want.end)
|
||||
}
|
||||
}
|
||||
|
||||
if _, _, err := e.findExactText(content, "aa", 3); err == nil {
|
||||
t.Error("index 3 (only 3 matches) must be out-of-range error")
|
||||
}
|
||||
if _, _, err := e.findExactText(content, "zz", 0); err == nil {
|
||||
t.Error("missing text must error")
|
||||
}
|
||||
if _, _, err := e.findExactText(content, "", 0); err == nil {
|
||||
t.Error("empty text selector must error")
|
||||
}
|
||||
}
|
||||
|
||||
// TestFindRegexPatternIndexAndError pins selector_index and error paths for regex
|
||||
// selection (non-overlapping matches via FindAllIndex).
|
||||
func TestFindRegexPatternIndexAndError(t *testing.T) {
|
||||
e := NewEngine(parser.NewRegistry())
|
||||
content := []byte("a1b2c3") // [0-9] matches at 1,3,5
|
||||
|
||||
for i, want := range []struct{ start, end int }{{1, 2}, {3, 4}, {5, 6}} {
|
||||
start, end, err := e.findRegexPattern(content, "[0-9]", i)
|
||||
if err != nil {
|
||||
t.Fatalf("index %d: unexpected error: %v", i, err)
|
||||
}
|
||||
if start != want.start || end != want.end {
|
||||
t.Errorf("index %d: got [%d,%d), want [%d,%d)", i, start, end, want.start, want.end)
|
||||
}
|
||||
}
|
||||
|
||||
if _, _, err := e.findRegexPattern(content, "[0-9]", 3); err == nil {
|
||||
t.Error("index 3 (only 3 matches) must be out-of-range error")
|
||||
}
|
||||
if _, _, err := e.findRegexPattern(content, "zzz", 0); err == nil {
|
||||
t.Error("non-matching pattern must error")
|
||||
}
|
||||
if _, _, err := e.findRegexPattern(content, "[unclosed", 0); err == nil {
|
||||
t.Error("invalid regex must error")
|
||||
}
|
||||
}
|
||||
|
||||
// TestSpliceContentCRLFNoBareLF verifies the CRLF pullback: when an edit range
|
||||
// ends between the \r and \n of a terminator, the \r\n must stay intact in the
|
||||
// tail rather than being split into a bare LF (which would corrupt the file's
|
||||
// line-ending convention).
|
||||
func TestSpliceContentCRLFNoBareLF(t *testing.T) {
|
||||
// Replace [0,4) which ends on the \n of "foo\r\n" (index 4, preceded by \r@3).
|
||||
got, err := spliceContent(EditReplace, []byte("foo\r\nbar\r\n"), 0, 4, "X", "\r\n")
|
||||
if err != nil {
|
||||
t.Fatalf("splice: %v", err)
|
||||
}
|
||||
if string(got) != "X\r\nbar\r\n" {
|
||||
t.Fatalf("pullback failed: got %q, want %q", got, "X\r\nbar\r\n")
|
||||
}
|
||||
// No \n may appear without a preceding \r.
|
||||
for i, b := range got {
|
||||
if b == '\n' && (i == 0 || got[i-1] != '\r') {
|
||||
t.Errorf("bare LF at index %d in %q", i, got)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
+19
-1
@@ -304,7 +304,12 @@ func (c *Client) send(msg interface{}) error {
|
||||
// On exit (for any reason), it drains all pending Call waiters with a
|
||||
// synthetic error so that goroutines blocked in Call are unblocked.
|
||||
func (c *Client) readLoop() {
|
||||
// On exit (EOF/error from a crashed or closed server) mark the client not
|
||||
// running so subsequent Calls fail fast — otherwise they pass the running
|
||||
// guard, send to a dead stdin, and block until their own timeout. Then
|
||||
// unblock any already-in-flight waiters. LIFO: setRunning(false) runs first.
|
||||
defer c.drainPending()
|
||||
defer c.setRunning(false)
|
||||
|
||||
reader := bufio.NewReader(c.stdout)
|
||||
|
||||
@@ -380,13 +385,19 @@ func (c *Client) drainPending() {
|
||||
defer c.mu.Unlock()
|
||||
|
||||
for id, ch := range c.pending {
|
||||
ch <- &Response{
|
||||
// Non-blocking, matching the dispatch path: the channel is buffered(1)
|
||||
// and read once, but a duplicate-id race could have already filled it —
|
||||
// never block here while holding c.mu.
|
||||
select {
|
||||
case ch <- &Response{
|
||||
JSONRPC: "2.0",
|
||||
ID: id,
|
||||
Error: &ResponseError{
|
||||
Code: -32603, // InternalError
|
||||
Message: "LSP client readLoop terminated",
|
||||
},
|
||||
}:
|
||||
default:
|
||||
}
|
||||
delete(c.pending, id)
|
||||
}
|
||||
@@ -398,3 +409,10 @@ func (c *Client) IsRunning() bool {
|
||||
defer c.runningMu.RUnlock()
|
||||
return c.running
|
||||
}
|
||||
|
||||
// setRunning sets the running flag under its lock.
|
||||
func (c *Client) setRunning(v bool) {
|
||||
c.runningMu.Lock()
|
||||
c.running = v
|
||||
c.runningMu.Unlock()
|
||||
}
|
||||
|
||||
@@ -1,10 +1,48 @@
|
||||
package lsp
|
||||
|
||||
import (
|
||||
"context"
|
||||
"os/exec"
|
||||
"strings"
|
||||
"testing"
|
||||
"time"
|
||||
)
|
||||
|
||||
// TestClientMarksNotRunningOnProcessExit verifies crash detection: when the
|
||||
// underlying server process exits (stdout EOF), readLoop must flip the client to
|
||||
// not-running so later Calls fail fast instead of blocking until their timeout.
|
||||
func TestClientMarksNotRunningOnProcessExit(t *testing.T) {
|
||||
cmd := exec.Command("sh", "-c", "exit 0") // exits immediately, closing stdout
|
||||
c, err := NewClient(cmd)
|
||||
if err != nil {
|
||||
t.Skipf("cannot start helper process: %v", err)
|
||||
}
|
||||
t.Cleanup(func() { _ = c.Close() })
|
||||
|
||||
deadline := time.Now().Add(2 * time.Second)
|
||||
for c.IsRunning() && time.Now().Before(deadline) {
|
||||
time.Sleep(10 * time.Millisecond)
|
||||
}
|
||||
if c.IsRunning() {
|
||||
t.Fatal("client should be marked not running after its process exits")
|
||||
}
|
||||
|
||||
// A Call on the dead client must fail fast (the running guard), not hang.
|
||||
done := make(chan error, 1)
|
||||
go func() {
|
||||
_, callErr := c.Call(context.Background(), "test/method", nil)
|
||||
done <- callErr
|
||||
}()
|
||||
select {
|
||||
case callErr := <-done:
|
||||
if callErr == nil {
|
||||
t.Error("Call on a dead client should return an error")
|
||||
}
|
||||
case <-time.After(2 * time.Second):
|
||||
t.Error("Call on a dead client hung instead of failing fast")
|
||||
}
|
||||
}
|
||||
|
||||
// drainStderr retains a bounded tail of server stderr for diagnostics; capture
|
||||
// must keep recent bytes and never grow past StderrCaptureMax.
|
||||
func TestClientStderrCapture(t *testing.T) {
|
||||
|
||||
+14
-2
@@ -128,7 +128,11 @@ func (m *Manager) GetServer(ctx context.Context, lang protocol.Language) (*Manag
|
||||
srv, exists := m.servers[lang]
|
||||
m.mu.RUnlock()
|
||||
|
||||
if exists && srv.ready {
|
||||
// A cached server is only reusable if it is both initialized and still
|
||||
// running — a crashed language server stays in the map with ready==true, so
|
||||
// without the liveness check every later call would be routed to a dead
|
||||
// process and block until timeout.
|
||||
if exists && srv.ready && srv.client.IsRunning() {
|
||||
// Update lastUsed with server's own lock to avoid race condition
|
||||
srv.mu.Lock()
|
||||
srv.lastUsed = time.Now()
|
||||
@@ -141,13 +145,21 @@ func (m *Manager) GetServer(ctx context.Context, lang protocol.Language) (*Manag
|
||||
defer m.mu.Unlock()
|
||||
|
||||
// Double-check after acquiring write lock
|
||||
if srv, ok := m.servers[lang]; ok && srv.ready {
|
||||
if srv, ok := m.servers[lang]; ok && srv.ready && srv.client.IsRunning() {
|
||||
srv.mu.Lock()
|
||||
srv.lastUsed = time.Now()
|
||||
srv.mu.Unlock()
|
||||
return srv, nil
|
||||
}
|
||||
|
||||
// Evict a stale entry (crashed or never-ready) before spawning a replacement.
|
||||
// Its process has already exited, so close it asynchronously to avoid holding
|
||||
// m.mu on the (fast) cmd.Wait().
|
||||
if stale, ok := m.servers[lang]; ok {
|
||||
delete(m.servers, lang)
|
||||
go func() { _ = stale.client.Close() }()
|
||||
}
|
||||
|
||||
// Check if server config exists
|
||||
config, ok := DefaultServerConfigs[lang]
|
||||
if !ok {
|
||||
|
||||
@@ -16,6 +16,14 @@ func FindNodeAtPosition(tree *sitter.Tree, line, col int) *sitter.Node {
|
||||
return nil
|
||||
}
|
||||
|
||||
// Positions are 1-indexed; reject non-positive values so the uint32
|
||||
// conversion below can't underflow to a huge Row/Column (line/col == 0
|
||||
// would wrap to 4294967295). Reachable from the LSP-down AST fallback with
|
||||
// caller-supplied coordinates.
|
||||
if line < 1 || col < 1 {
|
||||
return nil
|
||||
}
|
||||
|
||||
// Convert to 0-indexed
|
||||
point := sitter.Point{
|
||||
Row: uint32(line - 1), // #nosec G115 - line numbers are bounded by file size
|
||||
|
||||
@@ -0,0 +1,45 @@
|
||||
package parser
|
||||
|
||||
import (
|
||||
"context"
|
||||
"testing"
|
||||
)
|
||||
|
||||
func TestFindNodeAtPosition(t *testing.T) {
|
||||
r := NewRegistry()
|
||||
content := []byte("package main\n\nfunc Hello() string { return \"hi\" }\n")
|
||||
res, err := r.Parse(context.Background(), "test.go", content)
|
||||
if err != nil {
|
||||
t.Fatalf("parse: %v", err)
|
||||
}
|
||||
|
||||
t.Run("nil tree", func(t *testing.T) {
|
||||
if FindNodeAtPosition(nil, 3, 6) != nil {
|
||||
t.Error("nil tree must return nil")
|
||||
}
|
||||
})
|
||||
|
||||
// Guard against the uint32 underflow: non-positive coordinates must return
|
||||
// nil, never wrap to a huge Row/Column.
|
||||
t.Run("non-positive coordinates", func(t *testing.T) {
|
||||
for _, c := range []struct{ line, col int }{{0, 0}, {0, 5}, {3, 0}, {-1, 4}} {
|
||||
if got := FindNodeAtPosition(res.Tree, c.line, c.col); got != nil {
|
||||
t.Errorf("FindNodeAtPosition(tree, %d, %d) = %v, want nil", c.line, c.col, got.Type())
|
||||
}
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("out of range line returns nil", func(t *testing.T) {
|
||||
if got := FindNodeAtPosition(res.Tree, 99999, 1); got != nil {
|
||||
t.Errorf("far-out-of-range line should return nil, got %v", got.Type())
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("valid position returns a node", func(t *testing.T) {
|
||||
// Line 3, column 6 lands inside "Hello".
|
||||
got := FindNodeAtPosition(res.Tree, 3, 6)
|
||||
if got == nil {
|
||||
t.Fatal("expected a node at a valid in-range position, got nil")
|
||||
}
|
||||
})
|
||||
}
|
||||
@@ -4,70 +4,45 @@ package parser
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
|
||||
"gopkg.in/yaml.v3"
|
||||
|
||||
"github.com/lukaszraczylo/mcp-filepuff/pkg/errors"
|
||||
"github.com/lukaszraczylo/mcp-filepuff/pkg/protocol"
|
||||
sitter "github.com/smacker/go-tree-sitter"
|
||||
)
|
||||
|
||||
// YAMLNode wraps yaml.Node to provide tree-sitter-like interface
|
||||
type YAMLNode struct {
|
||||
*yaml.Node
|
||||
Content []byte
|
||||
}
|
||||
|
||||
// JSONNode represents a JSON AST node
|
||||
type JSONNode struct {
|
||||
Value any
|
||||
Type string
|
||||
Children []*JSONNode
|
||||
Line int
|
||||
Column int
|
||||
}
|
||||
|
||||
// ParseYAML parses YAML content and returns a tree-sitter-compatible result
|
||||
// ParseYAML parses YAML content and returns a tree-sitter-compatible result.
|
||||
// YAML does not use tree-sitter, so the returned ParseResult.Tree is nil.
|
||||
func (r *Registry) ParseYAML(ctx context.Context, filename string, content []byte) (*ParseResult, error) {
|
||||
// Check file size
|
||||
if len(content) > maxFileSize {
|
||||
return nil, errors.NewFileTooLarge(filename, int64(len(content)), maxFileSize)
|
||||
}
|
||||
|
||||
// Parse YAML
|
||||
var root yaml.Node
|
||||
if err := yaml.Unmarshal(content, &root); err != nil {
|
||||
return nil, errors.NewParseError("yaml", filename, err)
|
||||
}
|
||||
|
||||
// Extract syntax errors from YAML parse
|
||||
syntaxErrors := extractYAMLErrors()
|
||||
|
||||
// Create a pseudo tree-sitter tree for compatibility
|
||||
// We'll use nil for the tree since YAML doesn't use tree-sitter
|
||||
return &ParseResult{
|
||||
Tree: nil, // YAML uses yaml.Node instead
|
||||
Tree: nil, // YAML uses yaml.Node instead of tree-sitter
|
||||
Language: protocol.LangYAML,
|
||||
Errors: syntaxErrors,
|
||||
Errors: extractYAMLErrors(),
|
||||
Content: content,
|
||||
}, nil
|
||||
}
|
||||
|
||||
// ParseJSON parses JSON content and returns a tree-sitter-compatible result
|
||||
// ParseJSON parses JSON content and returns a tree-sitter-compatible result.
|
||||
// JSON does not use tree-sitter, so the returned ParseResult.Tree is nil.
|
||||
func (r *Registry) ParseJSON(ctx context.Context, filename string, content []byte) (*ParseResult, error) {
|
||||
// Check file size
|
||||
if len(content) > maxFileSize {
|
||||
return nil, errors.NewFileTooLarge(filename, int64(len(content)), maxFileSize)
|
||||
}
|
||||
|
||||
// Parse JSON to validate syntax
|
||||
var jsonData any
|
||||
if err := json.Unmarshal(content, &jsonData); err != nil {
|
||||
return nil, errors.NewParseError("json", filename, err)
|
||||
}
|
||||
|
||||
// JSON parsing succeeded, no syntax errors
|
||||
return &ParseResult{
|
||||
Tree: nil, // JSON uses native Go structures
|
||||
Language: protocol.LangJSON,
|
||||
@@ -76,120 +51,8 @@ func (r *Registry) ParseJSON(ctx context.Context, filename string, content []byt
|
||||
}, nil
|
||||
}
|
||||
|
||||
// extractYAMLErrors extracts errors from YAML nodes
|
||||
// extractYAMLErrors returns YAML syntax errors. yaml.Unmarshal already reports
|
||||
// parse errors, so reaching here means the document was syntactically valid.
|
||||
func extractYAMLErrors() []SyntaxError {
|
||||
// YAML parser already validates during unmarshal
|
||||
// If we got here, there are no syntax errors
|
||||
// However, we could add semantic validation here in the future
|
||||
return []SyntaxError{}
|
||||
}
|
||||
|
||||
// WalkYAML walks a YAML AST and calls fn for each node
|
||||
func WalkYAML(node *yaml.Node, fn func(*yaml.Node) bool) {
|
||||
if node == nil || !fn(node) {
|
||||
return
|
||||
}
|
||||
|
||||
for _, child := range node.Content {
|
||||
WalkYAML(child, fn)
|
||||
}
|
||||
}
|
||||
|
||||
// GetYAMLNodeText returns the text representation of a YAML node
|
||||
func GetYAMLNodeText(node *yaml.Node) string {
|
||||
if node == nil {
|
||||
return ""
|
||||
}
|
||||
|
||||
switch node.Kind {
|
||||
case yaml.DocumentNode:
|
||||
if len(node.Content) > 0 {
|
||||
return GetYAMLNodeText(node.Content[0])
|
||||
}
|
||||
return ""
|
||||
case yaml.MappingNode:
|
||||
return node.Value
|
||||
case yaml.SequenceNode:
|
||||
return node.Value
|
||||
case yaml.ScalarNode:
|
||||
return node.Value
|
||||
case yaml.AliasNode:
|
||||
return node.Value
|
||||
default:
|
||||
return ""
|
||||
}
|
||||
}
|
||||
|
||||
// GetYAMLNodeLocation returns the location of a YAML node
|
||||
func GetYAMLNodeLocation(node *yaml.Node) protocol.Location {
|
||||
if node == nil {
|
||||
return protocol.Location{Line: 1, Column: 1}
|
||||
}
|
||||
|
||||
return protocol.Location{
|
||||
Line: node.Line,
|
||||
Column: node.Column,
|
||||
}
|
||||
}
|
||||
|
||||
// QueryYAML performs a simple query on YAML content
|
||||
// Example: "$.metadata.name" to find the name field in metadata
|
||||
func QueryYAML(content []byte, query string) ([]*yaml.Node, error) {
|
||||
var root yaml.Node
|
||||
if err := yaml.Unmarshal(content, &root); err != nil {
|
||||
return nil, fmt.Errorf("failed to parse YAML: %w", err)
|
||||
}
|
||||
|
||||
// Simple path-based query implementation
|
||||
// This is a basic implementation - can be extended with more sophisticated queries
|
||||
var results []*yaml.Node
|
||||
|
||||
WalkYAML(&root, func(node *yaml.Node) bool {
|
||||
if node.Value == query || node.Tag == query {
|
||||
results = append(results, node)
|
||||
}
|
||||
return true
|
||||
})
|
||||
|
||||
return results, nil
|
||||
}
|
||||
|
||||
// QueryJSON performs a simple query on JSON content
|
||||
func QueryJSON(content []byte, query string) ([]any, error) {
|
||||
var data any
|
||||
if err := json.Unmarshal(content, &data); err != nil {
|
||||
return nil, fmt.Errorf("failed to parse JSON: %w", err)
|
||||
}
|
||||
|
||||
// Basic implementation - can be extended with JSONPath support
|
||||
var results []any
|
||||
|
||||
// For now, just validate that it's valid JSON
|
||||
results = append(results, data)
|
||||
|
||||
return results, nil
|
||||
}
|
||||
|
||||
// ValidateYAML validates YAML content without parsing to full AST
|
||||
func ValidateYAML(content []byte) error {
|
||||
var node yaml.Node
|
||||
if err := yaml.Unmarshal(content, &node); err != nil {
|
||||
return fmt.Errorf("YAML validation failed: %w", err)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// ValidateJSON validates JSON content
|
||||
func ValidateJSON(content []byte) error {
|
||||
var data any
|
||||
if err := json.Unmarshal(content, &data); err != nil {
|
||||
return fmt.Errorf("JSON validation failed: %w", err)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// ToSitterTree is a placeholder that returns nil for YAML/JSON
|
||||
// These formats don't use tree-sitter, but we keep this for interface compatibility
|
||||
func (yn *YAMLNode) ToSitterTree() *sitter.Tree {
|
||||
return nil
|
||||
}
|
||||
|
||||
@@ -4,8 +4,6 @@ import (
|
||||
"context"
|
||||
"testing"
|
||||
|
||||
"gopkg.in/yaml.v3"
|
||||
|
||||
"github.com/lukaszraczylo/mcp-filepuff/pkg/protocol"
|
||||
)
|
||||
|
||||
@@ -202,82 +200,3 @@ version: 1.0.0`)
|
||||
t.Errorf("expected YAML language for .yml extension, got %s", ymlResult.Language)
|
||||
}
|
||||
}
|
||||
|
||||
func TestWalkYAML(t *testing.T) {
|
||||
content := []byte(`metadata:
|
||||
name: test
|
||||
labels:
|
||||
app: myapp
|
||||
env: prod`)
|
||||
|
||||
var root yaml.Node
|
||||
if err := yaml.Unmarshal(content, &root); err != nil {
|
||||
t.Fatalf("failed to parse YAML: %v", err)
|
||||
}
|
||||
|
||||
nodeCount := 0
|
||||
WalkYAML(&root, func(node *yaml.Node) bool {
|
||||
nodeCount++
|
||||
return true
|
||||
})
|
||||
|
||||
if nodeCount == 0 {
|
||||
t.Error("expected to visit nodes, but count is 0")
|
||||
}
|
||||
}
|
||||
|
||||
func TestValidateYAML(t *testing.T) {
|
||||
tests := []struct {
|
||||
name string
|
||||
content []byte
|
||||
shouldError bool
|
||||
}{
|
||||
{
|
||||
name: "valid YAML",
|
||||
content: []byte("name: test\nvalue: 100"),
|
||||
shouldError: false,
|
||||
},
|
||||
{
|
||||
name: "invalid YAML",
|
||||
content: []byte("name: test\n bad:\n[unclosed"),
|
||||
shouldError: true,
|
||||
},
|
||||
}
|
||||
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
err := ValidateYAML(tt.content)
|
||||
if (err != nil) != tt.shouldError {
|
||||
t.Errorf("ValidateYAML() error = %v, shouldError = %v", err, tt.shouldError)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestValidateJSON(t *testing.T) {
|
||||
tests := []struct {
|
||||
name string
|
||||
content []byte
|
||||
shouldError bool
|
||||
}{
|
||||
{
|
||||
name: "valid JSON",
|
||||
content: []byte(`{"name": "test", "value": 100}`),
|
||||
shouldError: false,
|
||||
},
|
||||
{
|
||||
name: "invalid JSON",
|
||||
content: []byte(`{"name": "test", "value": 100`),
|
||||
shouldError: true,
|
||||
},
|
||||
}
|
||||
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
err := ValidateJSON(tt.content)
|
||||
if (err != nil) != tt.shouldError {
|
||||
t.Errorf("ValidateJSON() error = %v, shouldError = %v", err, tt.shouldError)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
@@ -509,11 +509,15 @@ func formatResults(results []MatchResult, maxResults int, format string, offset
|
||||
case "compact":
|
||||
for i := 0; i < renderCount; i++ {
|
||||
r := results[i]
|
||||
firstLine := firstLineOf(r.Text, 80)
|
||||
if !verbose {
|
||||
fmt.Fprintf(&sb, "%s:%d %s\n", r.File, r.Location.Line, firstLine)
|
||||
continue
|
||||
}
|
||||
nodeType := "unknown"
|
||||
if r.Node != nil {
|
||||
nodeType = r.Node.Type()
|
||||
}
|
||||
firstLine := firstLineOf(r.Text, 80)
|
||||
fmt.Fprintf(&sb, "%s:%d (%s) %s\n", r.File, r.Location.Line, nodeType, firstLine)
|
||||
}
|
||||
|
||||
|
||||
@@ -258,6 +258,17 @@ func (s *Searcher) validatePaths(paths []string) error {
|
||||
WithRemediation("Provide paths within the workspace root")
|
||||
}
|
||||
|
||||
// resultPathAllowed reports whether a ripgrep result path is within the
|
||||
// workspace root. ripgrep runs with Dir=WorkspaceRoot and emits paths relative
|
||||
// to it, so a relative path is joined to the root before the check; IsPathAllowed
|
||||
// then resolves symlinks, rejecting any path that --follow led outside the root.
|
||||
func (s *Searcher) resultPathAllowed(p string) bool {
|
||||
if !filepath.IsAbs(p) {
|
||||
p = filepath.Join(s.cfg.WorkspaceRoot, p)
|
||||
}
|
||||
return s.cfg.IsPathAllowed(p)
|
||||
}
|
||||
|
||||
// parseOutput parses ripgrep JSON output.
|
||||
func (s *Searcher) parseOutput(output *bytes.Buffer, maxResults int) (*SearchResults, error) {
|
||||
results := &SearchResults{
|
||||
@@ -288,6 +299,14 @@ func (s *Searcher) parseOutput(output *bytes.Buffer, maxResults int) (*SearchRes
|
||||
continue
|
||||
}
|
||||
|
||||
// Defense in depth: never surface a file outside the workspace root.
|
||||
// ripgrep with --follow (default on) can traverse a symlink whose
|
||||
// target lives outside the root; dropping those matches keeps search
|
||||
// from leaking external file content.
|
||||
if !s.resultPathAllowed(match.Path.Text) {
|
||||
continue
|
||||
}
|
||||
|
||||
// Check max results
|
||||
if maxResults > 0 && len(results.Results) >= maxResults {
|
||||
results.Truncated = true
|
||||
|
||||
@@ -402,3 +402,45 @@ func TestSearchEmptyPattern(t *testing.T) {
|
||||
t.Error("expected error for empty pattern")
|
||||
}
|
||||
}
|
||||
|
||||
// TestSearchSymlinkEscapeNotLeaked is a security regression guard: with
|
||||
// FollowSymlinks on (the default), ripgrep can traverse a symlink whose target
|
||||
// is outside the workspace root. Such matches must never be returned — search
|
||||
// must not leak the content of files outside the workspace.
|
||||
func TestSearchSymlinkEscapeNotLeaked(t *testing.T) {
|
||||
const secret = "SUPERSECRETLEAKCANARY"
|
||||
|
||||
outside := t.TempDir()
|
||||
if err := os.WriteFile(filepath.Join(outside, "secret.txt"), []byte(secret+"\n"), 0o600); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
workspace := t.TempDir()
|
||||
// A regular in-workspace file that does NOT contain the secret.
|
||||
if err := os.WriteFile(filepath.Join(workspace, "ok.txt"), []byte("nothing here\n"), 0o600); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
// A symlink inside the workspace pointing at the outside directory.
|
||||
if err := os.Symlink(outside, filepath.Join(workspace, "escape")); err != nil {
|
||||
t.Skipf("symlinks unsupported: %v", err)
|
||||
}
|
||||
|
||||
cfg := config.Default()
|
||||
cfg.WorkspaceRoot = workspace
|
||||
if !cfg.FollowSymlinks {
|
||||
t.Fatal("precondition: FollowSymlinks should default true so rg --follow is exercised")
|
||||
}
|
||||
logger := slog.New(slog.NewTextHandler(os.Stderr, &slog.HandlerOptions{Level: slog.LevelError}))
|
||||
s, err := New(cfg, logger)
|
||||
if err != nil {
|
||||
t.Skip("ripgrep not installed")
|
||||
}
|
||||
|
||||
results, err := s.Search(context.Background(), &Request{Pattern: secret})
|
||||
if err != nil {
|
||||
t.Fatalf("search: %v", err)
|
||||
}
|
||||
for _, r := range results.Results {
|
||||
t.Errorf("symlink escape leaked an out-of-workspace match: file=%q text=%q", r.File, r.MatchText)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -213,6 +213,20 @@ func TestASTQuerySkipsDependencyDirs(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
// TestRelPath verifies workspace-relative display paths (token saving) with a
|
||||
// safe fallback to the original path for anything outside the root.
|
||||
func TestRelPath(t *testing.T) {
|
||||
srv, tmpDir := newFeaturesServer(t)
|
||||
if got := srv.relPath(filepath.Join(tmpDir, "pkg", "a.go")); got != filepath.Join("pkg", "a.go") {
|
||||
t.Errorf("in-workspace path should be relativized, got %q", got)
|
||||
}
|
||||
// Outside the workspace root → returned unchanged (never a ../ escape string).
|
||||
outside := "/definitely/outside/x.go"
|
||||
if got := srv.relPath(outside); got != outside {
|
||||
t.Errorf("out-of-workspace path should be returned as-is, got %q", got)
|
||||
}
|
||||
}
|
||||
|
||||
// ---- Feature 3 (ast_query): pagination cursor ----
|
||||
|
||||
// TestASTQueryFirstPageCursor is a strict regression guard: page 1 must emit a
|
||||
|
||||
@@ -60,7 +60,7 @@ func (s *Server) resolveASTQueryParams(request mcp.CallToolRequest) (*astQueryPa
|
||||
prefsMaxResults = sp.DefaultMaxResults
|
||||
prefsFormat = sp.ASTQueryFormat
|
||||
}
|
||||
p.maxResults = effectiveInt(request, "max_results", prefsMaxResults, 100)
|
||||
p.maxResults = effectiveInt(request, "max_results", prefsMaxResults, 50)
|
||||
|
||||
p.format = request.GetString("format", "")
|
||||
if p.format == "" {
|
||||
@@ -263,15 +263,7 @@ func (s *Server) generateASTSummary(ctx context.Context, path string, content []
|
||||
|
||||
var sb strings.Builder
|
||||
|
||||
// Get relative path
|
||||
relPath := path
|
||||
if absPath, err := filepath.Abs(path); err == nil {
|
||||
if rel, err := filepath.Rel(s.cfg.WorkspaceRoot, absPath); err == nil && !strings.HasPrefix(rel, "..") {
|
||||
relPath = rel
|
||||
}
|
||||
}
|
||||
|
||||
fmt.Fprintf(&sb, "**%s** (%d lines, %s)\n\n", relPath, countLines(content), lang)
|
||||
fmt.Fprintf(&sb, "**%s** (%d lines, %s)\n\n", s.relPath(path), countLines(content), lang)
|
||||
sb.WriteString("Symbols:\n")
|
||||
|
||||
for _, sym := range symbols {
|
||||
@@ -282,6 +274,18 @@ func (s *Server) generateASTSummary(ctx context.Context, path string, content []
|
||||
return sb.String()
|
||||
}
|
||||
|
||||
// relPath renders path relative to the workspace root for display, saving the
|
||||
// tokens an absolute path would cost. Falls back to the original path when it
|
||||
// can't be relativized (outside the root, or on error).
|
||||
func (s *Server) relPath(path string) string {
|
||||
if absPath, err := filepath.Abs(path); err == nil {
|
||||
if rel, err := filepath.Rel(s.cfg.WorkspaceRoot, absPath); err == nil && !strings.HasPrefix(rel, "..") {
|
||||
return rel
|
||||
}
|
||||
}
|
||||
return path
|
||||
}
|
||||
|
||||
// symbolKindIcon returns an icon/prefix for a symbol kind.
|
||||
func symbolKindIcon(kind protocol.SymbolKind) string {
|
||||
switch kind {
|
||||
|
||||
@@ -51,7 +51,7 @@ func (s *Server) handleFileSearch(ctx context.Context, request mcp.CallToolReque
|
||||
fileTypes := request.GetStringSlice("file_types", nil)
|
||||
ignoreCase := request.GetBool("ignore_case", false)
|
||||
regex := request.GetBool("regex", true)
|
||||
contextLines := request.GetInt("context_lines", 2)
|
||||
contextLines := request.GetInt("context_lines", 0)
|
||||
|
||||
// Consult session prefs for max_results and cluster when not explicitly supplied.
|
||||
prefs := s.sessionPrefs.Load()
|
||||
@@ -326,9 +326,13 @@ type lineNumberOpts struct {
|
||||
// resolveLineNumberOpts resolves per-call vs session-pref line-number options.
|
||||
func (s *Server) resolveLineNumberOpts(request mcp.CallToolRequest) lineNumberOpts {
|
||||
opts := lineNumberOpts{
|
||||
noLineNumbers: request.GetBool("no_line_numbers", false),
|
||||
lineInterval: request.GetInt("line_number_interval", 1),
|
||||
compactLineNums: request.GetBool("compact_line_numbers", false),
|
||||
noLineNumbers: request.GetBool("no_line_numbers", false),
|
||||
lineInterval: request.GetInt("line_number_interval", 1),
|
||||
// Compact prefix ("12│content") is the default — it drops the 4-wide
|
||||
// padding and trailing space of the legacy " 12│ " form, saving ~3
|
||||
// chars/line with no information loss. Pass compact_line_numbers=false
|
||||
// (or mode/line_numbers="full") to restore the padded alignment.
|
||||
compactLineNums: request.GetBool("compact_line_numbers", true),
|
||||
}
|
||||
|
||||
// Apply session line_numbers pref when no explicit per-call override was supplied.
|
||||
|
||||
@@ -589,19 +589,23 @@ func TestCompactLineNumbers(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestCompactLineNumbersOffByDefault(t *testing.T) {
|
||||
func TestCompactLineNumbersDefaultAndOptOut(t *testing.T) {
|
||||
tmpDir := t.TempDir()
|
||||
srv := newTestServer(t, tmpDir)
|
||||
|
||||
content := "line one\nline two\n"
|
||||
f := writeFile(t, tmpDir, "test.txt", content)
|
||||
|
||||
// Default: no compact_line_numbers
|
||||
out := callRead(t, srv, map[string]interface{}{"path": f})
|
||||
// Default is now the compact prefix (token-saving): "1│line one".
|
||||
def := callRead(t, srv, map[string]interface{}{"path": f})
|
||||
if !strings.Contains(def, "1│line one") || strings.Contains(def, " 1│ line one") {
|
||||
t.Errorf("default should use compact prefix, got:\n%s", def)
|
||||
}
|
||||
|
||||
// Should have padded format
|
||||
if !strings.Contains(out, " 1│ line one") {
|
||||
t.Errorf("default should have padded format, got:\n%s", out)
|
||||
// Explicit opt-out restores the padded alignment.
|
||||
padded := callRead(t, srv, map[string]interface{}{"path": f, "compact_line_numbers": false})
|
||||
if !strings.Contains(padded, " 1│ line one") {
|
||||
t.Errorf("compact_line_numbers=false should restore padded format, got:\n%s", padded)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -679,9 +683,9 @@ func Hello() {
|
||||
if !strings.Contains(out, `println("hello")`) {
|
||||
t.Errorf("full mode missing body, got:\n%s", out)
|
||||
}
|
||||
// Padded line numbers
|
||||
if !strings.Contains(out, " 1│ package main") {
|
||||
t.Errorf("default should have padded line numbers, got:\n%s", out)
|
||||
// Default line numbers use the compact prefix.
|
||||
if !strings.Contains(out, "1│package main") {
|
||||
t.Errorf("default should have compact line numbers, got:\n%s", out)
|
||||
}
|
||||
// etag present
|
||||
if !strings.Contains(out, "[etag:") {
|
||||
|
||||
@@ -74,12 +74,14 @@ func (s *Server) handleLSPQuery(ctx context.Context, request mcp.CallToolRequest
|
||||
|
||||
case "references":
|
||||
includeDecl := request.GetBool("include_declaration", true)
|
||||
// compact: explicit call-time > session compact_refs pref > false
|
||||
// compact: explicit call-time > session compact_refs pref > true.
|
||||
// Compact (one line per file) is the default — it preserves every
|
||||
// line:col pair while cutting ~one line per reference for the LLM consumer.
|
||||
var prefsCompact *bool
|
||||
if sp := s.sessionPrefs.Load(); sp != nil {
|
||||
prefsCompact = sp.CompactRefs
|
||||
}
|
||||
compact := effectiveBool(request, "compact", prefsCompact, false)
|
||||
compact := effectiveBool(request, "compact", prefsCompact, true)
|
||||
return s.lspReferences(ctx, file, line, col, includeDecl, compact, verbose)
|
||||
|
||||
default:
|
||||
@@ -181,7 +183,7 @@ func (s *Server) lspDefinition(ctx context.Context, file string, line, col int,
|
||||
|
||||
for _, loc := range locations {
|
||||
filePath := lsp.URIToFile(loc.URI)
|
||||
fmt.Fprintf(&output, "**%s:%d:%d**\n", filePath, loc.Range.Start.Line+1, loc.Range.Start.Character+1)
|
||||
fmt.Fprintf(&output, "**%s:%d:%d**\n", s.relPath(filePath), loc.Range.Start.Line+1, loc.Range.Start.Character+1)
|
||||
|
||||
preview := s.readFilePreview(filePath, loc.Range.Start.Line+1, 3)
|
||||
if preview != "" {
|
||||
@@ -210,7 +212,9 @@ func (s *Server) lspReferences(ctx context.Context, file string, line, col int,
|
||||
fileGroups := make(map[string][]lsp.Location)
|
||||
fileOrder := make([]string, 0)
|
||||
for _, loc := range locations {
|
||||
filePath := lsp.URIToFile(loc.URI)
|
||||
// Relativize for display; references are not re-read so the absolute
|
||||
// path isn't needed downstream.
|
||||
filePath := s.relPath(lsp.URIToFile(loc.URI))
|
||||
if _, seen := fileGroups[filePath]; !seen {
|
||||
fileOrder = append(fileOrder, filePath)
|
||||
}
|
||||
|
||||
@@ -49,7 +49,7 @@ const helpFileSearch = "# file_search — flags and examples\n\n" +
|
||||
"| `cluster=true` | Coalesce consecutive match lines into ranges (`L12-14│ text`). Drops context lines for density. |\n" +
|
||||
"| `cursor` | Opaque pagination token from a previous truncated response — fetches next page. |\n" +
|
||||
"| `max_results` | Page size for pagination. Re-run with `cursor` to get next page. |\n" +
|
||||
"| `context_lines` | Number of context lines around matches (default: 2). |\n" +
|
||||
"| `context_lines` | Number of context lines around matches (default: 0). |\n" +
|
||||
"| `ignore_case` | Case-insensitive search. |\n" +
|
||||
"| `regex` | Treat pattern as regex (default: true). |\n" +
|
||||
"| `file_types` | Restrict to file extensions, e.g. `[\"go\", \"ts\"]`. |\n" +
|
||||
@@ -77,7 +77,7 @@ const helpASTQuery = "# ast_query — flags and examples\n\n" +
|
||||
"| `verbose=true` | Emit `Found N match(es):` preamble (v1 behaviour). Default: false. |\n" +
|
||||
"| `format` | `compact` (default, one line per match) \\| `verbose` (full code+captures) \\| `location` (file:line only) |\n" +
|
||||
"| `cursor` | Opaque pagination token from a previous truncated response — fetches next page. |\n" +
|
||||
"| `max_results` | Page size (default: 100). |\n" +
|
||||
"| `max_results` | Page size (default: 50). |\n" +
|
||||
"| `name_exact` | Exact symbol name to match. |\n" +
|
||||
"| `name_matches` | Regex pattern to filter by name. |\n" +
|
||||
"| `kind_in` | Node types to match (e.g. `function_declaration`, `class_declaration`). |\n" +
|
||||
|
||||
@@ -54,7 +54,7 @@ func New(cfg *config.Config, logger *slog.Logger) (*Server, error) {
|
||||
logger: logger,
|
||||
parser: parserRegistry,
|
||||
matcher: query.NewMatcher(parserRegistry),
|
||||
editor: edit.NewEngine(parserRegistry),
|
||||
editor: edit.NewEngineWithValidator(parserRegistry, cfg.IsPathAllowed),
|
||||
readSem: make(chan struct{}, MaxConcurrentReads),
|
||||
querySem: make(chan struct{}, MaxConcurrentQueries),
|
||||
}
|
||||
@@ -136,7 +136,7 @@ func (s *Server) registerTools() {
|
||||
mcp.Description("Treat pattern as regex (default: true)"),
|
||||
),
|
||||
mcp.WithNumber("context_lines",
|
||||
mcp.Description("Number of context lines around matches (default: 2)"),
|
||||
mcp.Description("Number of context lines around matches (default: 0)"),
|
||||
),
|
||||
mcp.WithNumber("max_results",
|
||||
mcp.Description("Maximum number of results to return (page size for pagination)"),
|
||||
@@ -206,7 +206,7 @@ func (s *Server) registerTools() {
|
||||
mcp.Description("Print line numbers only every N lines (default: 1 = every line). E.g. 10 = anchor every 10th line plus first/last. 0 = no line numbers."),
|
||||
),
|
||||
mcp.WithBoolean("compact_line_numbers",
|
||||
mcp.Description("Use compact line prefix '12\u2502' instead of ' 12\u2502 ' (no padding, no trailing space). Works with line_number_interval. Default off."),
|
||||
mcp.Description("Use compact line prefix '12\u2502' instead of padded ' 12\u2502 ' (no padding, no trailing space). Works with line_number_interval. Default true; set false to restore padded alignment."),
|
||||
),
|
||||
mcp.WithBoolean("collapse_blank_lines",
|
||||
mcp.Description("Collapse runs of consecutive blank lines to a single blank line. Useful for token savings on heavily-spaced code."),
|
||||
@@ -252,7 +252,7 @@ func (s *Server) registerTools() {
|
||||
mcp.WithStringItems(),
|
||||
),
|
||||
mcp.WithNumber("max_results",
|
||||
mcp.Description("Maximum number of results to return (default: 100, page size for pagination)"),
|
||||
mcp.Description("Maximum number of results to return (default: 50, page size for pagination)"),
|
||||
),
|
||||
mcp.WithString("format",
|
||||
mcp.Description("Output format: \"compact\" (default, one line per match), \"verbose\" (full code+captures), \"location\" (file:line only)"),
|
||||
@@ -299,7 +299,7 @@ func (s *Server) registerTools() {
|
||||
mcp.Description("Include the declaration in results. Only valid for action=references (default: true)."),
|
||||
),
|
||||
mcp.WithBoolean("compact",
|
||||
mcp.Description("Compact output: one line per file with all refs in brackets. Only valid for action=references. Default: false."),
|
||||
mcp.Description("Compact output: one line per file with all refs in brackets. Only valid for action=references. Default: true; set false for one line per reference."),
|
||||
),
|
||||
mcp.WithBoolean("verbose",
|
||||
mcp.Description("Emit count/header preamble. Applies to all actions. Default: false."),
|
||||
|
||||
@@ -48,7 +48,7 @@ func TestSessionPrefsFileReadLineNumbersNone(t *testing.T) {
|
||||
t.Fatalf("handleFileRead: %v", err)
|
||||
}
|
||||
text := result.Content[0].(mcp.TextContent).Text
|
||||
// Standard line-number format is " 1│ "; with no_line_numbers it's absent.
|
||||
// line_numbers=none suppresses the prefix entirely (no "│" gutter at all).
|
||||
if strings.Contains(text, " 1│") {
|
||||
t.Errorf("session line_numbers=none: expected no line-number prefix, got:\n%s", text)
|
||||
}
|
||||
@@ -87,7 +87,7 @@ func TestSessionPrefsFileReadLineNumbersCompact(t *testing.T) {
|
||||
req.Params.Arguments = map[string]interface{}{"path": testFile}
|
||||
result, _ := srv.handleFileRead(ctx, req)
|
||||
text := result.Content[0].(mcp.TextContent).Text
|
||||
// Standard padded prefix is " 1│ "; compact is "1│".
|
||||
// Padded prefix is " 1│ "; compact is "1│".
|
||||
if strings.Contains(text, " 1\u2502") {
|
||||
t.Errorf("session line_numbers=compact should use compact prefix, got:\n%s", text)
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user