From 50d933c0c3c0f743986ae816138ed9880139d56e Mon Sep 17 00:00:00 2001 From: Minsu Lee Date: Fri, 19 Dec 2025 12:30:11 +0900 Subject: [PATCH 1/9] feat(dora): add ast-grep provider for AST-aware code search --- packages/dora/src/providers/ast-grep/index.ts | 1 + 1 file changed, 1 insertion(+) create mode 100644 packages/dora/src/providers/ast-grep/index.ts diff --git a/packages/dora/src/providers/ast-grep/index.ts b/packages/dora/src/providers/ast-grep/index.ts new file mode 100644 index 0000000..087c849 --- /dev/null +++ b/packages/dora/src/providers/ast-grep/index.ts @@ -0,0 +1 @@ +// AST-grep provider implementation - WIP From a374f68a5d70a8a5fd686a4f4a7b85318d3d8bb1 Mon Sep 17 00:00:00 2001 From: Minsu Lee Date: Fri, 19 Dec 2025 13:18:31 +0900 Subject: [PATCH 2/9] feat(dora): add ast-grep provider for AST-aware code search and transformation - Add AstGrepProvider with 4 tools: - ast_grep_search: Search code patterns across files (25 languages) - ast_grep_replace: Replace patterns with AST-aware rewriting - ast_grep_analyze: In-memory analysis via NAPI (5 languages) - ast_grep_transform: In-memory transformation via NAPI - Implement auto-download of ast-grep binary from GitHub releases - Platform detection (win/linux/osx, x64/arm64) - Version tracking and caching in ~/.cache/dora/ast-grep/ - System PATH detection with binary verification - Add agent skill for writing ast-grep patterns and YAML rules - Meta-variable syntax guide ($VAR, $$$, _) - YAML rule structure with relational/composite operators - Common pattern examples for JS/TS, Python, Go - Create ADR documenting architecture decision Closes #34 --- agents/ast-grep.md | 315 +++++++++++++++ .../0001-ast-grep-integration-architecture.md | 130 ++++++ packages/dora/src/providers/ast-grep/cli.ts | 247 ++++++++++++ .../dora/src/providers/ast-grep/constants.ts | 160 ++++++++ .../dora/src/providers/ast-grep/downloader.ts | 229 +++++++++++ packages/dora/src/providers/ast-grep/index.ts | 377 +++++++++++++++++- packages/dora/src/providers/ast-grep/napi.ts | 200 ++++++++++ packages/dora/src/providers/ast-grep/types.ts | 123 ++++++ packages/dora/src/providers/ast-grep/utils.ts | 148 +++++++ packages/dora/src/providers/index.ts | 1 + packages/dora/src/server.ts | 10 +- packages/dora/test/ast-grep-provider.test.ts | 278 +++++++++++++ 12 files changed, 2216 insertions(+), 2 deletions(-) create mode 100644 agents/ast-grep.md create mode 100644 docs/adr/0001-ast-grep-integration-architecture.md create mode 100644 packages/dora/src/providers/ast-grep/cli.ts create mode 100644 packages/dora/src/providers/ast-grep/constants.ts create mode 100644 packages/dora/src/providers/ast-grep/downloader.ts create mode 100644 packages/dora/src/providers/ast-grep/napi.ts create mode 100644 packages/dora/src/providers/ast-grep/types.ts create mode 100644 packages/dora/src/providers/ast-grep/utils.ts create mode 100644 packages/dora/test/ast-grep-provider.test.ts diff --git a/agents/ast-grep.md b/agents/ast-grep.md new file mode 100644 index 0000000..d9b4fb5 --- /dev/null +++ b/agents/ast-grep.md @@ -0,0 +1,315 @@ +--- +name: ast-grep +description: | + Use this agent when users need to perform AST-aware code searches, write structural code patterns, create ast-grep rules, or perform code transformations using syntax tree matching. This agent helps write correct ast-grep patterns and YAML rules for finding code structures like "functions without error handling" or "React components using specific hooks." + + Examples: + + + Context: User wants to find all console.log statements. + user: "Find all console.log calls in my codebase" + assistant: "I'll use the ast-grep agent to create a pattern for finding console.log calls." + + Simple pattern search - use ast_grep_search with pattern "console.log($MSG)" + + + + + Context: User wants to find async functions without try-catch. + user: "Find async functions that don't have error handling" + assistant: "I'll use the ast-grep agent to write a YAML rule that finds async functions without try-catch blocks." + + Complex structural query requiring relational rules - need to use `has` and `not` operators. + + + + + Context: User wants to replace deprecated API calls. + user: "Replace all axios.get calls with fetch" + assistant: "I'll use the ast-grep agent to create a replacement pattern." + + Code transformation - use ast_grep_replace with pattern and rewrite. + + + + + Context: User is debugging an ast-grep pattern that returns no results. + user: "Why isn't my pattern 'function $NAME:' matching Python functions?" + assistant: "I'll use the ast-grep agent to help debug the pattern - Python functions need 'def' keyword and patterns shouldn't include trailing colons." + + Pattern debugging - common mistake with Python syntax. + + +tools: Bash, Read, Write +model: sonnet +--- + +# AST-GREP PATTERN EXPERT + +You are an expert at writing ast-grep patterns and rules for structural code search. + +--- + +## CORE CONCEPTS + +### What is ast-grep? + +ast-grep performs **syntax-aware pattern matching** using Abstract Syntax Trees (AST). Unlike text-based search (grep/ripgrep), it understands code structure. + +**Key differences from text search:** +- Matches code semantics, not text +- Ignores whitespace and formatting +- Can match across multiple lines +- Understands language syntax + +### Meta-variables + +Meta-variables capture parts of the AST: + +| Syntax | Meaning | Example | +|--------|---------|---------| +| `$VAR` | Single named node (identifier, expression) | `console.log($MSG)` | +| `$$VAR` | Unnamed node (operators, punctuation) | `$A $$OP $B` | +| `$$$MULTI` | Zero or more nodes (non-greedy) | `function $NAME($$$ARGS)` | +| `_` | Wildcard (anonymous, non-capturing) | `if ($_) { $$$ }` | + +**Naming rules:** Use UPPERCASE letters, numbers, underscores only. + +--- + +## PATTERN SYNTAX + +### Simple Patterns + +```bash +# Find console.log calls +ast-grep run --pattern 'console.log($MSG)' --lang javascript + +# Find function definitions +ast-grep run --pattern 'function $NAME($$$) { $$$ }' --lang javascript + +# Find Python class definitions +ast-grep run --pattern 'class $NAME' --lang python +``` + +### Pattern Rules + +1. **Patterns must be valid code** - they're parsed as AST +2. **No trailing colons in Python** - `def $NAME($$$)` not `def $NAME($$$):` +3. **Include body for functions** - `function $NAME($$$) { $$$ }` not `function $NAME` + +--- + +## YAML RULE SYNTAX + +For complex queries, use YAML rules with operators. + +### Rule Structure + +```yaml +id: rule-name +language: javascript +rule: + # Rule definition here +message: "Explanation of what was found" +``` + +### Atomic Rules + +| Rule | Purpose | Example | +|------|---------|---------| +| `pattern` | Match AST pattern | `pattern: console.log($MSG)` | +| `kind` | Match node type | `kind: function_declaration` | +| `regex` | Match text with regex | `regex: "^test_"` | +| `nthChild` | Match by position | `nthChild: 1` | + +### Relational Rules + +| Rule | Purpose | Example | +|------|---------|---------| +| `inside` | Node is inside another | `inside: { kind: class_body }` | +| `has` | Node contains another | `has: { pattern: await $X }` | +| `precedes` | Node comes before | `precedes: { kind: return_statement }` | +| `follows` | Node comes after | `follows: { kind: import_statement }` | + +**Important:** Always use `stopBy: end` for relational rules to search the entire subtree. + +### Composite Rules + +| Rule | Purpose | Example | +|------|---------|---------| +| `all` | AND - all must match | `all: [rule1, rule2]` | +| `any` | OR - any must match | `any: [rule1, rule2]` | +| `not` | Negate a rule | `not: { pattern: ... }` | +| `matches` | Reference utility rule | `matches: utility-rule-id` | + +--- + +## COMMON PATTERNS BY LANGUAGE + +### JavaScript/TypeScript + +```yaml +# Find async functions without try-catch +id: async-without-try +language: javascript +rule: + kind: function_declaration + has: + pattern: async + not: + has: + kind: try_statement + stopBy: end + +# Find React useState without dependency +id: usestate-in-component +language: tsx +rule: + pattern: useState($INIT) + inside: + kind: function_declaration + stopBy: end + +# Find console.log in production code +id: no-console-log +language: javascript +rule: + pattern: console.log($$$) + not: + inside: + regex: "test|spec|__tests__" + kind: string +``` + +### Python + +```yaml +# Find functions without docstrings +id: missing-docstring +language: python +rule: + kind: function_definition + not: + has: + kind: expression_statement + has: + kind: string + nthChild: 1 + stopBy: end + +# Find bare except clauses +id: bare-except +language: python +rule: + kind: except_clause + not: + has: + kind: identifier +``` + +### Go + +```yaml +# Find error not checked +id: unchecked-error +language: go +rule: + pattern: $_, $ERR := $CALL + not: + precedes: + pattern: if $ERR != nil + stopBy: neighbor +``` + +--- + +## DEBUGGING PATTERNS + +### Check AST structure + +```bash +# Dump CST (Concrete Syntax Tree) +ast-grep run --pattern '$ROOT' --debug-query=cst --lang javascript file.js + +# Dump AST +ast-grep run --pattern '$ROOT' --debug-query=ast --lang javascript file.js + +# Show pattern parse result +ast-grep run --pattern 'console.log($X)' --debug-query=pattern --lang javascript file.js +``` + +### Common Mistakes + +| Mistake | Problem | Fix | +|---------|---------|-----| +| `def $NAME():` | Trailing colon in Python | `def $NAME($$$)` | +| `function $NAME` | Incomplete pattern | `function $NAME($$$) { $$$ }` | +| `$a` | Lowercase meta-var | `$A` (uppercase) | +| Missing `stopBy: end` | Only searches immediate children | Add `stopBy: end` to relational rules | + +--- + +## USING MCP TOOLS + +### ast_grep_search + +Search for patterns across files: + +```json +{ + "pattern": "console.log($MSG)", + "lang": "javascript", + "paths": ["./src"], + "globs": ["!**/node_modules/**"] +} +``` + +Or with YAML rule file: + +```json +{ + "ruleFile": "./rules/no-console.yaml", + "lang": "javascript", + "paths": ["./src"] +} +``` + +### ast_grep_replace + +Transform code (dry-run by default): + +```json +{ + "pattern": "console.log($MSG)", + "rewrite": "logger.info($MSG)", + "lang": "javascript", + "paths": ["./src"], + "dryRun": true +} +``` + +--- + +## WORKFLOW + +1. **Understand the query** - What code structure are you looking for? +2. **Create example code** - Write sample code that matches what you want to find +3. **Write the pattern/rule** - Start simple, add complexity as needed +4. **Test the pattern** - Use `--debug-query` to verify AST structure +5. **Run the search** - Apply to codebase + +--- + +## SUPPORTED LANGUAGES (25) + +bash, c, cpp, csharp, css, elixir, go, haskell, html, java, javascript, json, kotlin, lua, nix, php, python, ruby, rust, scala, solidity, swift, typescript, tsx, yaml + +--- + +## REFERENCES + +- [ast-grep Documentation](https://ast-grep.github.io/) +- [Pattern Syntax](https://ast-grep.github.io/guide/pattern-syntax.html) +- [Rule Configuration](https://ast-grep.github.io/reference/rule.html) +- [Playground](https://ast-grep.github.io/playground.html) diff --git a/docs/adr/0001-ast-grep-integration-architecture.md b/docs/adr/0001-ast-grep-integration-architecture.md new file mode 100644 index 0000000..3da8c87 --- /dev/null +++ b/docs/adr/0001-ast-grep-integration-architecture.md @@ -0,0 +1,130 @@ +# ADR-0001: ast-grep Integration Architecture + +## Status + +Accepted + +## Context + +Dora MCP server needs AST-aware code search and transformation capabilities. Unlike text-based search (grep/ripgrep), AST-aware search understands code structure, enabling queries like "find async functions without error handling" or "find React components using a specific hook." + +### Requirements +- Support 25+ programming languages +- Pattern-based search with meta-variables (`$VAR`, `$$$`) +- Code transformation/replacement capabilities +- YAML rule file support for complex queries +- Integration with existing dora provider architecture + +### Reference Implementation +- `ref/oh-my-opencode/src/tools/ast-grep/` - MCP tools implementation +- https://github.com/ast-grep/claude-skill - Official Claude skill for rule writing + +## Decision + +We will implement **both MCP Tools and an Agent/Skill** for ast-grep integration: + +### 1. MCP Tools (Provider) + +Create a new `ast-grep` provider in `packages/dora/src/providers/ast-grep/` with: + +| Tool | Purpose | +|------|---------| +| `ast_grep_search` | Pattern-based code search across files | +| `ast_grep_replace` | AST-aware code transformation (dry-run by default) | + +**Features:** +- Both inline patterns (`console.log($MSG)`) and YAML rule files (`--rule file.yaml`) +- CLI binary auto-download from GitHub releases (following Dart LSP pattern) +- NAPI bindings (`@ast-grep/napi`) for faster in-memory transforms (5 languages) +- Result truncation (max matches, output size, timeout) +- Helpful hints for common pattern mistakes + +### 2. Agent/Skill + +Create `agents/ast-grep.md` following the existing `librarian.md` pattern: + +- Teaches Claude complex YAML rule writing +- Covers relational rules (`inside`, `has`, `precedes`, `follows`) +- Covers composite rules (`all`, `any`, `not`) +- Reference documentation for ast-grep syntax +- Examples for common use cases + +### Architecture + +``` +packages/dora/ +└── src/providers/ast-grep/ + ├── index.ts # AstGrepProvider implementation + ├── cli.ts # CLI wrapper (runSg function) + ├── downloader.ts # Binary auto-download + ├── napi.ts # NAPI bindings for in-memory transforms + ├── constants.ts # Languages, defaults, platform config + ├── types.ts # TypeScript interfaces + └── utils.ts # Result formatting + +agents/ +└── ast-grep.md # Rule writing skill/agent +``` + +### Binary Management + +Follow the Dart LSP pattern: +1. Check system PATH first (`Bun.which('sg')`) +2. If not found, download to `~/.cache/dora/ast-grep/` +3. Platform-specific binaries (win-x64, linux-x64, linux-arm64, osx-x64, osx-arm64) +4. Version tracking via marker file + +## Consequences + +### Positive +- **Comprehensive**: Both programmatic tools (MCP) and guidance (skill) for different use cases +- **Consistent**: Follows existing provider and binary download patterns in dora +- **Flexible**: CLI for 25 languages, NAPI for faster in-memory transforms (5 languages) +- **User-friendly**: Auto-download binary, helpful error hints +- **Maintainable**: Modular structure matches reference implementation + +### Negative +- **Complexity**: Two integration points (provider + agent) to maintain +- **Dependencies**: NAPI adds `@ast-grep/napi` as optional dependency +- **Binary size**: Auto-downloaded binary adds ~15-20MB to user's cache + +### Neutral +- **Platform support**: Same 5 platforms as existing LSP servers +- **Version updates**: Manual version bumps in constants.ts (same as Dart/Kotlin LSP) + +## Alternatives Considered + +### 1. MCP Tools Only (No Skill) + +**Rejected because:** +- Complex YAML rules are hard to write without guidance +- Official ast-grep recommends skill approach for rule development +- Pattern mistakes are common; skill provides best practices + +### 2. Skill Only (No MCP Tools) + +**Rejected because:** +- Less structured API for programmatic use +- No result formatting, truncation, error handling +- Doesn't leverage dora's provider architecture + +### 3. CLI Only (No NAPI) + +**Considered but expanded because:** +- NAPI provides significantly faster in-memory transforms +- 5 languages (JS/TS/TSX/CSS/HTML) cover most web development +- Small additional complexity for significant performance gain + +### 4. Separate Package (`packages/ast-grep`) + +**Rejected because:** +- ast-grep tools are tightly coupled with dora MCP server +- No reuse case outside of dora context +- Provider pattern keeps tools organized within dora + +## References + +- [ast-grep Documentation](https://ast-grep.github.io/) +- [ast-grep Prompting Guide](https://ast-grep.github.io/advanced/prompting.html) +- [ast-grep Claude Skill](https://github.com/ast-grep/claude-skill) +- [oh-my-opencode ast-grep implementation](ref/oh-my-opencode/src/tools/ast-grep/) diff --git a/packages/dora/src/providers/ast-grep/cli.ts b/packages/dora/src/providers/ast-grep/cli.ts new file mode 100644 index 0000000..95ef8e4 --- /dev/null +++ b/packages/dora/src/providers/ast-grep/cli.ts @@ -0,0 +1,247 @@ +/** + * CLI wrapper for ast-grep + * + * Executes sg CLI commands and parses results + */ + +import { spawn } from 'bun' +import { existsSync } from 'fs' +import { + CLI_LANGUAGES, + DEFAULT_MAX_MATCHES, + DEFAULT_MAX_OUTPUT_BYTES, + DEFAULT_TIMEOUT_MS, +} from './constants' +import { ensureAstGrepBinary, getInstallInstructions } from './downloader' +import type { CliMatch, RunSgOptions, SgResult } from './types' + +// Cached binary path +let resolvedCliPath: string | null = null + +/** + * Get ast-grep binary path + */ +export async function getAstGrepPath(): Promise { + if (resolvedCliPath !== null && existsSync(resolvedCliPath)) { + return resolvedCliPath + } + + const binaryPath = await ensureAstGrepBinary() + if (binaryPath) { + resolvedCliPath = binaryPath + } + + return resolvedCliPath +} + +/** + * Check if CLI is available + */ +export async function isCliAvailable(): Promise { + const path = await getAstGrepPath() + return path !== null && existsSync(path) +} + +/** + * Run ast-grep CLI command + */ +export async function runSg(options: RunSgOptions): Promise { + const cliPath = await getAstGrepPath() + + if (!cliPath) { + return { + matches: [], + totalMatches: 0, + truncated: false, + error: getInstallInstructions(), + } + } + + // Validate language + if (!CLI_LANGUAGES.includes(options.lang)) { + return { + matches: [], + totalMatches: 0, + truncated: false, + error: `Unsupported language: ${options.lang}\nSupported: ${CLI_LANGUAGES.join(', ')}`, + } + } + + // Build command arguments + const args: string[] = [] + + if (options.ruleFile) { + // Use rule file + args.push('scan', '--rule', options.ruleFile, '--json=compact') + } + else { + // Use inline pattern + args.push('run', '-p', options.pattern, '--lang', options.lang, '--json=compact') + } + + // Add rewrite if specified + if (options.rewrite) { + args.push('-r', options.rewrite) + if (options.updateAll) { + args.push('--update-all') + } + } + + // Add context lines + if (options.context && options.context > 0) { + args.push('-C', String(options.context)) + } + + // Add glob filters + if (options.globs) { + for (const glob of options.globs) { + args.push('--globs', glob) + } + } + + // Add paths (default to current directory) + const paths = options.paths && options.paths.length > 0 ? options.paths : ['.'] + args.push(...paths) + + // Spawn process + const timeout = DEFAULT_TIMEOUT_MS + + const proc = spawn([cliPath, ...args], { + stdout: 'pipe', + stderr: 'pipe', + }) + + // Set up timeout + const timeoutPromise = new Promise((_, reject) => { + const id = setTimeout(() => { + proc.kill() + reject(new Error(`Search timeout after ${timeout}ms`)) + }, timeout) + proc.exited.then(() => clearTimeout(id)) + }) + + let stdout: string + let stderr: string + let exitCode: number + + try { + stdout = await Promise.race([new Response(proc.stdout).text(), timeoutPromise]) + stderr = await new Response(proc.stderr).text() + exitCode = await proc.exited + } + catch (e) { + const error = e as Error + if (error.message?.includes('timeout')) { + return { + matches: [], + totalMatches: 0, + truncated: true, + truncatedReason: 'timeout', + error: error.message, + } + } + + const nodeError = e as NodeJS.ErrnoException + if ( + nodeError.code === 'ENOENT' + || nodeError.message?.includes('ENOENT') + || nodeError.message?.includes('not found') + ) { + // Binary not found, try to download + const downloadedPath = await ensureAstGrepBinary() + if (downloadedPath) { + resolvedCliPath = downloadedPath + return runSg(options) // Retry + } + else { + return { + matches: [], + totalMatches: 0, + truncated: false, + error: getInstallInstructions(), + } + } + } + + return { + matches: [], + totalMatches: 0, + truncated: false, + error: `Failed to spawn ast-grep: ${error.message}`, + } + } + + // Handle exit code + if (exitCode !== 0 && stdout.trim() === '') { + if (stderr.includes('No files found')) { + return { matches: [], totalMatches: 0, truncated: false } + } + if (stderr.trim()) { + return { matches: [], totalMatches: 0, truncated: false, error: stderr.trim() } + } + return { matches: [], totalMatches: 0, truncated: false } + } + + // No output + if (!stdout.trim()) { + return { matches: [], totalMatches: 0, truncated: false } + } + + // Check if output was truncated + const outputTruncated = stdout.length >= DEFAULT_MAX_OUTPUT_BYTES + const outputToProcess = outputTruncated ? stdout.substring(0, DEFAULT_MAX_OUTPUT_BYTES) : stdout + + // Parse JSON output + let matches: CliMatch[] = [] + try { + matches = JSON.parse(outputToProcess) as CliMatch[] + } + catch { + if (outputTruncated) { + // Try to parse partial JSON + try { + const lastValidIndex = outputToProcess.lastIndexOf('}') + if (lastValidIndex > 0) { + const bracketIndex = outputToProcess.lastIndexOf('},', lastValidIndex) + if (bracketIndex > 0) { + const truncatedJson = `${outputToProcess.substring(0, bracketIndex + 1)}]` + matches = JSON.parse(truncatedJson) as CliMatch[] + } + } + } + catch { + return { + matches: [], + totalMatches: 0, + truncated: true, + truncatedReason: 'max_output_bytes', + error: 'Output too large and could not be parsed', + } + } + } + else { + return { matches: [], totalMatches: 0, truncated: false } + } + } + + // Apply match limit + const totalMatches = matches.length + const matchesTruncated = totalMatches > DEFAULT_MAX_MATCHES + const finalMatches = matchesTruncated ? matches.slice(0, DEFAULT_MAX_MATCHES) : matches + + const result: SgResult = { + matches: finalMatches, + totalMatches, + truncated: outputTruncated || matchesTruncated, + } + + // Only add truncatedReason if truncation occurred + if (outputTruncated) { + result.truncatedReason = 'max_output_bytes' + } + else if (matchesTruncated) { + result.truncatedReason = 'max_matches' + } + + return result +} diff --git a/packages/dora/src/providers/ast-grep/constants.ts b/packages/dora/src/providers/ast-grep/constants.ts new file mode 100644 index 0000000..371b43d --- /dev/null +++ b/packages/dora/src/providers/ast-grep/constants.ts @@ -0,0 +1,160 @@ +/** + * Constants for ast-grep provider + */ + +import * as os from 'os' +import * as path from 'path' +import type { PlatformConfig, PlatformId } from './types' + +// CLI supported languages (25 total) +export const CLI_LANGUAGES = [ + 'bash', + 'c', + 'cpp', + 'csharp', + 'css', + 'elixir', + 'go', + 'haskell', + 'html', + 'java', + 'javascript', + 'json', + 'kotlin', + 'lua', + 'nix', + 'php', + 'python', + 'ruby', + 'rust', + 'scala', + 'solidity', + 'swift', + 'typescript', + 'tsx', + 'yaml', +] as const + +// NAPI supported languages (5 total - native bindings) +export const NAPI_LANGUAGES = ['html', 'javascript', 'tsx', 'css', 'typescript'] as const + +// Language to file extensions mapping +export const LANG_EXTENSIONS: Record = { + bash: ['.bash', '.sh', '.zsh', '.bats'], + c: ['.c', '.h'], + cpp: ['.cpp', '.cc', '.cxx', '.hpp', '.hxx', '.h'], + csharp: ['.cs'], + css: ['.css'], + elixir: ['.ex', '.exs'], + go: ['.go'], + haskell: ['.hs', '.lhs'], + html: ['.html', '.htm'], + java: ['.java'], + javascript: ['.js', '.jsx', '.mjs', '.cjs'], + json: ['.json'], + kotlin: ['.kt', '.kts'], + lua: ['.lua'], + nix: ['.nix'], + php: ['.php'], + python: ['.py', '.pyi'], + ruby: ['.rb', '.rake'], + rust: ['.rs'], + scala: ['.scala', '.sc'], + solidity: ['.sol'], + swift: ['.swift'], + typescript: ['.ts', '.cts', '.mts'], + tsx: ['.tsx'], + yaml: ['.yml', '.yaml'], +} + +// Default configuration +export const DEFAULT_TIMEOUT_MS = 300_000 // 5 minutes +export const DEFAULT_MAX_OUTPUT_BYTES = 1 * 1024 * 1024 // 1MB +export const DEFAULT_MAX_MATCHES = 500 + +// ast-grep binary version +export const AST_GREP_VERSION = '0.40.3' + +// GitHub release URL pattern +const GITHUB_RELEASE_BASE = 'https://github.com/ast-grep/ast-grep/releases/download' + +// Platform-specific binary configurations +// Note: Release asset names use "app-" prefix, e.g., app-x86_64-unknown-linux-gnu.zip +// The binary inside the archive is named "ast-grep" (or "ast-grep.exe" on Windows) +export const PLATFORM_CONFIGS: Record = { + 'win-x64': { + url: `${GITHUB_RELEASE_BASE}/${AST_GREP_VERSION}/app-x86_64-pc-windows-msvc.zip`, + binaryPath: 'ast-grep.exe', + }, + 'linux-x64': { + url: `${GITHUB_RELEASE_BASE}/${AST_GREP_VERSION}/app-x86_64-unknown-linux-gnu.zip`, + binaryPath: 'ast-grep', + }, + 'linux-arm64': { + url: `${GITHUB_RELEASE_BASE}/${AST_GREP_VERSION}/app-aarch64-unknown-linux-gnu.zip`, + binaryPath: 'ast-grep', + }, + 'osx-x64': { + url: `${GITHUB_RELEASE_BASE}/${AST_GREP_VERSION}/app-x86_64-apple-darwin.zip`, + binaryPath: 'ast-grep', + }, + 'osx-arm64': { + url: `${GITHUB_RELEASE_BASE}/${AST_GREP_VERSION}/app-aarch64-apple-darwin.zip`, + binaryPath: 'ast-grep', + }, +} + +/** + * Get platform identifier for current system + */ +export function getPlatformId(): PlatformId | undefined { + const platform = process.platform + const arch = process.arch + + const platformMap: Record = { + win32: 'win', + darwin: 'osx', + linux: 'linux', + } + + const archMap: Record = { + x64: 'x64', + arm64: 'arm64', + } + + const platformKey = platformMap[platform] + const archKey = archMap[arch] + + if (!platformKey || !archKey) { + return undefined + } + + return `${platformKey}-${archKey}` as PlatformId +} + +/** + * Get cache directory for ast-grep binary + */ +export function getAstGrepCacheDir(): string { + return path.join(os.homedir(), '.cache', 'dora', 'ast-grep') +} + +/** + * Get expected binary path in cache + */ +export function getCachedBinaryPath(): string { + const platformId = getPlatformId() + if (!platformId) { + return 'sg' // Fallback to PATH + } + + const config = PLATFORM_CONFIGS[platformId] + return path.join(getAstGrepCacheDir(), config.binaryPath) +} + +/** + * Get version marker file path + */ +export function getVersionMarkerPath(): string { + return path.join(getAstGrepCacheDir(), '.installed_version') +} diff --git a/packages/dora/src/providers/ast-grep/downloader.ts b/packages/dora/src/providers/ast-grep/downloader.ts new file mode 100644 index 0000000..ff1883c --- /dev/null +++ b/packages/dora/src/providers/ast-grep/downloader.ts @@ -0,0 +1,229 @@ +/** + * Binary downloader for ast-grep CLI + * + * Downloads platform-specific binary from GitHub releases + * Caches to ~/.cache/dora/ast-grep/ + */ + +import { existsSync, mkdirSync, chmodSync, unlinkSync, writeFileSync, readFileSync } from 'fs' +import { spawn } from 'bun' +import { + AST_GREP_VERSION, + PLATFORM_CONFIGS, + getAstGrepCacheDir, + getCachedBinaryPath, + getPlatformId, + getVersionMarkerPath, +} from './constants' +import type { PlatformId } from './types' + +/** + * Verify a binary is actually ast-grep by checking --version output + */ +async function verifyAstGrepBinary(binaryPath: string): Promise { + try { + const proc = spawn([binaryPath, '--version'], { + stdout: 'pipe', + stderr: 'pipe', + }) + const stdout = await new Response(proc.stdout).text() + await proc.exited + + // ast-grep version output contains "ast-grep" or version number like "0.x.x" + return stdout.includes('ast-grep') || /^\d+\.\d+\.\d+/.test(stdout.trim()) + } + catch { + return false + } +} + +/** + * Check if binary exists in system PATH and is actually ast-grep + * Checks both 'ast-grep' and 'sg' (common symlink name) + */ +export async function findSystemBinary(): Promise { + // Check 'ast-grep' first (newer name) + const astGrepPath = Bun.which('ast-grep') + if (astGrepPath && existsSync(astGrepPath) && await verifyAstGrepBinary(astGrepPath)) { + return astGrepPath + } + + // Check 'sg' (common symlink/alias) + const sgPath = Bun.which('sg') + if (sgPath && existsSync(sgPath) && await verifyAstGrepBinary(sgPath)) { + return sgPath + } + + return null +} + +/** + * Check if cached binary exists and matches version + */ +export function getCachedBinary(): string | null { + const binaryPath = getCachedBinaryPath() + const versionPath = getVersionMarkerPath() + + if (!existsSync(binaryPath)) { + return null + } + + // Check version marker + if (existsSync(versionPath)) { + try { + const installedVersion = readFileSync(versionPath, 'utf-8').trim() + if (installedVersion !== AST_GREP_VERSION) { + // Version mismatch, need to re-download + return null + } + } + catch { + // Can't read version, assume outdated + return null + } + } + + return binaryPath +} + +/** + * Extract zip archive + */ +async function extractZip(archivePath: string, destDir: string): Promise { + const proc + = process.platform === 'win32' + ? spawn( + [ + 'powershell', + '-command', + `Expand-Archive -Path '${archivePath}' -DestinationPath '${destDir}' -Force`, + ], + { stdout: 'pipe', stderr: 'pipe' }, + ) + : spawn(['unzip', '-o', archivePath, '-d', destDir], { stdout: 'pipe', stderr: 'pipe' }) + + const exitCode = await proc.exited + + if (exitCode !== 0) { + const stderr = await new Response(proc.stderr).text() + const toolHint + = process.platform === 'win32' + ? 'Ensure PowerShell is available on your system.' + : 'Please install \'unzip\' (e.g., apt install unzip, brew install unzip).' + throw new Error(`zip extraction failed (exit ${exitCode}): ${stderr}\n\n${toolHint}`) + } +} + +/** + * Download ast-grep binary from GitHub releases + */ +export async function downloadAstGrepBinary(platformId?: PlatformId): Promise { + const platform = platformId ?? getPlatformId() + if (!platform) { + console.error('[dora] Unsupported platform for ast-grep binary download') + return null + } + + const config = PLATFORM_CONFIGS[platform] + if (!config) { + console.error(`[dora] No binary configuration for platform: ${platform}`) + return null + } + + const cacheDir = getAstGrepCacheDir() + const binaryPath = getCachedBinaryPath() + + // Check if already downloaded + if (existsSync(binaryPath)) { + const versionPath = getVersionMarkerPath() + if (existsSync(versionPath)) { + const installedVersion = readFileSync(versionPath, 'utf-8').trim() + if (installedVersion === AST_GREP_VERSION) { + return binaryPath + } + } + } + + console.log(`[dora] Downloading ast-grep binary v${AST_GREP_VERSION}...`) + + try { + // Create cache directory + if (!existsSync(cacheDir)) { + mkdirSync(cacheDir, { recursive: true }) + } + + // Download archive + const response = await fetch(config.url, { redirect: 'follow' }) + + if (!response.ok) { + throw new Error(`HTTP ${response.status}: ${response.statusText}`) + } + + const archivePath = `${cacheDir}/sg-download.zip` + const arrayBuffer = await response.arrayBuffer() + await Bun.write(archivePath, arrayBuffer) + + // Extract archive + await extractZip(archivePath, cacheDir) + + // Clean up archive + if (existsSync(archivePath)) { + unlinkSync(archivePath) + } + + // Set executable permission on Unix + if (process.platform !== 'win32' && existsSync(binaryPath)) { + chmodSync(binaryPath, 0o755) + } + + // Write version marker + writeFileSync(getVersionMarkerPath(), AST_GREP_VERSION) + + console.log(`[dora] ast-grep binary ready at ${binaryPath}`) + + return binaryPath + } + catch (err) { + console.error(`[dora] Failed to download ast-grep: ${err instanceof Error ? err.message : err}`) + return null + } +} + +/** + * Ensure ast-grep binary is available + * + * Checks in order: + * 1. System PATH + * 2. Cached binary + * 3. Download if needed + */ +export async function ensureAstGrepBinary(): Promise { + // 1. Check system PATH first + const systemPath = await findSystemBinary() + if (systemPath) { + return systemPath + } + + // 2. Check cached binary + const cachedPath = getCachedBinary() + if (cachedPath) { + return cachedPath + } + + // 3. Download + return downloadAstGrepBinary() +} + +/** + * Get install instructions for manual installation + */ +export function getInstallInstructions(): string { + return `ast-grep CLI binary not found. + +Install options: + bun add -D @ast-grep/cli + cargo install ast-grep --locked + brew install ast-grep + +Or let dora auto-download on first use.` +} diff --git a/packages/dora/src/providers/ast-grep/index.ts b/packages/dora/src/providers/ast-grep/index.ts index 087c849..f799311 100644 --- a/packages/dora/src/providers/ast-grep/index.ts +++ b/packages/dora/src/providers/ast-grep/index.ts @@ -1 +1,376 @@ -// AST-grep provider implementation - WIP +/** + * ast-grep Provider for dora MCP server + * + * Provides AST-aware code search and transformation tools + */ + +import { z } from 'zod' +import type { Provider, ToolDefinition, ToolResult } from '../provider' +import type { RegistryConfig } from '../registry' +import { CLI_LANGUAGES, NAPI_LANGUAGES } from './constants' +import { runSg, isCliAvailable } from './cli' +import { isNapiAvailable, getNapiError, analyzeCode, transformCode } from './napi' +import { formatSearchResult, formatReplaceResult, formatAnalyzeResult, formatTransformResult, getEmptyResultHint } from './utils' +import type { NapiLanguage } from './types' + +// Tool definitions +const AST_GREP_TOOLS: ToolDefinition[] = [ + { + name: 'ast_grep_search', + description: + 'Search code patterns across filesystem using AST-aware matching. Supports 25 languages. ' + + 'Use meta-variables: $VAR (single node), $$$ (multiple nodes). ' + + 'IMPORTANT: Patterns must be complete AST nodes (valid code). ' + + 'For functions, include params and body: \'export async function $NAME($$$) { $$$ }\' not \'export async function $NAME\'. ' + + 'Examples: \'console.log($MSG)\', \'def $FUNC($$$)\', \'async function $NAME($$$)\'', + inputSchema: z.object({ + pattern: z + .string() + .describe('AST pattern with meta-variables ($VAR, $$$). Must be complete AST node.'), + lang: z + .enum(CLI_LANGUAGES) + .describe('Target language'), + paths: z + .array(z.string()) + .optional() + .describe('Paths to search (default: [\'.\'])'), + globs: z + .array(z.string()) + .optional() + .describe('Include/exclude globs (prefix ! to exclude)'), + context: z + .number() + .optional() + .describe('Context lines around match'), + ruleFile: z + .string() + .optional() + .describe('Path to YAML rule file (alternative to pattern)'), + }), + }, + { + name: 'ast_grep_replace', + description: + 'Replace code patterns across filesystem with AST-aware rewriting. ' + + 'Dry-run by default. Use meta-variables in rewrite to preserve matched content. ' + + 'Example: pattern=\'console.log($MSG)\' rewrite=\'logger.info($MSG)\'', + inputSchema: z.object({ + pattern: z + .string() + .describe('AST pattern to match'), + rewrite: z + .string() + .describe('Replacement pattern (can use $VAR from pattern)'), + lang: z + .enum(CLI_LANGUAGES) + .describe('Target language'), + paths: z + .array(z.string()) + .optional() + .describe('Paths to search'), + globs: z + .array(z.string()) + .optional() + .describe('Include/exclude globs'), + dryRun: z + .boolean() + .optional() + .describe('Preview changes without applying (default: true)'), + }), + }, + { + name: 'ast_grep_analyze', + description: + 'Analyze code in-memory using NAPI (faster, no file I/O). ' + + 'Supports 5 languages: html, javascript, tsx, css, typescript. ' + + 'Returns matches with optional meta-variable extraction. ' + + 'Use for single-file analysis or code transformation preview.', + inputSchema: z.object({ + code: z + .string() + .describe('Source code to analyze'), + pattern: z + .string() + .describe('AST pattern to match'), + lang: z + .enum(NAPI_LANGUAGES) + .describe('Target language (html, javascript, tsx, css, typescript)'), + extractMetaVars: z + .boolean() + .optional() + .describe('Extract meta-variable values (default: false)'), + }), + }, + { + name: 'ast_grep_transform', + description: + 'Transform code in-memory using NAPI (faster, no file I/O). ' + + 'Supports 5 languages: html, javascript, tsx, css, typescript. ' + + 'Returns transformed code without modifying files.', + inputSchema: z.object({ + code: z + .string() + .describe('Source code to transform'), + pattern: z + .string() + .describe('AST pattern to match'), + rewrite: z + .string() + .describe('Replacement pattern'), + lang: z + .enum(NAPI_LANGUAGES) + .describe('Target language (html, javascript, tsx, css, typescript)'), + }), + }, +] + +/** + * ast-grep Provider implementation + */ +export class AstGrepProvider implements Provider { + readonly name = 'ast-grep' + private connected = false + + constructor(_config: RegistryConfig) { + // Config reserved for future use (e.g., custom cache paths) + } + + async connect(): Promise { + // Pre-check CLI availability (don't fail, just log) + const cliAvailable = await isCliAvailable() + if (!cliAvailable) { + console.log('[ast-grep] CLI not found, will download on first use') + } + + // Check NAPI availability + const napiAvailable = isNapiAvailable() + if (!napiAvailable) { + const error = getNapiError() + console.log(`[ast-grep] NAPI not available: ${error ?? 'unknown'}`) + console.log('[ast-grep] In-memory tools (analyze/transform) disabled') + } + + this.connected = true + } + + async disconnect(): Promise { + this.connected = false + } + + isConnected(): boolean { + return this.connected + } + + listTools(): ToolDefinition[] { + // Filter out NAPI tools if not available + if (!isNapiAvailable()) { + return AST_GREP_TOOLS.filter( + t => t.name !== 'ast_grep_analyze' && t.name !== 'ast_grep_transform', + ) + } + return AST_GREP_TOOLS + } + + async callTool(name: string, args: unknown): Promise { + switch (name) { + case 'ast_grep_search': + return this.handleSearch(args) + case 'ast_grep_replace': + return this.handleReplace(args) + case 'ast_grep_analyze': + return this.handleAnalyze(args) + case 'ast_grep_transform': + return this.handleTransform(args) + default: + return { + content: [{ type: 'text', text: `Unknown tool: ${name}` }], + isError: true, + } + } + } + + private async handleSearch(args: unknown): Promise { + try { + const parsed = z + .object({ + pattern: z.string(), + lang: z.enum(CLI_LANGUAGES), + paths: z.array(z.string()).optional(), + globs: z.array(z.string()).optional(), + context: z.number().optional(), + ruleFile: z.string().optional(), + }) + .parse(args) + + const result = await runSg({ + pattern: parsed.pattern, + lang: parsed.lang, + ...(parsed.paths && { paths: parsed.paths }), + ...(parsed.globs && { globs: parsed.globs }), + ...(parsed.context !== undefined && { context: parsed.context }), + ...(parsed.ruleFile && { ruleFile: parsed.ruleFile }), + }) + + let output = formatSearchResult(result) + + // Add hint for empty results + if (result.matches.length === 0 && !result.error) { + const hint = getEmptyResultHint(parsed.pattern, parsed.lang) + if (hint) { + output += `\n\n${hint}` + } + } + + return { + content: [{ type: 'text', text: output }], + isError: !!result.error, + } + } + catch (e) { + return { + content: [{ type: 'text', text: `Error: ${e instanceof Error ? e.message : String(e)}` }], + isError: true, + } + } + } + + private async handleReplace(args: unknown): Promise { + try { + const parsed = z + .object({ + pattern: z.string(), + rewrite: z.string(), + lang: z.enum(CLI_LANGUAGES), + paths: z.array(z.string()).optional(), + globs: z.array(z.string()).optional(), + dryRun: z.boolean().optional(), + }) + .parse(args) + + const isDryRun = parsed.dryRun !== false // Default to true + + const result = await runSg({ + pattern: parsed.pattern, + rewrite: parsed.rewrite, + lang: parsed.lang, + ...(parsed.paths && { paths: parsed.paths }), + ...(parsed.globs && { globs: parsed.globs }), + updateAll: !isDryRun, + }) + + const output = formatReplaceResult(result, isDryRun) + + return { + content: [{ type: 'text', text: output }], + isError: !!result.error, + } + } + catch (e) { + return { + content: [{ type: 'text', text: `Error: ${e instanceof Error ? e.message : String(e)}` }], + isError: true, + } + } + } + + private async handleAnalyze(args: unknown): Promise { + try { + if (!isNapiAvailable()) { + const error = getNapiError() + return { + content: [{ + type: 'text', + text: `NAPI not available: ${error ?? 'unknown'}\n` + + 'Install with: bun add -D @ast-grep/napi\n\n' + + 'Use ast_grep_search for file-based search instead.', + }], + isError: true, + } + } + + const parsed = z + .object({ + code: z.string(), + pattern: z.string(), + lang: z.enum(NAPI_LANGUAGES), + extractMetaVars: z.boolean().optional(), + }) + .parse(args) + + const results = analyzeCode( + parsed.code, + parsed.lang as NapiLanguage, + parsed.pattern, + parsed.extractMetaVars ?? false, + ) + + const output = formatAnalyzeResult(results, parsed.extractMetaVars ?? false) + + return { + content: [{ type: 'text', text: output }], + } + } + catch (e) { + return { + content: [{ type: 'text', text: `Error: ${e instanceof Error ? e.message : String(e)}` }], + isError: true, + } + } + } + + private async handleTransform(args: unknown): Promise { + try { + if (!isNapiAvailable()) { + const error = getNapiError() + return { + content: [{ + type: 'text', + text: `NAPI not available: ${error ?? 'unknown'}\n` + + 'Install with: bun add -D @ast-grep/napi\n\n' + + 'Use ast_grep_replace for file-based replacement instead.', + }], + isError: true, + } + } + + const parsed = z + .object({ + code: z.string(), + pattern: z.string(), + rewrite: z.string(), + lang: z.enum(NAPI_LANGUAGES), + }) + .parse(args) + + const result = transformCode( + parsed.code, + parsed.lang as NapiLanguage, + parsed.pattern, + parsed.rewrite, + ) + + const output = formatTransformResult(parsed.code, result.transformed, result.editCount) + + return { + content: [{ type: 'text', text: output }], + } + } + catch (e) { + return { + content: [{ type: 'text', text: `Error: ${e instanceof Error ? e.message : String(e)}` }], + isError: true, + } + } + } +} + +/** + * Factory function to create AstGrepProvider + */ +export function createAstGrepProvider(config: RegistryConfig): AstGrepProvider { + return new AstGrepProvider(config) +} + +// Re-export types and utilities +export { CLI_LANGUAGES, NAPI_LANGUAGES } from './constants' +export type { CliLanguage, NapiLanguage, SgResult } from './types' diff --git a/packages/dora/src/providers/ast-grep/napi.ts b/packages/dora/src/providers/ast-grep/napi.ts new file mode 100644 index 0000000..4558665 --- /dev/null +++ b/packages/dora/src/providers/ast-grep/napi.ts @@ -0,0 +1,200 @@ +/** + * NAPI bindings for ast-grep (in-memory transforms) + * + * Supports 5 languages: html, javascript, tsx, css, typescript + * Provides faster in-memory analysis and transformation without spawning CLI + */ + +import { NAPI_LANGUAGES } from './constants' +import type { AnalyzeResult, MetaVariable, NapiLanguage, Range } from './types' + +// eslint-disable-next-line ts/no-explicit-any -- Runtime-loaded optional dependency +type AstGrepNapiModule = any +// eslint-disable-next-line ts/no-explicit-any -- AST node from NAPI module +type SgNode = any + +// Dynamic import for @ast-grep/napi (optional dependency) +let napiModule: AstGrepNapiModule | null = null +let napiLoadError: Error | null = null + +/** + * Check if NAPI is available + */ +export function isNapiAvailable(): boolean { + if (napiModule !== null) return true + if (napiLoadError !== null) return false + + try { + // eslint-disable-next-line ts/no-require-imports + napiModule = require('@ast-grep/napi') + return true + } + catch (e) { + napiLoadError = e instanceof Error ? e : new Error(String(e)) + return false + } +} + +/** + * Get NAPI load error if any + */ +export function getNapiError(): string | null { + return napiLoadError?.message ?? null +} + +/** + * Language to NAPI Lang enum mapping + */ +function getLangEnum(lang: NapiLanguage): unknown { + if (!napiModule) { + throw new Error('NAPI module not loaded') + } + + const { Lang } = napiModule + const langMap: Record = { + html: Lang.Html, + javascript: Lang.JavaScript, + tsx: Lang.Tsx, + css: Lang.Css, + typescript: Lang.TypeScript, + } + + return langMap[lang] +} + +/** + * Parse code using NAPI + */ +export function parseCode(code: string, lang: NapiLanguage) { + if (!isNapiAvailable()) { + throw new Error( + `@ast-grep/napi not available: ${napiLoadError?.message ?? 'unknown error'}\n` + + 'Install with: bun add -D @ast-grep/napi', + ) + } + + if (!NAPI_LANGUAGES.includes(lang)) { + const supportedLangs = NAPI_LANGUAGES.join(', ') + throw new Error( + `Unsupported language for NAPI: "${lang}"\n` + + `Supported languages: ${supportedLangs}\n\n` + + `Use ast_grep_search for other languages (25 supported via CLI).`, + ) + } + + const parseLang = getLangEnum(lang) + return napiModule!.parse(parseLang as Parameters[0], code) +} + +/** + * Find pattern matches in parsed tree + */ +export function findPattern(root: ReturnType, pattern: string) { + return root.root().findAll(pattern) +} + +/** + * Convert NAPI node to Range + */ +function nodeToRange(node: SgNode): Range { + const range = node.range() + return { + start: { line: range.start.line, column: range.start.column }, + end: { line: range.end.line, column: range.end.column }, + } +} + +/** + * Extract meta-variable names from pattern + */ +function extractMetaVariablesFromPattern(pattern: string): string[] { + const matches = pattern.match(/\$[A-Z_][A-Z0-9_]*/g) || [] + return Array.from(new Set(matches.map(m => m.slice(1)))) +} + +/** + * Extract meta-variables from a matched node + */ +export function extractMetaVariables( + node: SgNode, + pattern: string, +): MetaVariable[] { + const varNames = extractMetaVariablesFromPattern(pattern) + const result: MetaVariable[] = [] + + for (const name of varNames) { + const match = node.getMatch(name) + if (match) { + result.push({ + name, + text: match.text(), + kind: String(match.kind()), + }) + } + } + + return result +} + +/** + * Analyze code with pattern matching + */ +export function analyzeCode( + code: string, + lang: NapiLanguage, + pattern: string, + shouldExtractMetaVars: boolean, +): AnalyzeResult[] { + const root = parseCode(code, lang) + const matches = findPattern(root, pattern) + + return matches.map((node: SgNode) => ({ + text: node.text(), + range: nodeToRange(node), + kind: String(node.kind()), + metaVariables: shouldExtractMetaVars ? extractMetaVariables(node, pattern) : [], + })) +} + +/** + * Transform code with pattern replacement + */ +export function transformCode( + code: string, + lang: NapiLanguage, + pattern: string, + rewrite: string, +): { transformed: string; editCount: number } { + const root = parseCode(code, lang) + const matches = findPattern(root, pattern) + + if (matches.length === 0) { + return { transformed: code, editCount: 0 } + } + + const edits = matches.map((node: SgNode) => { + const metaVars = extractMetaVariables(node, pattern) + let replacement = rewrite + + for (const mv of metaVars) { + replacement = replacement.replace(new RegExp(`\\$${mv.name}`, 'g'), mv.text) + } + + return node.replace(replacement) + }) + + const transformed = root.root().commitEdits(edits) + return { transformed, editCount: edits.length } +} + +/** + * Get root node info for debugging + */ +export function getRootInfo(code: string, lang: NapiLanguage): { kind: string; childCount: number } { + const root = parseCode(code, lang) + const rootNode = root.root() + return { + kind: String(rootNode.kind()), + childCount: rootNode.children().length, + } +} diff --git a/packages/dora/src/providers/ast-grep/types.ts b/packages/dora/src/providers/ast-grep/types.ts new file mode 100644 index 0000000..c6e6485 --- /dev/null +++ b/packages/dora/src/providers/ast-grep/types.ts @@ -0,0 +1,123 @@ +/** + * Type definitions for ast-grep provider + */ + +import type { CLI_LANGUAGES, NAPI_LANGUAGES } from './constants' + +/** CLI supported language type */ +export type CliLanguage = (typeof CLI_LANGUAGES)[number] + +/** NAPI supported language type (subset of CLI languages) */ +export type NapiLanguage = (typeof NAPI_LANGUAGES)[number] + +/** Position in source code */ +export interface Position { + line: number + column: number +} + +/** Range in source code */ +export interface Range { + start: Position + end: Position +} + +/** Match result from CLI */ +export interface CliMatch { + text: string + range: { + byteOffset: { start: number; end: number } + start: Position + end: Position + } + file: string + lines: string + charCount: { leading: number; trailing: number } + language: string +} + +/** Simplified search match */ +export interface SearchMatch { + file: string + text: string + range: Range + lines: string +} + +/** Meta-variable extracted from pattern */ +export interface MetaVariable { + name: string + text: string + kind: string +} + +/** Result from NAPI analyze */ +export interface AnalyzeResult { + text: string + range: Range + kind: string + metaVariables: MetaVariable[] +} + +/** Result from NAPI transform */ +export interface TransformResult { + original: string + transformed: string + editCount: number +} + +/** Result from sg CLI */ +export interface SgResult { + matches: CliMatch[] + totalMatches: number + truncated: boolean + truncatedReason?: 'max_matches' | 'max_output_bytes' | 'timeout' + error?: string +} + +/** Options for running sg CLI */ +export interface RunSgOptions { + /** AST pattern to match */ + pattern: string + /** Target language */ + lang: CliLanguage + /** Paths to search (default: ['.']) */ + paths?: string[] + /** Glob patterns to include/exclude (prefix ! to exclude) */ + globs?: string[] + /** Rewrite pattern for replacements */ + rewrite?: string + /** Context lines around match */ + context?: number + /** Apply changes (for replace) */ + updateAll?: boolean + /** Path to YAML rule file */ + ruleFile?: string +} + +/** Platform identifier */ +export type PlatformId = + | 'win-x64' + | 'linux-x64' + | 'linux-arm64' + | 'osx-x64' + | 'osx-arm64' + +/** Platform-specific binary configuration */ +export interface PlatformConfig { + url: string + binaryPath: string +} + +/** Environment check result */ +export interface EnvironmentCheckResult { + cli: { + available: boolean + path: string + error?: string + } + napi: { + available: boolean + error?: string + } +} diff --git a/packages/dora/src/providers/ast-grep/utils.ts b/packages/dora/src/providers/ast-grep/utils.ts new file mode 100644 index 0000000..a88cd24 --- /dev/null +++ b/packages/dora/src/providers/ast-grep/utils.ts @@ -0,0 +1,148 @@ +/** + * Utility functions for ast-grep provider + */ + +import type { AnalyzeResult, CliLanguage, SgResult } from './types' + +/** + * Format search result for display + */ +export function formatSearchResult(result: SgResult): string { + if (result.error) { + return `Error: ${result.error}` + } + + if (result.matches.length === 0) { + return 'No matches found' + } + + const lines: string[] = [] + + if (result.truncated) { + const reason + = result.truncatedReason === 'max_matches' + ? `showing first ${result.matches.length} of ${result.totalMatches}` + : result.truncatedReason === 'max_output_bytes' + ? 'output exceeded 1MB limit' + : 'search timed out' + lines.push(`Warning: Results truncated (${reason})\n`) + } + + lines.push( + `Found ${result.matches.length} match(es)${result.truncated ? ` (truncated from ${result.totalMatches})` : ''}:\n`, + ) + + for (const match of result.matches) { + const loc = `${match.file}:${match.range.start.line + 1}:${match.range.start.column + 1}` + lines.push(`${loc}`) + lines.push(` ${match.lines.trim()}`) + lines.push('') + } + + return lines.join('\n') +} + +/** + * Format replace result for display + */ +export function formatReplaceResult(result: SgResult, isDryRun: boolean): string { + if (result.error) { + return `Error: ${result.error}` + } + + if (result.matches.length === 0) { + return 'No matches found to replace' + } + + const prefix = isDryRun ? '[DRY RUN] ' : '' + const lines: string[] = [] + + if (result.truncated) { + const reason + = result.truncatedReason === 'max_matches' + ? `showing first ${result.matches.length} of ${result.totalMatches}` + : result.truncatedReason === 'max_output_bytes' + ? 'output exceeded 1MB limit' + : 'search timed out' + lines.push(`Warning: Results truncated (${reason})\n`) + } + + lines.push(`${prefix}${result.matches.length} replacement(s):\n`) + + for (const match of result.matches) { + const loc = `${match.file}:${match.range.start.line + 1}:${match.range.start.column + 1}` + lines.push(`${loc}`) + lines.push(` ${match.text}`) + lines.push('') + } + + if (isDryRun) { + lines.push('Use dryRun=false to apply changes') + } + + return lines.join('\n') +} + +/** + * Format NAPI analyze result for display + */ +export function formatAnalyzeResult(results: AnalyzeResult[], extractedMetaVars: boolean): string { + if (results.length === 0) { + return 'No matches found' + } + + const lines: string[] = [`Found ${results.length} match(es):\n`] + + for (const result of results) { + const loc = `L${result.range.start.line + 1}:${result.range.start.column + 1}` + lines.push(`[${loc}] (${result.kind})`) + lines.push(` ${result.text}`) + + if (extractedMetaVars && result.metaVariables.length > 0) { + lines.push(' Meta-variables:') + for (const mv of result.metaVariables) { + lines.push(` $${mv.name} = "${mv.text}" (${mv.kind})`) + } + } + lines.push('') + } + + return lines.join('\n') +} + +/** + * Format NAPI transform result for display + */ +export function formatTransformResult(_original: string, transformed: string, editCount: number): string { + if (editCount === 0) { + return 'No matches found to transform' + } + + return `Transformed (${editCount} edit(s)):\n\`\`\`\n${transformed}\n\`\`\`` +} + +/** + * Get hint for empty result based on pattern + */ +export function getEmptyResultHint(pattern: string, lang: CliLanguage): string | null { + const src = pattern.trim() + + if (lang === 'python') { + if (src.startsWith('class ') && src.endsWith(':')) { + const withoutColon = src.slice(0, -1) + return `Hint: Remove trailing colon. Try: "${withoutColon}"` + } + if ((src.startsWith('def ') || src.startsWith('async def ')) && src.endsWith(':')) { + const withoutColon = src.slice(0, -1) + return `Hint: Remove trailing colon. Try: "${withoutColon}"` + } + } + + if (['javascript', 'typescript', 'tsx'].includes(lang)) { + if (/^(export\s+)?(async\s+)?function\s+\$[A-Z_]+\s*$/i.test(src)) { + return `Hint: Function patterns need params and body. Try "function $NAME($$$) { $$$ }"` + } + } + + return null +} diff --git a/packages/dora/src/providers/index.ts b/packages/dora/src/providers/index.ts index 1257035..06cd1f2 100644 --- a/packages/dora/src/providers/index.ts +++ b/packages/dora/src/providers/index.ts @@ -2,6 +2,7 @@ * Providers module exports */ +export * from './ast-grep' export * from './file' // export * from './jetbrains' // TBD: JetBrains integration export * from './lsp' diff --git a/packages/dora/src/server.ts b/packages/dora/src/server.ts index ccc73b6..7135d15 100644 --- a/packages/dora/src/server.ts +++ b/packages/dora/src/server.ts @@ -4,6 +4,7 @@ import { McpServer } from '@modelcontextprotocol/sdk/server/mcp.js' import { + createAstGrepProvider, createFileProvider, createLSPProvider, } from './providers' @@ -27,19 +28,23 @@ export async function createDoraServer( // Initialize providers const lspProvider = createLSPProvider(config) const fileProvider = createFileProvider(config) + const astGrepProvider = createAstGrepProvider(config) // Connect providers immediately await lspProvider.connect() await fileProvider.connect() + await astGrepProvider.connect() // Collect tools from all providers const lspTools = lspProvider.listTools() const fileTools = fileProvider.listTools() - const allTools = [...lspTools, ...fileTools] + const astGrepTools = astGrepProvider.listTools() + const allTools = [...lspTools, ...fileTools, ...astGrepTools] // Map tool names to their providers const lspToolNames = new Set(lspTools.map(t => t.name)) const fileToolNames = new Set(fileTools.map(t => t.name)) + const astGrepToolNames = new Set(astGrepTools.map(t => t.name)) // Register each tool dynamically for (const tool of allTools) { @@ -58,6 +63,9 @@ export async function createDoraServer( else if (lspToolNames.has(tool.name)) { return await lspProvider.callTool(tool.name, params) } + else if (astGrepToolNames.has(tool.name)) { + return await astGrepProvider.callTool(tool.name, params) + } return { content: [{ type: 'text', text: `Unknown tool: ${tool.name}` }], diff --git a/packages/dora/test/ast-grep-provider.test.ts b/packages/dora/test/ast-grep-provider.test.ts new file mode 100644 index 0000000..7a0e6d6 --- /dev/null +++ b/packages/dora/test/ast-grep-provider.test.ts @@ -0,0 +1,278 @@ +/** + * Tests for AstGrepProvider + */ + +import fs from 'node:fs' +import os from 'node:os' +import path from 'node:path' +import { afterEach, beforeEach, describe, expect, it } from 'bun:test' +import { AstGrepProvider, createAstGrepProvider } from '../src/providers/ast-grep' +import { isCliAvailable } from '../src/providers/ast-grep/cli' + +/** + * Check if CLI is available before running tests that require it + */ +async function checkCliAvailable(): Promise { + return await isCliAvailable() +} + +/** Helper to get text from tool result */ +function getText(result: { content: Array<{ type: string, text: string }> }): string { + return result.content[0]?.text ?? '' +} + +describe('AstGrepProvider', () => { + let testDir: string + let provider: AstGrepProvider + + beforeEach(() => { + // Create a temp directory for each test + testDir = fs.mkdtempSync(path.join(os.tmpdir(), 'ast-grep-provider-test-')) + + // Create test files with code + fs.mkdirSync(path.join(testDir, 'src')) + fs.writeFileSync( + path.join(testDir, 'src', 'example.ts'), + `console.log('hello'); +console.log('world'); +function greet(name: string) { + console.log(\`Hello, \${name}!\`); +} +`, + ) + + fs.writeFileSync( + path.join(testDir, 'src', 'app.js'), + `const foo = 'bar'; +console.warn('deprecated'); +`, + ) + + provider = createAstGrepProvider({ projectPath: testDir }) + }) + + afterEach(() => { + // Clean up temp directory + fs.rmSync(testDir, { recursive: true, force: true }) + }) + + describe('connection', () => { + it('has correct name', () => { + expect(provider.name).toBe('ast-grep') + }) + + it('starts disconnected', () => { + expect(provider.isConnected()).toBe(false) + }) + + it('connects successfully', async () => { + await provider.connect() + expect(provider.isConnected()).toBe(true) + }) + + it('disconnect sets connected to false', async () => { + await provider.connect() + await provider.disconnect() + expect(provider.isConnected()).toBe(false) + }) + }) + + describe('listTools', () => { + it('lists CLI tools', async () => { + await provider.connect() + const tools = provider.listTools() + const toolNames = tools.map(t => t.name) + + // CLI tools should always be available + expect(toolNames).toContain('ast_grep_search') + expect(toolNames).toContain('ast_grep_replace') + }) + + it('tool descriptions are informative', async () => { + await provider.connect() + const tools = provider.listTools() + + const searchTool = tools.find(t => t.name === 'ast_grep_search') + expect(searchTool?.description).toContain('AST-aware') + expect(searchTool?.description).toContain('meta-variables') + }) + }) + + describe('ast_grep_search', () => { + let cliAvailable: boolean + + beforeEach(async () => { + await provider.connect() + cliAvailable = await checkCliAvailable() + }) + + it('finds pattern matches', async () => { + if (!cliAvailable) { + console.log('[SKIP] ast-grep CLI not available') + return + } + + const result = await provider.callTool('ast_grep_search', { + pattern: 'console.log($MSG)', + lang: 'typescript', + paths: [testDir], + }) + + expect(result.isError).toBeFalsy() + const text = getText(result) + // Should find console.log calls + expect(text).toMatch(/\d+ match(es)?/) + }) + + it('handles no matches gracefully', async () => { + if (!cliAvailable) { + console.log('[SKIP] ast-grep CLI not available') + return + } + + const result = await provider.callTool('ast_grep_search', { + pattern: 'nonexistent_function($X)', + lang: 'typescript', + paths: [testDir], + }) + + expect(result.isError).toBeFalsy() + const text = getText(result) + // Output says "No matches found" when there are no matches + expect(text).toMatch(/no matches|0 match/i) + }) + + it('provides hint for common pattern mistakes', async () => { + if (!cliAvailable) { + console.log('[SKIP] ast-grep CLI not available') + return + } + + // Incomplete pattern that's a common mistake + const result = await provider.callTool('ast_grep_search', { + pattern: 'def $NAME', + lang: 'python', + paths: [testDir], + }) + + expect(result.isError).toBeFalsy() + // Should provide a hint about complete patterns + }) + + it('validates language parameter', async () => { + const result = await provider.callTool('ast_grep_search', { + pattern: 'test', + lang: 'invalid_language', + paths: [testDir], + }) + + expect(result.isError).toBe(true) + }) + + it('returns helpful error when CLI unavailable', async () => { + // This test verifies the error message when CLI is not found + // The result may be success (with matches) or error (with helpful message) + const result = await provider.callTool('ast_grep_search', { + pattern: 'console.log($MSG)', + lang: 'typescript', + paths: [testDir], + }) + + const text = getText(result) + if (result.isError) { + // Should provide some error message + expect(text.length).toBeGreaterThan(0) + } + else { + // CLI is available, should have matches + expect(text).toMatch(/match/i) + } + }) + }) + + describe('ast_grep_replace', () => { + let cliAvailable: boolean + + beforeEach(async () => { + await provider.connect() + cliAvailable = await checkCliAvailable() + }) + + it('previews replacements in dry-run mode', async () => { + if (!cliAvailable) { + console.log('[SKIP] ast-grep CLI not available') + return + } + + const result = await provider.callTool('ast_grep_replace', { + pattern: 'console.log($MSG)', + rewrite: 'logger.info($MSG)', + lang: 'typescript', + paths: [testDir], + dryRun: true, + }) + + expect(result.isError).toBeFalsy() + const text = getText(result) + // Output contains "DRY RUN" to indicate preview mode + expect(text).toMatch(/DRY RUN|Preview/i) + }) + + it('dry-run is default', async () => { + if (!cliAvailable) { + console.log('[SKIP] ast-grep CLI not available') + return + } + + const result = await provider.callTool('ast_grep_replace', { + pattern: 'console.log($MSG)', + rewrite: 'logger.info($MSG)', + lang: 'typescript', + paths: [testDir], + // No dryRun specified - should default to true + }) + + expect(result.isError).toBeFalsy() + const text = getText(result) + // Should indicate it's a dry run + expect(text).toMatch(/DRY RUN|Preview/i) + + // File should not be modified + const content = fs.readFileSync(path.join(testDir, 'src', 'example.ts'), 'utf-8') + expect(content).toContain('console.log') + }) + }) + + describe('error handling', () => { + it('handles missing required parameters', async () => { + await provider.connect() + const result = await provider.callTool('ast_grep_search', { + // Missing pattern and lang + }) + + expect(result.isError).toBe(true) + }) + + it('returns error for unknown tool', async () => { + await provider.connect() + const result = await provider.callTool('unknown_tool', {}) + + expect(result.isError).toBe(true) + expect(getText(result)).toContain('Unknown tool') + }) + }) +}) + +describe('createAstGrepProvider', () => { + it('creates provider instance', () => { + const tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), 'ast-grep-test-')) + try { + const provider = createAstGrepProvider({ projectPath: tmpDir }) + expect(provider).toBeInstanceOf(AstGrepProvider) + expect(provider.name).toBe('ast-grep') + } + finally { + fs.rmSync(tmpDir, { recursive: true, force: true }) + } + }) +}) From 46ee26ff39fc68357e4c2486e121c7e4b1a41963 Mon Sep 17 00:00:00 2001 From: Minsu Lee Date: Fri, 19 Dec 2025 13:35:37 +0900 Subject: [PATCH 3/9] fix(ast-grep): address code review issues in error handling and documentation - Fix silent failures in error handling: - Add error logging when JSON parsing fails (cli.ts) - Add error logging in binary verification (downloader.ts) - Add warning when reading version marker fails (downloader.ts) - Improve error handling robustness: - Use .finally() instead of .then() for timeout cleanup (cli.ts) - Include error details in nested catch blocks (cli.ts) - Add retry counter to prevent infinite download loops (cli.ts) - Return exit code when process exits with no output (cli.ts) - Log full error on NAPI load failure (napi.ts) - Security improvements: - Escape single quotes in PowerShell extractZip command - Use -LiteralPath for safer path handling - Better error messages: - Add Zod-specific error formatting for validation errors (index.ts) - Documentation updates: - Update ADR to document all 4 tools (search, replace, analyze, transform) - Update agent skill frontmatter with correct MCP tool names - Add NAPI tool documentation (ast_grep_analyze, ast_grep_transform) --- agents/ast-grep.md | 34 ++++++++++- .../0001-ast-grep-integration-architecture.md | 13 +++-- packages/dora/src/providers/ast-grep/cli.ts | 57 ++++++++++++------- .../dora/src/providers/ast-grep/downloader.ts | 36 +++++++----- packages/dora/src/providers/ast-grep/index.ts | 21 +++++-- packages/dora/src/providers/ast-grep/napi.ts | 2 + 6 files changed, 119 insertions(+), 44 deletions(-) diff --git a/agents/ast-grep.md b/agents/ast-grep.md index d9b4fb5..c4d4ce3 100644 --- a/agents/ast-grep.md +++ b/agents/ast-grep.md @@ -40,7 +40,7 @@ description: | Pattern debugging - common mistake with Python syntax. -tools: Bash, Read, Write +tools: ast_grep_search, ast_grep_replace, ast_grep_analyze, ast_grep_transform model: sonnet --- @@ -289,6 +289,38 @@ Transform code (dry-run by default): } ``` +### ast_grep_analyze (NAPI - In-Memory) + +Analyze code in-memory without file I/O (faster, supports 5 languages: html, javascript, tsx, css, typescript): + +```json +{ + "code": "console.log('hello'); console.log('world');", + "pattern": "console.log($MSG)", + "lang": "javascript", + "extractMetaVars": true +} +``` + +Returns matches with optional meta-variable extraction. Use for single-file analysis or quick pattern testing. + +### ast_grep_transform (NAPI - In-Memory) + +Transform code in-memory without modifying files (supports 5 languages: html, javascript, tsx, css, typescript): + +```json +{ + "code": "console.log('hello');", + "pattern": "console.log($MSG)", + "rewrite": "logger.info($MSG)", + "lang": "javascript" +} +``` + +Returns transformed code without writing to disk. Use for previewing transformations or processing code strings. + +**Note:** NAPI tools require `@ast-grep/napi` optional dependency. If not installed, use CLI-based `ast_grep_search` and `ast_grep_replace` instead. + --- ## WORKFLOW diff --git a/docs/adr/0001-ast-grep-integration-architecture.md b/docs/adr/0001-ast-grep-integration-architecture.md index 3da8c87..c04ca2f 100644 --- a/docs/adr/0001-ast-grep-integration-architecture.md +++ b/docs/adr/0001-ast-grep-integration-architecture.md @@ -27,10 +27,12 @@ We will implement **both MCP Tools and an Agent/Skill** for ast-grep integration Create a new `ast-grep` provider in `packages/dora/src/providers/ast-grep/` with: -| Tool | Purpose | -|------|---------| -| `ast_grep_search` | Pattern-based code search across files | -| `ast_grep_replace` | AST-aware code transformation (dry-run by default) | +| Tool | Purpose | Backend | +|------|---------|---------| +| `ast_grep_search` | Pattern-based code search across files | CLI (25 languages) | +| `ast_grep_replace` | AST-aware code transformation (dry-run by default) | CLI (25 languages) | +| `ast_grep_analyze` | In-memory code analysis with meta-variable extraction | NAPI (5 languages) | +| `ast_grep_transform` | In-memory code transformation (no file I/O) | NAPI (5 languages) | **Features:** - Both inline patterns (`console.log($MSG)`) and YAML rule files (`--rule file.yaml`) @@ -69,10 +71,11 @@ agents/ ### Binary Management Follow the Dart LSP pattern: -1. Check system PATH first (`Bun.which('sg')`) +1. Check system PATH first (`Bun.which('ast-grep')` or `Bun.which('sg')`) 2. If not found, download to `~/.cache/dora/ast-grep/` 3. Platform-specific binaries (win-x64, linux-x64, linux-arm64, osx-x64, osx-arm64) 4. Version tracking via marker file +5. Binary verification to ensure it's actually ast-grep (checks `--version` output) ## Consequences diff --git a/packages/dora/src/providers/ast-grep/cli.ts b/packages/dora/src/providers/ast-grep/cli.ts index 95ef8e4..47293dc 100644 --- a/packages/dora/src/providers/ast-grep/cli.ts +++ b/packages/dora/src/providers/ast-grep/cli.ts @@ -44,8 +44,11 @@ export async function isCliAvailable(): Promise { /** * Run ast-grep CLI command + * + * @param options - Command options + * @param retried - Internal flag to prevent infinite retry loops */ -export async function runSg(options: RunSgOptions): Promise { +export async function runSg(options: RunSgOptions, retried = false): Promise { const cliPath = await getAstGrepPath() if (!cliPath) { @@ -117,7 +120,8 @@ export async function runSg(options: RunSgOptions): Promise { proc.kill() reject(new Error(`Search timeout after ${timeout}ms`)) }, timeout) - proc.exited.then(() => clearTimeout(id)) + // Use .finally() to ensure cleanup even if proc.exited rejects + proc.exited.finally(() => clearTimeout(id)) }) let stdout: string @@ -147,20 +151,20 @@ export async function runSg(options: RunSgOptions): Promise { || nodeError.message?.includes('ENOENT') || nodeError.message?.includes('not found') ) { - // Binary not found, try to download - const downloadedPath = await ensureAstGrepBinary() - if (downloadedPath) { - resolvedCliPath = downloadedPath - return runSg(options) // Retry - } - else { - return { - matches: [], - totalMatches: 0, - truncated: false, - error: getInstallInstructions(), + // Binary not found, try to download (only once to prevent infinite loops) + if (!retried) { + const downloadedPath = await ensureAstGrepBinary() + if (downloadedPath) { + resolvedCliPath = downloadedPath + return runSg(options, true) // Retry once } } + return { + matches: [], + totalMatches: 0, + truncated: false, + error: getInstallInstructions(), + } } return { @@ -179,7 +183,13 @@ export async function runSg(options: RunSgOptions): Promise { if (stderr.trim()) { return { matches: [], totalMatches: 0, truncated: false, error: stderr.trim() } } - return { matches: [], totalMatches: 0, truncated: false } + // Non-zero exit with no output - include exit code for diagnosis + return { + matches: [], + totalMatches: 0, + truncated: false, + error: `ast-grep exited with code ${exitCode} (no output)`, + } } // No output @@ -196,7 +206,7 @@ export async function runSg(options: RunSgOptions): Promise { try { matches = JSON.parse(outputToProcess) as CliMatch[] } - catch { + catch (parseError) { if (outputTruncated) { // Try to parse partial JSON try { @@ -209,18 +219,27 @@ export async function runSg(options: RunSgOptions): Promise { } } } - catch { + catch (recoveryError) { + const errorMsg = recoveryError instanceof Error ? recoveryError.message : 'unknown' return { matches: [], totalMatches: 0, truncated: true, truncatedReason: 'max_output_bytes', - error: 'Output too large and could not be parsed', + error: `Output too large and could not be parsed: ${errorMsg}`, } } } else { - return { matches: [], totalMatches: 0, truncated: false } + // Non-truncated output but failed to parse - log and return error + const errorMsg = parseError instanceof Error ? parseError.message : 'unknown' + console.error(`[ast-grep] Failed to parse output: ${errorMsg}`) + return { + matches: [], + totalMatches: 0, + truncated: false, + error: `Failed to parse ast-grep output: ${errorMsg}`, + } } } diff --git a/packages/dora/src/providers/ast-grep/downloader.ts b/packages/dora/src/providers/ast-grep/downloader.ts index ff1883c..6c11ddb 100644 --- a/packages/dora/src/providers/ast-grep/downloader.ts +++ b/packages/dora/src/providers/ast-grep/downloader.ts @@ -32,7 +32,8 @@ async function verifyAstGrepBinary(binaryPath: string): Promise { // ast-grep version output contains "ast-grep" or version number like "0.x.x" return stdout.includes('ast-grep') || /^\d+\.\d+\.\d+/.test(stdout.trim()) } - catch { + catch (e) { + console.error(`[ast-grep] Binary verification failed for ${binaryPath}: ${e instanceof Error ? e.message : String(e)}`) return false } } @@ -77,8 +78,9 @@ export function getCachedBinary(): string | null { return null } } - catch { - // Can't read version, assume outdated + catch (e) { + // Log warning but continue - will trigger re-download + console.warn(`[ast-grep] Failed to read version marker at ${versionPath}: ${e instanceof Error ? e.message : String(e)}`) return null } } @@ -90,17 +92,23 @@ export function getCachedBinary(): string | null { * Extract zip archive */ async function extractZip(archivePath: string, destDir: string): Promise { - const proc - = process.platform === 'win32' - ? spawn( - [ - 'powershell', - '-command', - `Expand-Archive -Path '${archivePath}' -DestinationPath '${destDir}' -Force`, - ], - { stdout: 'pipe', stderr: 'pipe' }, - ) - : spawn(['unzip', '-o', archivePath, '-d', destDir], { stdout: 'pipe', stderr: 'pipe' }) + let proc + if (process.platform === 'win32') { + // Escape single quotes for PowerShell by doubling them + const escapedArchive = archivePath.replace(/'/g, "''") + const escapedDest = destDir.replace(/'/g, "''") + proc = spawn( + [ + 'powershell', + '-command', + `Expand-Archive -LiteralPath '${escapedArchive}' -DestinationPath '${escapedDest}' -Force`, + ], + { stdout: 'pipe', stderr: 'pipe' }, + ) + } + else { + proc = spawn(['unzip', '-o', archivePath, '-d', destDir], { stdout: 'pipe', stderr: 'pipe' }) + } const exitCode = await proc.exited diff --git a/packages/dora/src/providers/ast-grep/index.ts b/packages/dora/src/providers/ast-grep/index.ts index f799311..63e4099 100644 --- a/packages/dora/src/providers/ast-grep/index.ts +++ b/packages/dora/src/providers/ast-grep/index.ts @@ -4,7 +4,7 @@ * Provides AST-aware code search and transformation tools */ -import { z } from 'zod' +import { z, ZodError } from 'zod' import type { Provider, ToolDefinition, ToolResult } from '../provider' import type { RegistryConfig } from '../registry' import { CLI_LANGUAGES, NAPI_LANGUAGES } from './constants' @@ -13,6 +13,17 @@ import { isNapiAvailable, getNapiError, analyzeCode, transformCode } from './nap import { formatSearchResult, formatReplaceResult, formatAnalyzeResult, formatTransformResult, getEmptyResultHint } from './utils' import type { NapiLanguage } from './types' +/** + * Format error for tool result, with special handling for Zod validation errors + */ +function formatToolError(e: unknown): string { + if (e instanceof ZodError) { + const issues = e.issues.map(i => ` - ${i.path.join('.')}: ${i.message}`).join('\n') + return `Invalid arguments:\n${issues}` + } + return `Error: ${e instanceof Error ? e.message : String(e)}` +} + // Tool definitions const AST_GREP_TOOLS: ToolDefinition[] = [ { @@ -228,7 +239,7 @@ export class AstGrepProvider implements Provider { } catch (e) { return { - content: [{ type: 'text', text: `Error: ${e instanceof Error ? e.message : String(e)}` }], + content: [{ type: 'text', text: formatToolError(e) }], isError: true, } } @@ -267,7 +278,7 @@ export class AstGrepProvider implements Provider { } catch (e) { return { - content: [{ type: 'text', text: `Error: ${e instanceof Error ? e.message : String(e)}` }], + content: [{ type: 'text', text: formatToolError(e) }], isError: true, } } @@ -312,7 +323,7 @@ export class AstGrepProvider implements Provider { } catch (e) { return { - content: [{ type: 'text', text: `Error: ${e instanceof Error ? e.message : String(e)}` }], + content: [{ type: 'text', text: formatToolError(e) }], isError: true, } } @@ -357,7 +368,7 @@ export class AstGrepProvider implements Provider { } catch (e) { return { - content: [{ type: 'text', text: `Error: ${e instanceof Error ? e.message : String(e)}` }], + content: [{ type: 'text', text: formatToolError(e) }], isError: true, } } diff --git a/packages/dora/src/providers/ast-grep/napi.ts b/packages/dora/src/providers/ast-grep/napi.ts index 4558665..2bc91c5 100644 --- a/packages/dora/src/providers/ast-grep/napi.ts +++ b/packages/dora/src/providers/ast-grep/napi.ts @@ -31,6 +31,8 @@ export function isNapiAvailable(): boolean { } catch (e) { napiLoadError = e instanceof Error ? e : new Error(String(e)) + // Log full error on first load failure for diagnostics + console.error('[ast-grep] NAPI module load failed:', e) return false } } From 5960eb81a013d5f5258ae95a487c6468fe28abba Mon Sep 17 00:00:00 2001 From: Minsu Lee Date: Fri, 19 Dec 2025 13:42:29 +0900 Subject: [PATCH 4/9] style(ast-grep): fix lint errors - Add process import from node:process in constants.ts and downloader.ts - Convert capturing groups to non-capturing in utils.ts regex - Add YAML document separators (---) in ast-grep.md examples - Apply auto-fixes for import ordering and code style --- agents/ast-grep.md | 12 +++++----- packages/dora/src/providers/ast-grep/cli.ts | 4 ++-- .../dora/src/providers/ast-grep/constants.ts | 5 ++-- .../dora/src/providers/ast-grep/downloader.ts | 11 +++++---- packages/dora/src/providers/ast-grep/index.ts | 12 ++++++---- packages/dora/src/providers/ast-grep/napi.ts | 23 +++++++++++-------- packages/dora/src/providers/ast-grep/types.ts | 16 ++++++------- packages/dora/src/providers/ast-grep/utils.ts | 2 +- 8 files changed, 46 insertions(+), 39 deletions(-) diff --git a/agents/ast-grep.md b/agents/ast-grep.md index c4d4ce3..85f6db5 100644 --- a/agents/ast-grep.md +++ b/agents/ast-grep.md @@ -110,8 +110,8 @@ For complex queries, use YAML rules with operators. id: rule-name language: javascript rule: - # Rule definition here -message: "Explanation of what was found" +# Rule definition here +message: Explanation of what was found ``` ### Atomic Rules @@ -161,7 +161,7 @@ rule: has: kind: try_statement stopBy: end - +--- # Find React useState without dependency id: usestate-in-component language: tsx @@ -170,7 +170,7 @@ rule: inside: kind: function_declaration stopBy: end - +--- # Find console.log in production code id: no-console-log language: javascript @@ -178,7 +178,7 @@ rule: pattern: console.log($$$) not: inside: - regex: "test|spec|__tests__" + regex: test|spec|__tests__ kind: string ``` @@ -197,7 +197,7 @@ rule: kind: string nthChild: 1 stopBy: end - +--- # Find bare except clauses id: bare-except language: python diff --git a/packages/dora/src/providers/ast-grep/cli.ts b/packages/dora/src/providers/ast-grep/cli.ts index 47293dc..c1d5552 100644 --- a/packages/dora/src/providers/ast-grep/cli.ts +++ b/packages/dora/src/providers/ast-grep/cli.ts @@ -4,8 +4,9 @@ * Executes sg CLI commands and parses results */ +import type { CliMatch, RunSgOptions, SgResult } from './types' +import { existsSync } from 'node:fs' import { spawn } from 'bun' -import { existsSync } from 'fs' import { CLI_LANGUAGES, DEFAULT_MAX_MATCHES, @@ -13,7 +14,6 @@ import { DEFAULT_TIMEOUT_MS, } from './constants' import { ensureAstGrepBinary, getInstallInstructions } from './downloader' -import type { CliMatch, RunSgOptions, SgResult } from './types' // Cached binary path let resolvedCliPath: string | null = null diff --git a/packages/dora/src/providers/ast-grep/constants.ts b/packages/dora/src/providers/ast-grep/constants.ts index 371b43d..49c66d2 100644 --- a/packages/dora/src/providers/ast-grep/constants.ts +++ b/packages/dora/src/providers/ast-grep/constants.ts @@ -2,9 +2,10 @@ * Constants for ast-grep provider */ -import * as os from 'os' -import * as path from 'path' import type { PlatformConfig, PlatformId } from './types' +import * as os from 'node:os' +import * as path from 'node:path' +import process from 'node:process' // CLI supported languages (25 total) export const CLI_LANGUAGES = [ diff --git a/packages/dora/src/providers/ast-grep/downloader.ts b/packages/dora/src/providers/ast-grep/downloader.ts index 6c11ddb..e7eec33 100644 --- a/packages/dora/src/providers/ast-grep/downloader.ts +++ b/packages/dora/src/providers/ast-grep/downloader.ts @@ -5,17 +5,18 @@ * Caches to ~/.cache/dora/ast-grep/ */ -import { existsSync, mkdirSync, chmodSync, unlinkSync, writeFileSync, readFileSync } from 'fs' +import type { PlatformId } from './types' +import { chmodSync, existsSync, mkdirSync, readFileSync, unlinkSync, writeFileSync } from 'node:fs' +import process from 'node:process' import { spawn } from 'bun' import { AST_GREP_VERSION, - PLATFORM_CONFIGS, getAstGrepCacheDir, getCachedBinaryPath, getPlatformId, getVersionMarkerPath, + PLATFORM_CONFIGS, } from './constants' -import type { PlatformId } from './types' /** * Verify a binary is actually ast-grep by checking --version output @@ -95,8 +96,8 @@ async function extractZip(archivePath: string, destDir: string): Promise { let proc if (process.platform === 'win32') { // Escape single quotes for PowerShell by doubling them - const escapedArchive = archivePath.replace(/'/g, "''") - const escapedDest = destDir.replace(/'/g, "''") + const escapedArchive = archivePath.replace(/'/g, '\'\'') + const escapedDest = destDir.replace(/'/g, '\'\'') proc = spawn( [ 'powershell', diff --git a/packages/dora/src/providers/ast-grep/index.ts b/packages/dora/src/providers/ast-grep/index.ts index 63e4099..aac74cf 100644 --- a/packages/dora/src/providers/ast-grep/index.ts +++ b/packages/dora/src/providers/ast-grep/index.ts @@ -4,14 +4,14 @@ * Provides AST-aware code search and transformation tools */ -import { z, ZodError } from 'zod' import type { Provider, ToolDefinition, ToolResult } from '../provider' import type { RegistryConfig } from '../registry' -import { CLI_LANGUAGES, NAPI_LANGUAGES } from './constants' -import { runSg, isCliAvailable } from './cli' -import { isNapiAvailable, getNapiError, analyzeCode, transformCode } from './napi' -import { formatSearchResult, formatReplaceResult, formatAnalyzeResult, formatTransformResult, getEmptyResultHint } from './utils' import type { NapiLanguage } from './types' +import { z, ZodError } from 'zod' +import { isCliAvailable, runSg } from './cli' +import { CLI_LANGUAGES, NAPI_LANGUAGES } from './constants' +import { analyzeCode, getNapiError, isNapiAvailable, transformCode } from './napi' +import { formatAnalyzeResult, formatReplaceResult, formatSearchResult, formatTransformResult, getEmptyResultHint } from './utils' /** * Format error for tool result, with special handling for Zod validation errors @@ -157,7 +157,9 @@ export class AstGrepProvider implements Provider { const napiAvailable = isNapiAvailable() if (!napiAvailable) { const error = getNapiError() + console.log(`[ast-grep] NAPI not available: ${error ?? 'unknown'}`) + console.log('[ast-grep] In-memory tools (analyze/transform) disabled') } diff --git a/packages/dora/src/providers/ast-grep/napi.ts b/packages/dora/src/providers/ast-grep/napi.ts index 2bc91c5..f0cf0b8 100644 --- a/packages/dora/src/providers/ast-grep/napi.ts +++ b/packages/dora/src/providers/ast-grep/napi.ts @@ -5,12 +5,11 @@ * Provides faster in-memory analysis and transformation without spawning CLI */ -import { NAPI_LANGUAGES } from './constants' import type { AnalyzeResult, MetaVariable, NapiLanguage, Range } from './types' +import { NAPI_LANGUAGES } from './constants' -// eslint-disable-next-line ts/no-explicit-any -- Runtime-loaded optional dependency type AstGrepNapiModule = any -// eslint-disable-next-line ts/no-explicit-any -- AST node from NAPI module + type SgNode = any // Dynamic import for @ast-grep/napi (optional dependency) @@ -21,8 +20,10 @@ let napiLoadError: Error | null = null * Check if NAPI is available */ export function isNapiAvailable(): boolean { - if (napiModule !== null) return true - if (napiLoadError !== null) return false + if (napiModule !== null) + return true + if (napiLoadError !== null) + return false try { // eslint-disable-next-line ts/no-require-imports @@ -67,11 +68,12 @@ function getLangEnum(lang: NapiLanguage): unknown { /** * Parse code using NAPI */ +// eslint-disable-next-line ts/explicit-function-return-type export function parseCode(code: string, lang: NapiLanguage) { if (!isNapiAvailable()) { throw new Error( `@ast-grep/napi not available: ${napiLoadError?.message ?? 'unknown error'}\n` - + 'Install with: bun add -D @ast-grep/napi', + + 'Install with: bun add -D @ast-grep/napi', ) } @@ -79,8 +81,8 @@ export function parseCode(code: string, lang: NapiLanguage) { const supportedLangs = NAPI_LANGUAGES.join(', ') throw new Error( `Unsupported language for NAPI: "${lang}"\n` - + `Supported languages: ${supportedLangs}\n\n` - + `Use ast_grep_search for other languages (25 supported via CLI).`, + + `Supported languages: ${supportedLangs}\n\n` + + `Use ast_grep_search for other languages (25 supported via CLI).`, ) } @@ -91,6 +93,7 @@ export function parseCode(code: string, lang: NapiLanguage) { /** * Find pattern matches in parsed tree */ +// eslint-disable-next-line ts/explicit-function-return-type export function findPattern(root: ReturnType, pattern: string) { return root.root().findAll(pattern) } @@ -166,7 +169,7 @@ export function transformCode( lang: NapiLanguage, pattern: string, rewrite: string, -): { transformed: string; editCount: number } { +): { transformed: string, editCount: number } { const root = parseCode(code, lang) const matches = findPattern(root, pattern) @@ -192,7 +195,7 @@ export function transformCode( /** * Get root node info for debugging */ -export function getRootInfo(code: string, lang: NapiLanguage): { kind: string; childCount: number } { +export function getRootInfo(code: string, lang: NapiLanguage): { kind: string, childCount: number } { const root = parseCode(code, lang) const rootNode = root.root() return { diff --git a/packages/dora/src/providers/ast-grep/types.ts b/packages/dora/src/providers/ast-grep/types.ts index c6e6485..eacbc8e 100644 --- a/packages/dora/src/providers/ast-grep/types.ts +++ b/packages/dora/src/providers/ast-grep/types.ts @@ -26,13 +26,13 @@ export interface Range { export interface CliMatch { text: string range: { - byteOffset: { start: number; end: number } + byteOffset: { start: number, end: number } start: Position end: Position } file: string lines: string - charCount: { leading: number; trailing: number } + charCount: { leading: number, trailing: number } language: string } @@ -96,12 +96,12 @@ export interface RunSgOptions { } /** Platform identifier */ -export type PlatformId = - | 'win-x64' - | 'linux-x64' - | 'linux-arm64' - | 'osx-x64' - | 'osx-arm64' +export type PlatformId + = | 'win-x64' + | 'linux-x64' + | 'linux-arm64' + | 'osx-x64' + | 'osx-arm64' /** Platform-specific binary configuration */ export interface PlatformConfig { diff --git a/packages/dora/src/providers/ast-grep/utils.ts b/packages/dora/src/providers/ast-grep/utils.ts index a88cd24..ba3cffd 100644 --- a/packages/dora/src/providers/ast-grep/utils.ts +++ b/packages/dora/src/providers/ast-grep/utils.ts @@ -139,7 +139,7 @@ export function getEmptyResultHint(pattern: string, lang: CliLanguage): string | } if (['javascript', 'typescript', 'tsx'].includes(lang)) { - if (/^(export\s+)?(async\s+)?function\s+\$[A-Z_]+\s*$/i.test(src)) { + if (/^(?:export\s+)?(?:async\s+)?function\s+\$[A-Z_]+\s*$/i.test(src)) { return `Hint: Function patterns need params and body. Try "function $NAME($$$) { $$$ }"` } } From 5244bc468a7504c12a3c3869beed864ab56d4c92 Mon Sep 17 00:00:00 2001 From: Minsu Lee Date: Fri, 19 Dec 2025 13:59:01 +0900 Subject: [PATCH 5/9] refactor(ast-grep): extract pattern knowledge to reusable skill Separate ast-grep knowledge into skill and agent: - skills/ast-grep/SKILL.md: Pattern writing guide (reusable) - skills/ast-grep/references/rule_reference.md: Detailed rule docs - agents/ast-grep.md: Simplified to focus on MCP tool usage Agent now references skill via Skill("code-please:ast-grep") --- agents/ast-grep.md | 332 +++++------------ skills/ast-grep/SKILL.md | 232 ++++++++++++ skills/ast-grep/references/rule_reference.md | 368 +++++++++++++++++++ 3 files changed, 694 insertions(+), 238 deletions(-) create mode 100644 skills/ast-grep/SKILL.md create mode 100644 skills/ast-grep/references/rule_reference.md diff --git a/agents/ast-grep.md b/agents/ast-grep.md index 85f6db5..fad0938 100644 --- a/agents/ast-grep.md +++ b/agents/ast-grep.md @@ -44,240 +44,46 @@ tools: ast_grep_search, ast_grep_replace, ast_grep_analyze, ast_grep_transform model: sonnet --- -# AST-GREP PATTERN EXPERT +# AST-GREP MCP TOOL EXPERT -You are an expert at writing ast-grep patterns and rules for structural code search. +You are an expert at using ast-grep MCP tools provided by the dora server. ---- - -## CORE CONCEPTS - -### What is ast-grep? - -ast-grep performs **syntax-aware pattern matching** using Abstract Syntax Trees (AST). Unlike text-based search (grep/ripgrep), it understands code structure. - -**Key differences from text search:** -- Matches code semantics, not text -- Ignores whitespace and formatting -- Can match across multiple lines -- Understands language syntax +## Related Skill -### Meta-variables - -Meta-variables capture parts of the AST: - -| Syntax | Meaning | Example | -|--------|---------|---------| -| `$VAR` | Single named node (identifier, expression) | `console.log($MSG)` | -| `$$VAR` | Unnamed node (operators, punctuation) | `$A $$OP $B` | -| `$$$MULTI` | Zero or more nodes (non-greedy) | `function $NAME($$$ARGS)` | -| `_` | Wildcard (anonymous, non-capturing) | `if ($_) { $$$ }` | - -**Naming rules:** Use UPPERCASE letters, numbers, underscores only. - ---- - -## PATTERN SYNTAX - -### Simple Patterns - -```bash -# Find console.log calls -ast-grep run --pattern 'console.log($MSG)' --lang javascript - -# Find function definitions -ast-grep run --pattern 'function $NAME($$$) { $$$ }' --lang javascript - -# Find Python class definitions -ast-grep run --pattern 'class $NAME' --lang python +For detailed ast-grep pattern syntax and rule writing, load the skill: ``` - -### Pattern Rules - -1. **Patterns must be valid code** - they're parsed as AST -2. **No trailing colons in Python** - `def $NAME($$$)` not `def $NAME($$$):` -3. **Include body for functions** - `function $NAME($$$) { $$$ }` not `function $NAME` - ---- - -## YAML RULE SYNTAX - -For complex queries, use YAML rules with operators. - -### Rule Structure - -```yaml -id: rule-name -language: javascript -rule: -# Rule definition here -message: Explanation of what was found +Skill("code-please:ast-grep") ``` -### Atomic Rules - -| Rule | Purpose | Example | -|------|---------|---------| -| `pattern` | Match AST pattern | `pattern: console.log($MSG)` | -| `kind` | Match node type | `kind: function_declaration` | -| `regex` | Match text with regex | `regex: "^test_"` | -| `nthChild` | Match by position | `nthChild: 1` | - -### Relational Rules - -| Rule | Purpose | Example | -|------|---------|---------| -| `inside` | Node is inside another | `inside: { kind: class_body }` | -| `has` | Node contains another | `has: { pattern: await $X }` | -| `precedes` | Node comes before | `precedes: { kind: return_statement }` | -| `follows` | Node comes after | `follows: { kind: import_statement }` | +## Available MCP Tools -**Important:** Always use `stopBy: end` for relational rules to search the entire subtree. +### ast_grep_search (CLI - 25 languages) -### Composite Rules - -| Rule | Purpose | Example | -|------|---------|---------| -| `all` | AND - all must match | `all: [rule1, rule2]` | -| `any` | OR - any must match | `any: [rule1, rule2]` | -| `not` | Negate a rule | `not: { pattern: ... }` | -| `matches` | Reference utility rule | `matches: utility-rule-id` | - ---- - -## COMMON PATTERNS BY LANGUAGE - -### JavaScript/TypeScript - -```yaml -# Find async functions without try-catch -id: async-without-try -language: javascript -rule: - kind: function_declaration - has: - pattern: async - not: - has: - kind: try_statement - stopBy: end ---- -# Find React useState without dependency -id: usestate-in-component -language: tsx -rule: - pattern: useState($INIT) - inside: - kind: function_declaration - stopBy: end ---- -# Find console.log in production code -id: no-console-log -language: javascript -rule: - pattern: console.log($$$) - not: - inside: - regex: test|spec|__tests__ - kind: string -``` - -### Python - -```yaml -# Find functions without docstrings -id: missing-docstring -language: python -rule: - kind: function_definition - not: - has: - kind: expression_statement - has: - kind: string - nthChild: 1 - stopBy: end ---- -# Find bare except clauses -id: bare-except -language: python -rule: - kind: except_clause - not: - has: - kind: identifier -``` - -### Go - -```yaml -# Find error not checked -id: unchecked-error -language: go -rule: - pattern: $_, $ERR := $CALL - not: - precedes: - pattern: if $ERR != nil - stopBy: neighbor -``` - ---- - -## DEBUGGING PATTERNS - -### Check AST structure - -```bash -# Dump CST (Concrete Syntax Tree) -ast-grep run --pattern '$ROOT' --debug-query=cst --lang javascript file.js - -# Dump AST -ast-grep run --pattern '$ROOT' --debug-query=ast --lang javascript file.js - -# Show pattern parse result -ast-grep run --pattern 'console.log($X)' --debug-query=pattern --lang javascript file.js -``` - -### Common Mistakes - -| Mistake | Problem | Fix | -|---------|---------|-----| -| `def $NAME():` | Trailing colon in Python | `def $NAME($$$)` | -| `function $NAME` | Incomplete pattern | `function $NAME($$$) { $$$ }` | -| `$a` | Lowercase meta-var | `$A` (uppercase) | -| Missing `stopBy: end` | Only searches immediate children | Add `stopBy: end` to relational rules | - ---- - -## USING MCP TOOLS - -### ast_grep_search - -Search for patterns across files: +Search code patterns across filesystem using AST-aware matching. ```json { "pattern": "console.log($MSG)", "lang": "javascript", "paths": ["./src"], - "globs": ["!**/node_modules/**"] + "globs": ["!**/node_modules/**"], + "context": 2, + "ruleFile": "./rules/custom.yaml" } ``` -Or with YAML rule file: +**Parameters:** +- `pattern` (required): AST pattern with meta-variables +- `lang` (required): Target language +- `paths`: Paths to search (default: ['.']) +- `globs`: Include/exclude globs (prefix ! to exclude) +- `context`: Context lines around match +- `ruleFile`: Path to YAML rule file (alternative to pattern) -```json -{ - "ruleFile": "./rules/no-console.yaml", - "lang": "javascript", - "paths": ["./src"] -} -``` - -### ast_grep_replace +### ast_grep_replace (CLI - 25 languages) -Transform code (dry-run by default): +Replace code patterns across filesystem with AST-aware rewriting. +**Dry-run by default** - use `dryRun: false` to apply changes. ```json { @@ -289,9 +95,18 @@ Transform code (dry-run by default): } ``` -### ast_grep_analyze (NAPI - In-Memory) +**Parameters:** +- `pattern` (required): AST pattern to match +- `rewrite` (required): Replacement pattern (can use $VAR from pattern) +- `lang` (required): Target language +- `paths`: Paths to search +- `globs`: Include/exclude globs +- `dryRun`: Preview changes without applying (default: true) -Analyze code in-memory without file I/O (faster, supports 5 languages: html, javascript, tsx, css, typescript): +### ast_grep_analyze (NAPI - 5 languages) + +Analyze code in-memory without file I/O. Faster for single-file analysis. +Supports: html, javascript, tsx, css, typescript ```json { @@ -302,11 +117,16 @@ Analyze code in-memory without file I/O (faster, supports 5 languages: html, jav } ``` -Returns matches with optional meta-variable extraction. Use for single-file analysis or quick pattern testing. +**Parameters:** +- `code` (required): Source code to analyze +- `pattern` (required): AST pattern to match +- `lang` (required): Target language (html, javascript, tsx, css, typescript) +- `extractMetaVars`: Extract meta-variable values (default: false) -### ast_grep_transform (NAPI - In-Memory) +### ast_grep_transform (NAPI - 5 languages) -Transform code in-memory without modifying files (supports 5 languages: html, javascript, tsx, css, typescript): +Transform code in-memory without modifying files. +Supports: html, javascript, tsx, css, typescript ```json { @@ -317,31 +137,67 @@ Transform code in-memory without modifying files (supports 5 languages: html, ja } ``` -Returns transformed code without writing to disk. Use for previewing transformations or processing code strings. +**Parameters:** +- `code` (required): Source code to transform +- `pattern` (required): AST pattern to match +- `rewrite` (required): Replacement pattern +- `lang` (required): Target language (html, javascript, tsx, css, typescript) -**Note:** NAPI tools require `@ast-grep/napi` optional dependency. If not installed, use CLI-based `ast_grep_search` and `ast_grep_replace` instead. +## Tool Selection Guide ---- +| Use Case | Tool | Reason | +|----------|------|--------| +| Search files on disk | `ast_grep_search` | CLI, 25 languages | +| Transform files on disk | `ast_grep_replace` | CLI, 25 languages | +| Analyze code string | `ast_grep_analyze` | NAPI, faster, no file I/O | +| Transform code string | `ast_grep_transform` | NAPI, faster, no file I/O | +| Complex structural queries | `ast_grep_search` + ruleFile | YAML rules for advanced logic | -## WORKFLOW +## Quick Pattern Reference -1. **Understand the query** - What code structure are you looking for? -2. **Create example code** - Write sample code that matches what you want to find -3. **Write the pattern/rule** - Start simple, add complexity as needed -4. **Test the pattern** - Use `--debug-query` to verify AST structure -5. **Run the search** - Apply to codebase +### Meta-variables ---- +| Syntax | Meaning | Example | +|--------|---------|---------| +| `$VAR` | Single node | `console.log($MSG)` | +| `$$$` | Multiple nodes | `function $NAME($$$)` | +| `_` | Wildcard | `if ($_) { $$$ }` | -## SUPPORTED LANGUAGES (25) +### Common Patterns -bash, c, cpp, csharp, css, elixir, go, haskell, html, java, javascript, json, kotlin, lua, nix, php, python, ruby, rust, scala, solidity, swift, typescript, tsx, yaml +``` +# JavaScript/TypeScript +console.log($MSG) # Find console.log +function $NAME($$$) { $$$ } # Find function declarations +await $EXPR # Find await expressions +import $X from '$PATH' # Find imports + +# Python +def $NAME($$$) # Find function definitions (no colon!) +class $NAME # Find class definitions +import $MODULE # Find imports + +# Go +func $NAME($$$) $RET # Find function declarations +if err != nil # Find error checks +``` ---- +### Common Mistakes to Avoid + +1. **Incomplete patterns**: `function $NAME` → `function $NAME($$$) { $$$ }` +2. **Python trailing colon**: `def $NAME():` → `def $NAME($$$)` +3. **Lowercase meta-vars**: `$name` → `$NAME` + +## Workflow + +1. **Understand what to find** - What code structure? +2. **Choose the right tool** - CLI for files, NAPI for strings +3. **Write the pattern** - Start simple, add complexity +4. **Test with dry-run** - Always preview before applying changes +5. **Apply changes** - Set `dryRun: false` when ready + +## Supported Languages -## REFERENCES +**CLI (25):** bash, c, cpp, csharp, css, elixir, go, haskell, html, java, javascript, json, kotlin, lua, nix, php, python, ruby, rust, scala, solidity, swift, typescript, tsx, yaml -- [ast-grep Documentation](https://ast-grep.github.io/) -- [Pattern Syntax](https://ast-grep.github.io/guide/pattern-syntax.html) -- [Rule Configuration](https://ast-grep.github.io/reference/rule.html) -- [Playground](https://ast-grep.github.io/playground.html) +**NAPI (5):** html, javascript, tsx, css, typescript diff --git a/skills/ast-grep/SKILL.md b/skills/ast-grep/SKILL.md new file mode 100644 index 0000000..269004f --- /dev/null +++ b/skills/ast-grep/SKILL.md @@ -0,0 +1,232 @@ +# ast-grep Pattern Writing Guide + +> AST-aware structural code search and transformation using pattern matching + +## When to Use This Skill + +This skill is activated when you need to: + +- Write ast-grep patterns for structural code search +- Create YAML rules for complex queries +- Debug patterns that aren't matching +- Transform code using AST-aware rewriting +- User mentions: `ast-grep pattern`, `AST search`, `structural search`, `code pattern`, `meta-variables` + +## What is ast-grep? + +**ast-grep** performs **syntax-aware pattern matching** using Abstract Syntax Trees (AST). Unlike text-based search (grep/ripgrep), it understands code structure. + +**Key differences from text search:** +- Matches code semantics, not text +- Ignores whitespace and formatting +- Can match across multiple lines +- Understands language syntax + +## Meta-variables + +Meta-variables capture parts of the AST: + +| Syntax | Meaning | Example | +|--------|---------|---------| +| `$VAR` | Single named node (identifier, expression) | `console.log($MSG)` | +| `$$VAR` | Unnamed node (operators, punctuation) | `$A $$OP $B` | +| `$$$MULTI` | Zero or more nodes (non-greedy) | `function $NAME($$$ARGS)` | +| `_` | Wildcard (anonymous, non-capturing) | `if ($_) { $$$ }` | + +**Naming rules:** Use UPPERCASE letters, numbers, underscores only. + +## Pattern Syntax + +### Simple Patterns + +```bash +# Find console.log calls +ast-grep run --pattern 'console.log($MSG)' --lang javascript + +# Find function definitions +ast-grep run --pattern 'function $NAME($$$) { $$$ }' --lang javascript + +# Find Python class definitions +ast-grep run --pattern 'class $NAME' --lang python +``` + +### Pattern Rules + +1. **Patterns must be valid code** - they're parsed as AST +2. **No trailing colons in Python** - `def $NAME($$$)` not `def $NAME($$$):` +3. **Include body for functions** - `function $NAME($$$) { $$$ }` not `function $NAME` + +## YAML Rule Syntax + +For complex queries, use YAML rules with operators. + +### Rule Structure + +```yaml +id: rule-name +language: javascript +rule: + pattern: console.log($MSG) +message: Explanation of what was found +``` + +### Atomic Rules + +| Rule | Purpose | Example | +|------|---------|---------| +| `pattern` | Match AST pattern | `pattern: console.log($MSG)` | +| `kind` | Match node type | `kind: function_declaration` | +| `regex` | Match text with regex | `regex: "^test_"` | +| `nthChild` | Match by position | `nthChild: 1` | + +### Relational Rules + +| Rule | Purpose | Example | +|------|---------|---------| +| `inside` | Node is inside another | `inside: { kind: class_body }` | +| `has` | Node contains another | `has: { pattern: await $X }` | +| `precedes` | Node comes before | `precedes: { kind: return_statement }` | +| `follows` | Node comes after | `follows: { kind: import_statement }` | + +**Important:** Always use `stopBy: end` for relational rules to search the entire subtree. + +### Composite Rules + +| Rule | Purpose | Example | +|------|---------|---------| +| `all` | AND - all must match | `all: [rule1, rule2]` | +| `any` | OR - any must match | `any: [rule1, rule2]` | +| `not` | Negate a rule | `not: { pattern: ... }` | +| `matches` | Reference utility rule | `matches: utility-rule-id` | + +## Common Patterns by Language + +### JavaScript/TypeScript + +```yaml +# Find async functions without try-catch +id: async-without-try +language: javascript +rule: + kind: function_declaration + has: + pattern: async + not: + has: + kind: try_statement + stopBy: end +``` + +```yaml +# Find React useState in component +id: usestate-in-component +language: tsx +rule: + pattern: useState($INIT) + inside: + kind: function_declaration + stopBy: end +``` + +```yaml +# Find console.log in production code +id: no-console-log +language: javascript +rule: + pattern: console.log($$$) + not: + inside: + regex: test|spec|__tests__ + kind: string +``` + +### Python + +```yaml +# Find functions without docstrings +id: missing-docstring +language: python +rule: + kind: function_definition + not: + has: + kind: expression_statement + has: + kind: string + nthChild: 1 + stopBy: end +``` + +```yaml +# Find bare except clauses +id: bare-except +language: python +rule: + kind: except_clause + not: + has: + kind: identifier +``` + +### Go + +```yaml +# Find error not checked +id: unchecked-error +language: go +rule: + pattern: $_, $ERR := $CALL + not: + precedes: + pattern: if $ERR != nil + stopBy: neighbor +``` + +## Debugging Patterns + +### Check AST structure + +```bash +# Dump CST (Concrete Syntax Tree) +ast-grep run --pattern '$ROOT' --debug-query=cst --lang javascript file.js + +# Dump AST +ast-grep run --pattern '$ROOT' --debug-query=ast --lang javascript file.js + +# Show pattern parse result +ast-grep run --pattern 'console.log($X)' --debug-query=pattern --lang javascript file.js +``` + +### Common Mistakes + +| Mistake | Problem | Fix | +|---------|---------|-----| +| `def $NAME():` | Trailing colon in Python | `def $NAME($$$)` | +| `function $NAME` | Incomplete pattern | `function $NAME($$$) { $$$ }` | +| `$a` | Lowercase meta-var | `$A` (uppercase) | +| Missing `stopBy: end` | Only searches immediate children | Add `stopBy: end` to relational rules | + +## Workflow + +1. **Understand the query** - What code structure are you looking for? +2. **Create example code** - Write sample code that matches what you want to find +3. **Write the pattern/rule** - Start simple, add complexity as needed +4. **Test the pattern** - Use `--debug-query` to verify AST structure +5. **Run the search** - Apply to codebase + +## Supported Languages (25) + +bash, c, cpp, csharp, css, elixir, go, haskell, html, java, javascript, json, kotlin, lua, nix, php, python, ruby, rust, scala, solidity, swift, typescript, tsx, yaml + +## References + +- [ast-grep Documentation](https://ast-grep.github.io/) +- [Pattern Syntax](https://ast-grep.github.io/guide/pattern-syntax.html) +- [Rule Configuration](https://ast-grep.github.io/reference/rule.html) +- [Playground](https://ast-grep.github.io/playground.html) + +See `references/rule_reference.md` for detailed rule syntax documentation. + +## Related Agent + +For using ast-grep with dora MCP tools, see the `code-please:ast-grep` agent definition. diff --git a/skills/ast-grep/references/rule_reference.md b/skills/ast-grep/references/rule_reference.md new file mode 100644 index 0000000..6c10084 --- /dev/null +++ b/skills/ast-grep/references/rule_reference.md @@ -0,0 +1,368 @@ +# ast-grep Rule Reference + +> Complete reference for ast-grep rule syntax and operators + +## Rule Object Structure + +Every field within an ast-grep Rule Object is optional, but at least one **positive** key (e.g., `kind`, `pattern`) must be present. + +### Properties Table + +| Property | Type | Description | +|----------|------|-------------| +| `pattern` | string | AST pattern to match | +| `kind` | string | Node type to match | +| `regex` | string | Regular expression for text matching | +| `nthChild` | number or object | Match by position in parent | +| `range` | object | Match by source position | +| `inside` | Rule | Match if inside another node | +| `has` | Rule | Match if contains another node | +| `precedes` | Rule | Match if before another node | +| `follows` | Rule | Match if after another node | +| `all` | Rule[] | All rules must match (AND) | +| `any` | Rule[] | Any rule must match (OR) | +| `not` | Rule | Negate a rule | +| `matches` | string | Reference a utility rule by ID | +| `stopBy` | string | Control traversal depth | + +## Atomic Rules + +### pattern + +Matches nodes using AST pattern syntax with meta-variables. + +```yaml +rule: + pattern: console.log($MSG) +``` + +**Meta-variable types:** +- `$VAR` - Single named node +- `$$VAR` - Single unnamed node (operators) +- `$$$VAR` - Multiple nodes (variadic) +- `$_` - Anonymous wildcard + +**Important:** Meta-variables must be the only text within an AST node to function correctly. + +### kind + +Matches nodes by their AST node type. + +```yaml +rule: + kind: function_declaration +``` + +Common node types by language: + +| Language | Function | Class | Variable | +|----------|----------|-------|----------| +| JavaScript | `function_declaration` | `class_declaration` | `variable_declaration` | +| TypeScript | `function_declaration` | `class_declaration` | `lexical_declaration` | +| Python | `function_definition` | `class_definition` | `assignment` | +| Go | `function_declaration` | `type_declaration` | `short_var_declaration` | +| Rust | `function_item` | `struct_item` | `let_declaration` | + +### regex + +Matches the text content of a node against a regular expression. + +```yaml +rule: + kind: identifier + regex: ^test_ +``` + +### nthChild + +Matches nodes by their position within the parent. + +```yaml +# Match first child +rule: + nthChild: 1 +``` + +```yaml +# Match with options +rule: + nthChild: + position: 2 + ofRule: + kind: argument +``` + +### range + +Matches nodes by source position. + +```yaml +rule: + range: + start: {line: 10, column: 0} + end: {line: 20, column: 0} +``` + +## Relational Rules + +### inside + +Matches if the node is inside another node matching the rule. + +```yaml +rule: + pattern: console.log($MSG) + inside: + kind: class_body + stopBy: end # Always include this +``` + +### has + +Matches if the node contains another node matching the rule. + +```yaml +rule: + kind: function_declaration + has: + pattern: await $EXPR + stopBy: end # Always include this +``` + +### precedes + +Matches if the node comes before another node matching the rule. + +```yaml +rule: + pattern: $X = null + precedes: + pattern: if ($X == null) + stopBy: neighbor # Only check immediate siblings +``` + +### follows + +Matches if the node comes after another node matching the rule. + +```yaml +rule: + kind: return_statement + follows: + kind: if_statement + stopBy: neighbor +``` + +### stopBy Options + +Controls how far relational rules traverse: + +| Value | Description | +|-------|-------------| +| `end` | Search entire subtree (recommended default) | +| `neighbor` | Only check immediate siblings | +| `{ kind: "node_type" }` | Stop at specific node type | +| `{ pattern: "..." }` | Stop at pattern match | + +**Critical:** Without `stopBy: end`, relational rules only search immediate children. + +## Composite Rules + +### all + +All sub-rules must match (logical AND). + +```yaml +rule: + all: + - kind: function_declaration + - has: + pattern: async + - not: + has: + kind: try_statement + stopBy: end +``` + +### any + +Any sub-rule must match (logical OR). + +```yaml +rule: + any: + - pattern: console.log($X) + - pattern: console.warn($X) + - pattern: console.error($X) +``` + +### not + +Negates a rule. + +```yaml +rule: + kind: function_declaration + not: + has: + kind: return_statement + stopBy: end +``` + +### matches + +References a utility rule defined in the same file. + +```yaml +utils: + has-error-handling: + has: + kind: try_statement + stopBy: end + +rules: + - id: async-without-error-handling + language: javascript + rule: + kind: function_declaration + has: + pattern: async + not: + matches: has-error-handling +``` + +## Fix and Rewrite + +### Basic Rewrite + +```yaml +id: replace-console-log +language: javascript +rule: + pattern: console.log($MSG) +fix: logger.info($MSG) +``` + +### Conditional Fix + +```yaml +id: add-type-annotation +language: typescript +rule: + pattern: const $NAME = $VALUE + not: + pattern: 'const $NAME: $TYPE = $VALUE' +fix: 'const $NAME: unknown = $VALUE' +``` + +### Multi-line Fix + +```yaml +fix: | + try { + $BODY + } catch (error) { + console.error(error) + } +``` + +## Complete Examples + +### Find Async Functions Without Error Handling + +```yaml +id: async-no-try-catch +language: typescript +severity: warning +rule: + all: + - kind: function_declaration + - has: + pattern: async + - not: + has: + kind: try_statement + stopBy: end +message: Async function without try-catch error handling +``` + +### Find Unused Variables + +```yaml +id: unused-variable +language: javascript +rule: + kind: variable_declarator + pattern: $NAME = $VALUE + not: + precedes: + pattern: $NAME + stopBy: end +message: Variable '$NAME' is declared but never used +``` + +### Find React Hooks Outside Components + +```yaml +id: hooks-outside-component +language: tsx +rule: + any: + - pattern: useState($$$) + - pattern: useEffect($$$) + - pattern: useMemo($$$) + not: + inside: + any: + - kind: function_declaration + - kind: arrow_function + stopBy: end +message: React hooks must be called inside function components +``` + +### Find SQL Injection Risks + +```yaml +id: sql-injection-risk +language: javascript +rule: + pattern: $DB.query(`$$$${$VAR}$$$`) +message: Potential SQL injection - use parameterized queries +fix: $DB.query($SQL, [$VAR]) +``` + +## Troubleshooting + +### Pattern Not Matching + +1. **Check AST structure:** + ```bash + ast-grep run --pattern '$ROOT' --debug-query=cst --lang javascript file.js + ``` + +2. **Verify node type:** + ```bash + ast-grep run --pattern '$ROOT' --debug-query=ast --lang javascript file.js + ``` + +3. **Test pattern parsing:** + ```bash + ast-grep run --pattern 'your_pattern' --debug-query=pattern --lang javascript file.js + ``` + +### Common Issues + +| Issue | Cause | Solution | +|-------|-------|----------| +| No matches | Incomplete pattern | Ensure pattern is complete AST node | +| No matches | Wrong node type | Use `--debug-query=cst` to check actual types | +| Partial matches | Missing `stopBy: end` | Add `stopBy: end` to relational rules | +| Too many matches | Pattern too broad | Add constraints with `kind` or `inside` | + +### Debugging Checklist + +- [ ] Pattern is valid code that parses correctly +- [ ] Meta-variables use UPPERCASE names +- [ ] Relational rules have `stopBy: end` +- [ ] Node types match the target language +- [ ] Pattern captures complete AST nodes From ae5812cc056e05127b63ad370d4fbe72b60916f4 Mon Sep 17 00:00:00 2001 From: Minsu Lee Date: Fri, 19 Dec 2025 14:05:11 +0900 Subject: [PATCH 6/9] docs(ast-grep): update skill based on official ast-grep documentation Align skill content with official ast-grep/claude-skill repository: - Add 5-step workflow for writing ast-grep rules - Document ast-grep scan with --inline-rules for testing - Add pattern object form with selector, context, strictness - Add stopBy and field options for relational rules - Expand metavariables documentation - Add best practices and troubleshooting sections --- skills/ast-grep/SKILL.md | 353 +++++++++-------- skills/ast-grep/references/rule_reference.md | 389 ++++++++++--------- 2 files changed, 397 insertions(+), 345 deletions(-) diff --git a/skills/ast-grep/SKILL.md b/skills/ast-grep/SKILL.md index 269004f..00fc85e 100644 --- a/skills/ast-grep/SKILL.md +++ b/skills/ast-grep/SKILL.md @@ -1,218 +1,265 @@ -# ast-grep Pattern Writing Guide +--- +name: ast-grep +description: Guide for writing ast-grep rules to perform structural code search and analysis. Use when users need to search codebases using Abstract Syntax Tree (AST) patterns, find specific code structures, or perform complex code queries that go beyond simple text search. This skill should be used when users ask to search for code patterns, find specific language constructs, or locate code with particular structural characteristics. +--- -> AST-aware structural code search and transformation using pattern matching +# ast-grep Code Search + +> Guide for writing ast-grep rules to perform structural code search and analysis + +## Overview + +This skill helps translate natural language queries into ast-grep rules for structural code search. ast-grep uses Abstract Syntax Tree (AST) patterns to match code based on its structure rather than just text, enabling powerful and precise code search across large codebases. ## When to Use This Skill -This skill is activated when you need to: +Use this skill when users: +- Need to search for code patterns using structural matching (e.g., "find all async functions that don't have error handling") +- Want to locate specific language constructs (e.g., "find all function calls with specific parameters") +- Request searches that require understanding code structure rather than just text +- Ask to search for code with particular AST characteristics +- Need to perform complex code queries that traditional text search cannot handle -- Write ast-grep patterns for structural code search -- Create YAML rules for complex queries -- Debug patterns that aren't matching -- Transform code using AST-aware rewriting -- User mentions: `ast-grep pattern`, `AST search`, `structural search`, `code pattern`, `meta-variables` +## General Workflow -## What is ast-grep? +Follow this process to help users write effective ast-grep rules: -**ast-grep** performs **syntax-aware pattern matching** using Abstract Syntax Trees (AST). Unlike text-based search (grep/ripgrep), it understands code structure. +### Step 1: Understand the Query -**Key differences from text search:** -- Matches code semantics, not text -- Ignores whitespace and formatting -- Can match across multiple lines -- Understands language syntax +Clearly understand what the user wants to find. Ask clarifying questions if needed: +- What specific code pattern or structure are they looking for? +- Which programming language? +- Are there specific edge cases or variations to consider? +- What should be included or excluded from matches? -## Meta-variables +### Step 2: Create Example Code -Meta-variables capture parts of the AST: +Write a simple code snippet that represents what the user wants to match. Save this to a temporary file for testing. -| Syntax | Meaning | Example | -|--------|---------|---------| -| `$VAR` | Single named node (identifier, expression) | `console.log($MSG)` | -| `$$VAR` | Unnamed node (operators, punctuation) | `$A $$OP $B` | -| `$$$MULTI` | Zero or more nodes (non-greedy) | `function $NAME($$$ARGS)` | -| `_` | Wildcard (anonymous, non-capturing) | `if ($_) { $$$ }` | +**Example:** +If searching for "async functions that use await", create a test file: -**Naming rules:** Use UPPERCASE letters, numbers, underscores only. +```javascript +// test_example.js +async function example() { + const result = await fetchData() + return result +} +``` -## Pattern Syntax +### Step 3: Write the ast-grep Rule -### Simple Patterns +Translate the pattern into an ast-grep rule. Start simple and add complexity as needed. -```bash -# Find console.log calls -ast-grep run --pattern 'console.log($MSG)' --lang javascript +**Key principles:** +- Always use `stopBy: end` for relational rules (`inside`, `has`) to ensure search goes to the end of the direction +- Use `pattern` for simple structures +- Use `kind` with `has`/`inside` for complex structures +- Break complex queries into smaller sub-rules using `all`, `any`, or `not` -# Find function definitions -ast-grep run --pattern 'function $NAME($$$) { $$$ }' --lang javascript +**Example rule file (test_rule.yml):** -# Find Python class definitions -ast-grep run --pattern 'class $NAME' --lang python +```yaml +id: async-with-await +language: javascript +rule: + kind: function_declaration + has: + pattern: await $EXPR + stopBy: end ``` -### Pattern Rules - -1. **Patterns must be valid code** - they're parsed as AST -2. **No trailing colons in Python** - `def $NAME($$$)` not `def $NAME($$$):` -3. **Include body for functions** - `function $NAME($$$) { $$$ }` not `function $NAME` +See `references/rule_reference.md` for comprehensive rule documentation. -## YAML Rule Syntax +### Step 4: Test the Rule -For complex queries, use YAML rules with operators. +Use ast-grep CLI to verify the rule matches the example code. -### Rule Structure +**Option A: Test with inline rules (for quick iterations)** -```yaml -id: rule-name +```bash +echo "async function test() { await fetch(); }" | ast-grep scan --inline-rules "id: test language: javascript rule: - pattern: console.log($MSG) -message: Explanation of what was found + kind: function_declaration + has: + pattern: await \$EXPR + stopBy: end" --stdin ``` -### Atomic Rules - -| Rule | Purpose | Example | -|------|---------|---------| -| `pattern` | Match AST pattern | `pattern: console.log($MSG)` | -| `kind` | Match node type | `kind: function_declaration` | -| `regex` | Match text with regex | `regex: "^test_"` | -| `nthChild` | Match by position | `nthChild: 1` | +**Option B: Test with rule files (recommended for complex rules)** -### Relational Rules +```bash +ast-grep scan --rule test_rule.yml test_example.js +``` -| Rule | Purpose | Example | -|------|---------|---------| -| `inside` | Node is inside another | `inside: { kind: class_body }` | -| `has` | Node contains another | `has: { pattern: await $X }` | -| `precedes` | Node comes before | `precedes: { kind: return_statement }` | -| `follows` | Node comes after | `follows: { kind: import_statement }` | +**Debugging if no matches:** +1. Simplify the rule (remove sub-rules) +2. Add `stopBy: end` to relational rules if not present +3. Use `--debug-query` to understand the AST structure +4. Check if `kind` values are correct for the language -**Important:** Always use `stopBy: end` for relational rules to search the entire subtree. +### Step 5: Search the Codebase -### Composite Rules +Once the rule matches the example code correctly, search the actual codebase: -| Rule | Purpose | Example | -|------|---------|---------| -| `all` | AND - all must match | `all: [rule1, rule2]` | -| `any` | OR - any must match | `any: [rule1, rule2]` | -| `not` | Negate a rule | `not: { pattern: ... }` | -| `matches` | Reference utility rule | `matches: utility-rule-id` | +**For simple pattern searches:** -## Common Patterns by Language +```bash +ast-grep run --pattern 'console.log($ARG)' --lang javascript /path/to/project +``` -### JavaScript/TypeScript +**For complex rule-based searches:** -```yaml -# Find async functions without try-catch -id: async-without-try -language: javascript -rule: - kind: function_declaration - has: - pattern: async - not: - has: - kind: try_statement - stopBy: end +```bash +ast-grep scan --rule my_rule.yml /path/to/project ``` -```yaml -# Find React useState in component -id: usestate-in-component -language: tsx -rule: - pattern: useState($INIT) - inside: - kind: function_declaration - stopBy: end +## ast-grep CLI Commands + +### Inspect Code Structure (--debug-query) + +Dump the AST structure to understand how code is parsed: + +```bash +ast-grep run --pattern 'async function example() { await fetch(); }' \ + --lang javascript \ + --debug-query=cst ``` -```yaml -# Find console.log in production code -id: no-console-log +**Available formats:** +- `cst`: Concrete Syntax Tree (shows all nodes including punctuation) +- `ast`: Abstract Syntax Tree (shows only named nodes) +- `pattern`: Shows how ast-grep interprets your pattern + +### Test Rules (scan with --stdin) + +Test a rule against code snippet without creating files: + +```bash +echo "const x = await fetch();" | ast-grep scan --inline-rules "id: test language: javascript rule: - pattern: console.log($$$) - not: - inside: - regex: test|spec|__tests__ - kind: string + pattern: await \$EXPR" --stdin ``` -### Python +### Search with Patterns (run) -```yaml -# Find functions without docstrings -id: missing-docstring -language: python -rule: - kind: function_definition - not: - has: - kind: expression_statement - has: - kind: string - nthChild: 1 - stopBy: end +Simple pattern-based search for single AST node matches: + +```bash +# Basic pattern search +ast-grep run --pattern 'console.log($ARG)' --lang javascript . + +# JSON output for programmatic use +ast-grep run --pattern 'function $NAME($$$)' --lang javascript --json . ``` -```yaml -# Find bare except clauses -id: bare-except -language: python +### Search with Rules (scan) + +YAML rule-based search for complex structural queries: + +```bash +# With rule file +ast-grep scan --rule my_rule.yml /path/to/project + +# With inline rules +ast-grep scan --inline-rules "id: find-async +language: javascript rule: - kind: except_clause - not: - has: - kind: identifier + kind: function_declaration" . ``` -### Go +## Best Practices + +### Always Use stopBy: end + +For relational rules (`inside`, `has`), always include `stopBy: end`: ```yaml -# Find error not checked -id: unchecked-error -language: go -rule: - pattern: $_, $ERR := $CALL - not: - precedes: - pattern: if $ERR != nil - stopBy: neighbor +has: + pattern: await $EXPR + stopBy: end ``` -## Debugging Patterns +This ensures the search traverses the entire subtree rather than stopping at the first non-matching node. -### Check AST structure +### Start Simple, Then Add Complexity -```bash -# Dump CST (Concrete Syntax Tree) -ast-grep run --pattern '$ROOT' --debug-query=cst --lang javascript file.js +1. Try a `pattern` first +2. If that doesn't work, try `kind` to match the node type +3. Add relational rules (`has`, `inside`) as needed +4. Combine with composite rules (`all`, `any`, `not`) for complex logic + +### Use the Right Rule Type + +- **Pattern**: For simple, direct code matching (e.g., `console.log($ARG)`) +- **Kind + Relational**: For complex structures (e.g., "function containing await") +- **Composite**: For logical combinations (e.g., "function with await but not in try-catch") + +### Debug with AST Inspection -# Dump AST -ast-grep run --pattern '$ROOT' --debug-query=ast --lang javascript file.js +When rules don't match: +1. Use `--debug-query=cst` to see the actual AST structure +2. Check if metavariables are being detected correctly +3. Verify the node `kind` matches what you expect +4. Ensure relational rules are searching in the right direction -# Show pattern parse result -ast-grep run --pattern 'console.log($X)' --debug-query=pattern --lang javascript file.js +### Escaping in Inline Rules + +When using `--inline-rules`, escape metavariables in shell commands: +- Use `\$VAR` instead of `$VAR` (shell interprets `$` as variable) +- Or use single quotes: `'$VAR'` works in most shells + +## Common Use Cases + +### Find Functions with Specific Content + +Find async functions that use await: + +```bash +ast-grep scan --inline-rules "id: async-await +language: javascript +rule: + all: + - kind: function_declaration + - has: + pattern: await \$EXPR + stopBy: end" /path/to/project ``` -### Common Mistakes +### Find Code Inside Specific Contexts -| Mistake | Problem | Fix | -|---------|---------|-----| -| `def $NAME():` | Trailing colon in Python | `def $NAME($$$)` | -| `function $NAME` | Incomplete pattern | `function $NAME($$$) { $$$ }` | -| `$a` | Lowercase meta-var | `$A` (uppercase) | -| Missing `stopBy: end` | Only searches immediate children | Add `stopBy: end` to relational rules | +Find console.log inside class methods: -## Workflow +```bash +ast-grep scan --inline-rules "id: console-in-class +language: javascript +rule: + pattern: console.log(\$\$\$) + inside: + kind: method_definition + stopBy: end" /path/to/project +``` + +### Find Code Missing Expected Patterns + +Find async functions without try-catch: -1. **Understand the query** - What code structure are you looking for? -2. **Create example code** - Write sample code that matches what you want to find -3. **Write the pattern/rule** - Start simple, add complexity as needed -4. **Test the pattern** - Use `--debug-query` to verify AST structure -5. **Run the search** - Apply to codebase +```bash +ast-grep scan --inline-rules "id: async-no-trycatch +language: javascript +rule: + all: + - kind: function_declaration + - has: + pattern: await \$EXPR + stopBy: end + - not: + has: + pattern: try { \$\$\$ } catch (\$E) { \$\$\$ } + stopBy: end" /path/to/project +``` ## Supported Languages (25) diff --git a/skills/ast-grep/references/rule_reference.md b/skills/ast-grep/references/rule_reference.md index 6c10084..a4b80d5 100644 --- a/skills/ast-grep/references/rule_reference.md +++ b/skills/ast-grep/references/rule_reference.md @@ -1,56 +1,87 @@ # ast-grep Rule Reference -> Complete reference for ast-grep rule syntax and operators +This document provides comprehensive documentation for ast-grep rule syntax, covering all rule types and metavariables. + +## Introduction to ast-grep Rules + +ast-grep rules are declarative specifications for matching and filtering Abstract Syntax Tree (AST) nodes. They enable structural code search and analysis by defining conditions an AST node must meet to be matched. + +### Rule Categories + +ast-grep rules are categorized into three types: + +- **Atomic Rules**: Match individual AST nodes based on intrinsic properties like code patterns (`pattern`), node type (`kind`), or text content (`regex`). +- **Relational Rules**: Define conditions based on a target node's position or relationship to other nodes (e.g., `inside`, `has`, `precedes`, `follows`). +- **Composite Rules**: Combine other rules using logical operations (AND, OR, NOT) to form complex matching criteria (e.g., `all`, `any`, `not`, `matches`). ## Rule Object Structure -Every field within an ast-grep Rule Object is optional, but at least one **positive** key (e.g., `kind`, `pattern`) must be present. - -### Properties Table - -| Property | Type | Description | -|----------|------|-------------| -| `pattern` | string | AST pattern to match | -| `kind` | string | Node type to match | -| `regex` | string | Regular expression for text matching | -| `nthChild` | number or object | Match by position in parent | -| `range` | object | Match by source position | -| `inside` | Rule | Match if inside another node | -| `has` | Rule | Match if contains another node | -| `precedes` | Rule | Match if before another node | -| `follows` | Rule | Match if after another node | -| `all` | Rule[] | All rules must match (AND) | -| `any` | Rule[] | Any rule must match (OR) | -| `not` | Rule | Negate a rule | -| `matches` | string | Reference a utility rule by ID | -| `stopBy` | string | Control traversal depth | +The ast-grep rule object is the core configuration unit defining how ast-grep identifies and filters AST nodes. It's typically written in YAML format. + +### General Structure + +Every field within an ast-grep Rule Object is optional, but at least one "positive" key (e.g., `kind`, `pattern`) must be present. + +A node matches a rule if it satisfies all fields defined within that rule object, implying an implicit logical AND operation. + +For rules using metavariables that depend on prior matching, explicit `all` composite rules are recommended to guarantee execution order. + +### Rule Object Properties + +| Property | Type | Category | Purpose | +|----------|------|----------|---------| +| `pattern` | String or Object | Atomic | Matches AST node by code pattern | +| `kind` | String | Atomic | Matches AST node by its kind name | +| `regex` | String | Atomic | Matches node's text by Rust regex | +| `nthChild` | number, string, Object | Atomic | Matches nodes by index within parent | +| `range` | RangeObject | Atomic | Matches node by character positions | +| `inside` | Object | Relational | Target node must be inside matching node | +| `has` | Object | Relational | Target node must have matching descendant | +| `precedes` | Object | Relational | Target node must appear before matching node | +| `follows` | Object | Relational | Target node must appear after matching node | +| `all` | Array | Composite | Matches if all sub-rules match | +| `any` | Array | Composite | Matches if any sub-rules match | +| `not` | Object | Composite | Matches if sub-rule does not match | +| `matches` | String | Composite | Matches if predefined utility rule matches | ## Atomic Rules -### pattern +Atomic rules match individual AST nodes based on their intrinsic properties. + +### pattern: String and Object Forms -Matches nodes using AST pattern syntax with meta-variables. +The `pattern` rule matches a single AST node based on a code pattern. + +**String Pattern**: Directly matches using ast-grep's pattern syntax with metavariables. ```yaml -rule: - pattern: console.log($MSG) +pattern: console.log($ARG) ``` -**Meta-variable types:** -- `$VAR` - Single named node -- `$$VAR` - Single unnamed node (operators) -- `$$$VAR` - Multiple nodes (variadic) -- `$_` - Anonymous wildcard +**Object Pattern**: Offers granular control for ambiguous patterns or specific contexts. -**Important:** Meta-variables must be the only text within an AST node to function correctly. +```yaml +# Using selector to pinpoint specific part +pattern: + selector: field_definition + context: class { $F } +``` -### kind +```yaml +# Using strictness to modify matching algorithm +pattern: + context: foo($BAR) + strictness: relaxed +``` + +**Strictness options**: `cst`, `smart`, `ast`, `relaxed`, `signature` + +### kind: Matching by Node Type -Matches nodes by their AST node type. +The `kind` rule matches an AST node by its tree-sitter node kind name. ```yaml -rule: - kind: function_declaration +kind: call_expression ``` Common node types by language: @@ -63,9 +94,9 @@ Common node types by language: | Go | `function_declaration` | `type_declaration` | `short_var_declaration` | | Rust | `function_item` | `struct_item` | `let_declaration` | -### regex +### regex: Text-Based Node Matching -Matches the text content of a node against a regular expression. +The `regex` rule matches the entire text content of an AST node using a Rust regular expression. ```yaml rule: @@ -73,132 +104,144 @@ rule: regex: ^test_ ``` -### nthChild +**Note**: It's not a "positive" rule, meaning it matches any node whose text satisfies the regex. -Matches nodes by their position within the parent. +### nthChild: Positional Node Matching + +The `nthChild` rule finds nodes by their 1-based index within their parent's children list. + +**Number form**: Match exact position ```yaml -# Match first child -rule: - nthChild: 1 +nthChild: 1 ``` +**String form**: Match using An+B formula + ```yaml -# Match with options -rule: - nthChild: - position: 2 - ofRule: - kind: argument +nthChild: 2n+1 +``` + +**Object form**: Granular control + +```yaml +nthChild: + position: 2 + reverse: true + ofRule: + kind: argument ``` -### range +### range: Position-Based Node Matching -Matches nodes by source position. +The `range` rule matches an AST node based on its character-based start and end positions. ```yaml rule: range: - start: {line: 10, column: 0} - end: {line: 20, column: 0} + start: {line: 0, column: 0} + end: {line: 10, column: 0} ``` ## Relational Rules -### inside +Relational rules filter targets based on their position relative to other AST nodes. -Matches if the node is inside another node matching the rule. +### inside: Matching Within a Parent Node + +Requires the target node to be inside another node matching the `inside` sub-rule. ```yaml rule: pattern: console.log($MSG) inside: kind: class_body - stopBy: end # Always include this + stopBy: end ``` -### has +### has: Matching with a Descendant Node -Matches if the node contains another node matching the rule. +Requires the target node to have a descendant node matching the `has` sub-rule. ```yaml rule: kind: function_declaration has: pattern: await $EXPR - stopBy: end # Always include this + stopBy: end ``` -### precedes +### precedes and follows: Sequential Node Matching -Matches if the node comes before another node matching the rule. +- `precedes`: Target node must appear before a matching node +- `follows`: Target node must appear after a matching node ```yaml rule: pattern: $X = null precedes: pattern: if ($X == null) - stopBy: neighbor # Only check immediate siblings -``` - -### follows - -Matches if the node comes after another node matching the rule. - -```yaml -rule: - kind: return_statement - follows: - kind: if_statement stopBy: neighbor ``` -### stopBy Options +### stopBy and field Options -Controls how far relational rules traverse: +**stopBy**: Controls search termination for relational rules. | Value | Description | |-------|-------------| -| `end` | Search entire subtree (recommended default) | -| `neighbor` | Only check immediate siblings | -| `{ kind: "node_type" }` | Stop at specific node type | -| `{ pattern: "..." }` | Stop at pattern match | +| `neighbor` | Stops when immediate surrounding node doesn't match (default) | +| `end` | Searches to the end of the direction | +| Rule object | Stops when surrounding node matches provided rule | -**Critical:** Without `stopBy: end`, relational rules only search immediate children. +**field**: Specifies a sub-node within the target that should match. Only for `inside` and `has`. + +```yaml +rule: + kind: binary_expression + has: + field: operator + pattern: $$OP +``` + +**Best Practice**: When unsure, always use `stopBy: end` to ensure the search goes to the end of the direction. ## Composite Rules -### all +Composite rules combine atomic and relational rules using logical operations. + +### all: Conjunction (AND) of Rules -All sub-rules must match (logical AND). +Matches a node only if all sub-rules in the list match. Guarantees order of rule matching. ```yaml rule: all: - kind: function_declaration - has: - pattern: async + pattern: await $EXPR + stopBy: end - not: has: kind: try_statement stopBy: end ``` -### any +### any: Disjunction (OR) of Rules -Any sub-rule must match (logical OR). +Matches a node if any sub-rules in the list match. ```yaml rule: any: - - pattern: console.log($X) - - pattern: console.warn($X) - - pattern: console.error($X) + - pattern: console.log($$$) + - pattern: console.warn($$$) + - pattern: console.error($$$) ``` -### not +### not: Negation (NOT) of a Rule -Negates a rule. +Matches a node if the single sub-rule does not match. ```yaml rule: @@ -209,9 +252,9 @@ rule: stopBy: end ``` -### matches +### matches: Rule Reuse and Utility Rules -References a utility rule defined in the same file. +Takes a rule-id string, matching if the referenced utility rule matches. ```yaml utils: @@ -231,138 +274,100 @@ rules: matches: has-error-handling ``` -## Fix and Rewrite +## Metavariables + +Metavariables are placeholders in patterns to match dynamic content in the AST. + +### $VAR: Single Named Node Capture + +Captures a single named node in the AST. + +- **Valid**: `$META`, `$META_VAR`, `$_` +- **Invalid**: `$invalid`, `$123`, `$KEBAB-CASE` +- **Reuse**: `$A == $A` matches `a == a` but not `a == b` + +### $$VAR: Single Unnamed Node Capture -### Basic Rewrite +Captures a single unnamed node (e.g., operators, punctuation). ```yaml -id: replace-console-log -language: javascript rule: - pattern: console.log($MSG) -fix: logger.info($MSG) + kind: binary_expression + has: + field: operator + pattern: $$OP ``` -### Conditional Fix +### $$$MULTI: Multi-Node Capture + +Matches zero or more AST nodes (non-greedy). + +- `console.log($$$)` matches any number of arguments +- `function $FUNC($$$ARGS) { $$$ }` matches varying parameters/statements + +### Non-Capturing Metavariables (_VAR) + +Metavariables starting with underscore (`_`) are not captured. They can match different content even if named identically. + +- `$_FUNC($_FUNC)` matches `test(a)` and `testFunc(1 + 1)` + +### Important Considerations + +- **Syntax Matching**: Only exact metavariable syntax is recognized +- **Exclusive Content**: Metavariable text must be the only text within an AST node +- **Non-working examples**: `obj.on$EVENT`, `"Hello $WORLD"`, `$jq` + +## Common Patterns + +### Find Functions with Specific Content ```yaml -id: add-type-annotation -language: typescript rule: - pattern: const $NAME = $VALUE - not: - pattern: 'const $NAME: $TYPE = $VALUE' -fix: 'const $NAME: unknown = $VALUE' + kind: function_declaration + has: + pattern: await $EXPR + stopBy: end ``` -### Multi-line Fix +### Find Code Inside Specific Contexts ```yaml -fix: | - try { - $BODY - } catch (error) { - console.error(error) - } +rule: + pattern: console.log($$$) + inside: + kind: method_definition + stopBy: end ``` -## Complete Examples - -### Find Async Functions Without Error Handling +### Combining Multiple Conditions ```yaml -id: async-no-try-catch -language: typescript -severity: warning rule: all: - kind: function_declaration - has: - pattern: async + pattern: await $EXPR + stopBy: end - not: has: kind: try_statement stopBy: end -message: Async function without try-catch error handling -``` - -### Find Unused Variables - -```yaml -id: unused-variable -language: javascript -rule: - kind: variable_declarator - pattern: $NAME = $VALUE - not: - precedes: - pattern: $NAME - stopBy: end -message: Variable '$NAME' is declared but never used ``` -### Find React Hooks Outside Components +### Matching Multiple Alternatives ```yaml -id: hooks-outside-component -language: tsx rule: any: - - pattern: useState($$$) - - pattern: useEffect($$$) - - pattern: useMemo($$$) - not: - inside: - any: - - kind: function_declaration - - kind: arrow_function - stopBy: end -message: React hooks must be called inside function components + - pattern: console.log($$$) + - pattern: console.warn($$$) + - pattern: console.error($$$) ``` -### Find SQL Injection Risks - -```yaml -id: sql-injection-risk -language: javascript -rule: - pattern: $DB.query(`$$$${$VAR}$$$`) -message: Potential SQL injection - use parameterized queries -fix: $DB.query($SQL, [$VAR]) -``` - -## Troubleshooting - -### Pattern Not Matching - -1. **Check AST structure:** - ```bash - ast-grep run --pattern '$ROOT' --debug-query=cst --lang javascript file.js - ``` - -2. **Verify node type:** - ```bash - ast-grep run --pattern '$ROOT' --debug-query=ast --lang javascript file.js - ``` - -3. **Test pattern parsing:** - ```bash - ast-grep run --pattern 'your_pattern' --debug-query=pattern --lang javascript file.js - ``` - -### Common Issues - -| Issue | Cause | Solution | -|-------|-------|----------| -| No matches | Incomplete pattern | Ensure pattern is complete AST node | -| No matches | Wrong node type | Use `--debug-query=cst` to check actual types | -| Partial matches | Missing `stopBy: end` | Add `stopBy: end` to relational rules | -| Too many matches | Pattern too broad | Add constraints with `kind` or `inside` | - -### Debugging Checklist +## Troubleshooting Tips -- [ ] Pattern is valid code that parses correctly -- [ ] Meta-variables use UPPERCASE names -- [ ] Relational rules have `stopBy: end` -- [ ] Node types match the target language -- [ ] Pattern captures complete AST nodes +1. **Rule doesn't match**: Use `--debug-query=cst` to see actual AST structure +2. **Relational rule issues**: Ensure `stopBy: end` is set for deep searches +3. **Wrong node kind**: Check the language's tree-sitter grammar for correct kind names +4. **Metavariable not working**: Ensure it's the only content in its AST node +5. **Pattern too complex**: Break it down into simpler sub-rules using `all` From 256205002b647da61f2f36597a61ab536c11c7a5 Mon Sep 17 00:00:00 2001 From: Minsu Lee Date: Fri, 19 Dec 2025 15:01:44 +0900 Subject: [PATCH 7/9] chore(ast-grep): add version metadata to skill frontmatter --- skills/ast-grep/SKILL.md | 2 ++ 1 file changed, 2 insertions(+) diff --git a/skills/ast-grep/SKILL.md b/skills/ast-grep/SKILL.md index 00fc85e..f3ca37b 100644 --- a/skills/ast-grep/SKILL.md +++ b/skills/ast-grep/SKILL.md @@ -1,6 +1,8 @@ --- name: ast-grep description: Guide for writing ast-grep rules to perform structural code search and analysis. Use when users need to search codebases using Abstract Syntax Tree (AST) patterns, find specific code structures, or perform complex code queries that go beyond simple text search. This skill should be used when users ask to search for code patterns, find specific language constructs, or locate code with particular structural characteristics. +version: 1.0.0 +lastUpdated: 2025-12-19 --- # ast-grep Code Search From 775eeddc39a1b1ec2cb4e5c23e6414a646af36cc Mon Sep 17 00:00:00 2001 From: Minsu Lee Date: Fri, 19 Dec 2025 15:09:11 +0900 Subject: [PATCH 8/9] chore(ast-grep): align skill frontmatter with agentskills.io spec Move version and lastUpdated fields into metadata block per the official Agent Skills specification at agentskills.io. --- .claude/rules/agentskills.md | 0 skills/ast-grep/SKILL.md | 5 +++-- 2 files changed, 3 insertions(+), 2 deletions(-) create mode 100644 .claude/rules/agentskills.md diff --git a/.claude/rules/agentskills.md b/.claude/rules/agentskills.md new file mode 100644 index 0000000..e69de29 diff --git a/skills/ast-grep/SKILL.md b/skills/ast-grep/SKILL.md index f3ca37b..e322f1f 100644 --- a/skills/ast-grep/SKILL.md +++ b/skills/ast-grep/SKILL.md @@ -1,8 +1,9 @@ --- name: ast-grep description: Guide for writing ast-grep rules to perform structural code search and analysis. Use when users need to search codebases using Abstract Syntax Tree (AST) patterns, find specific code structures, or perform complex code queries that go beyond simple text search. This skill should be used when users ask to search for code patterns, find specific language constructs, or locate code with particular structural characteristics. -version: 1.0.0 -lastUpdated: 2025-12-19 +metadata: + version: "1.0.0" + lastUpdated: "2025-12-19" --- # ast-grep Code Search From bbfd023f38ebc02d1f1090a343f40a833fedd4fd Mon Sep 17 00:00:00 2001 From: Minsu Lee Date: Fri, 19 Dec 2025 15:21:18 +0900 Subject: [PATCH 9/9] style: format --- .claude/rules/agentskills.md | 8 ++++++++ .github/workflows/ci.yml | 6 +++--- .github/workflows/release-please.yml | 4 ++-- CLAUDE.md | 10 +++++----- eslint.config.js | 4 ++++ package.json | 6 +++--- packages/lsp/CLAUDE.md | 4 ++-- 7 files changed, 27 insertions(+), 15 deletions(-) diff --git a/.claude/rules/agentskills.md b/.claude/rules/agentskills.md index e69de29..3213d24 100644 --- a/.claude/rules/agentskills.md +++ b/.claude/rules/agentskills.md @@ -0,0 +1,8 @@ +# Agent Skills + +## Docs + +- [Overview](https://agentskills.io/home.md): A simple, open format for giving agents new capabilities and expertise. +- [Integrate skills into your agent](https://agentskills.io/integrate-skills.md): How to add Agent Skills support to your agent or tool. +- [Specification](https://agentskills.io/specification.md): The complete format specification for Agent Skills. +- [What are skills?](https://agentskills.io/what-are-skills.md): Agent Skills are a lightweight, open format for extending AI agent capabilities with specialized knowledge and workflows. \ No newline at end of file diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 8b031de..62c8544 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -16,7 +16,7 @@ env: jobs: lint: - runs-on: ["self-hosted", "linux", "x64", "ubuntu-2404", "aws"] + runs-on: [self-hosted, linux, x64, ubuntu-2404, aws] steps: - uses: actions/checkout@v4 with: @@ -44,7 +44,7 @@ jobs: run: bun run lint typecheck: - runs-on: ["self-hosted", "linux", "x64", "ubuntu-2404", "aws"] + runs-on: [self-hosted, linux, x64, ubuntu-2404, aws] steps: - uses: actions/checkout@v4 @@ -65,7 +65,7 @@ jobs: run: bun run typecheck test: - runs-on: ["self-hosted", "linux", "x64", "ubuntu-2404", "aws"] + runs-on: [self-hosted, linux, x64, ubuntu-2404, aws] steps: - uses: actions/checkout@v4 diff --git a/.github/workflows/release-please.yml b/.github/workflows/release-please.yml index 29c1075..09ba616 100644 --- a/.github/workflows/release-please.yml +++ b/.github/workflows/release-please.yml @@ -19,7 +19,7 @@ env: jobs: release-please: - runs-on: ["self-hosted", "linux", "x64", "ubuntu-2404", "aws"] + runs-on: [self-hosted, linux, x64, ubuntu-2404, aws] outputs: releases_created: ${{ steps.release.outputs.releases_created }} release_created: ${{ steps.release.outputs.release_created }} @@ -41,7 +41,7 @@ jobs: publish: needs: release-please if: ${{ needs.release-please.outputs.release_created == 'true' }} - runs-on: ["self-hosted", "linux", "x64", "ubuntu-2404", "aws"] + runs-on: [self-hosted, linux, x64, ubuntu-2404, aws] permissions: contents: read id-token: write diff --git a/CLAUDE.md b/CLAUDE.md index f367ed8..1646a18 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -150,19 +150,19 @@ ignore_patterns: # Formatter settings formatter: biome: - command: ["biome", "format", "--write", "$FILE"] - extensions: [".ts", ".tsx", ".js", ".jsx"] + command: [biome, format, --write, $FILE] + extensions: [.ts, .tsx, .js, .jsx] prettier: - disabled: true # Disable a built-in formatter + disabled: true # Disable a built-in formatter # LSP settings lsp: typescript: enabled: true vue: - enabled: false # Disable a specific LSP server + enabled: false # Disable a specific LSP server pyright: - root: "./backend" # Custom root path + root: ./backend # Custom root path ``` **Formatter Config:** diff --git a/eslint.config.js b/eslint.config.js index d7cdab3..5577483 100644 --- a/eslint.config.js +++ b/eslint.config.js @@ -12,4 +12,8 @@ export default antfu({ 'specs/**', 'docs/**', ], +}, { + rules: { + 'no-console': 'off', + }, }) diff --git a/package.json b/package.json index e64e0f6..5e380a4 100644 --- a/package.json +++ b/package.json @@ -22,9 +22,6 @@ "publish:npm": "bun run build:npm && for dir in npm/code-*; do (cd \"$dir\" && bun publish --access public --tolerate-republish); done && cd npm/code && bun publish --access public --tolerate-republish", "prepare": "husky" }, - "lint-staged": { - "*.{ts,tsx,js,jsx,mjs,cjs}": "eslint --fix" - }, "dependencies": { "@modelcontextprotocol/sdk": "^1.12.0", "vscode-jsonrpc": "^8.2.1", @@ -40,5 +37,8 @@ "lint-staged": "^16.2.7", "turbo": "^2.6.3", "typescript": "^5.7.0" + }, + "lint-staged": { + "*.{ts,tsx,js,jsx,mjs,cjs}": "eslint --fix" } } diff --git a/packages/lsp/CLAUDE.md b/packages/lsp/CLAUDE.md index 0497894..8558b3c 100644 --- a/packages/lsp/CLAUDE.md +++ b/packages/lsp/CLAUDE.md @@ -159,7 +159,7 @@ lsp: # Use custom root path pyright: - root: "./backend" + root: ./backend # Globally disable all LSP servers # lsp: false @@ -172,7 +172,7 @@ lsp: **Config Utilities:** ```typescript -import { isServerEnabled, getServerRoot, loadLspConfig } from '@pleaseai/code-lsp' +import { getServerRoot, isServerEnabled, loadLspConfig } from '@pleaseai/code-lsp' const config = await loadLspConfig(projectDir) const enabled = isServerEnabled(config, 'typescript')