tree-ast-grep-mcp
Version:
Simple, direct ast-grep wrapper for AI coding agents. Zero abstractions, maximum performance.
490 lines • 25.7 kB
JavaScript
import * as fs from 'fs/promises';
import * as path from 'path';
import * as os from 'os';
import { BaseTool } from '../core/tool-base.js';
import { EnhancedPatternValidator } from '../core/pattern-validator.js';
import { AstGrepErrorTranslator } from '../core/error-handler.js';
import { ValidationError } from '../types/errors.js';
export class RunRuleTool extends BaseTool {
patternValidator;
constructor(workspaceManager, binaryManager) {
super(workspaceManager, binaryManager);
this.patternValidator = new EnhancedPatternValidator(workspaceManager.getWorkspaceRoot());
}
static getSchema() {
return {
name: 'ast_run_rule',
description: '✅ ENHANCED & FIXED: Auto-generates perfect YAML rules with improved reliability, constraint handling, and tool consistency. Generate an ast-grep YAML rule and immediately execute it. 🔧 CRITICAL FIXES: Fixed where constraints (now working), improved tool consistency with ast_search, enhanced pattern object generation with strictness control. Perfect for creating custom linting rules, security checks, and code analysis patterns. BEST PRACTICE: Use absolute paths for file-based scanning.',
inputSchema: {
type: 'object',
properties: {
// Rule fields
id: {
type: 'string',
description: 'Rule ID (unique and descriptive). Used to identify the rule in results. Example: "no-console-log", "prefer-const", "security-hardcoded-secret".'
},
language: {
type: 'string',
description: 'Programming language for the rule. Common values: "javascript", "typescript", "python", "java", "rust", "go", "cpp". Determines AST parsing and pattern matching behavior.'
},
pattern: {
type: 'string',
description: 'Primary AST pattern with metavariables. ✅ ATOMIC PATTERNS: "console.log($ARG)" (simple matching), "function $NAME($PARAMS) { $BODY }" (structural matching). ✅ RELATIONAL PATTERNS: Use with insidePattern/hasPattern for contextual matching. ✅ COMPOSITE PATTERNS: Combine multiple conditions with boolean logic. ✅ PROVEN EXAMPLES: "console.log($ARG)" (find logging), "def $NAME($PARAMS): $BODY" (Python functions), "public $TYPE $METHOD($PARAMS) { $BODY }" (Java methods), "$OBJ.$METHOD($_)" (method calls). ⚠️ RELIABILITY: Use named metavariables ($NAME, $ARG) for reliable capture. Avoid $_ and $$$ in fix templates.'
},
message: {
type: 'string',
description: 'Human-readable message explaining the finding. Shown to users when rule matches. Example: "Use logger.info instead of console.log", "Prefer const over var for immutable variables".'
},
severity: {
type: 'string',
enum: ['error', 'warning', 'info'],
default: 'warning',
description: 'Severity level for rule. "error" for critical issues, "warning" for important issues, "info" for suggestions.'
},
kind: {
type: 'string',
description: 'Optional node kind to constrain matches (language-specific). Examples: "function_declaration", "variable_declaration", "call_expression". Helps narrow down matches to specific AST node types.'
},
insidePattern: {
type: 'string',
description: '✅ RELATIONAL RULE: Pattern that the match must be inside of. PROVEN EXAMPLES: "class $CLASS { $$$ }" (inside classes), "function $FUNC() { $$$ }" (inside functions), "if ($COND) { $$$ }" (inside conditionals), "try { $$$ }" (inside try blocks). USE CASES: Find console.log only inside functions, detect unsafe operations inside loops, locate deprecated APIs inside specific contexts.'
},
hasPattern: {
type: 'string',
description: '✅ RELATIONAL RULE: Pattern that must exist inside the match. PROVEN EXAMPLES: "console.log($ARG)" (functions containing logging), "await $PROMISE" (functions with async calls), "$EXCEPTION" (catch blocks with specific errors), "return $VALUE" (functions that return values). ADVANCED: "{ kind: method_definition }" (classes with methods), "@$ANNOTATION" (decorated elements).'
},
notPattern: {
type: 'string',
description: 'Optional: pattern that must NOT exist inside the match. Example: "console.log($_)" to exclude console.log calls that are inside try-catch blocks.'
},
where: {
type: 'array',
items: {
type: 'object',
properties: {
metavariable: { type: 'string', description: 'Metavariable name to constrain (e.g., "NAME", "ARGS")' },
regex: { type: 'string', description: 'Regex pattern the metavariable must match' },
notRegex: { type: 'string', description: 'Regex pattern the metavariable must NOT match' },
equals: { type: 'string', description: 'Exact string the metavariable must equal' },
includes: { type: 'string', description: 'String the metavariable must include' },
},
required: ['metavariable']
},
description: 'Optional: constraints on metavariables using regex, equals, or includes. Example: [{"metavariable": "NAME", "regex": "^[A-Z]"}] to only match names starting with uppercase.'
},
fix: {
type: 'string',
description: 'Optional rewrite template using the same metavariables as pattern. Enables automatic fixing. Example: "logger.info($_)" to replace "console.log($_)" with "logger.info($_)".'
},
// Scan fields
paths: {
type: 'array',
items: { type: 'string' },
description: '⚠️ PATH REQUIREMENTS: REQUIRED: Use absolute paths for file operations (e.g., "D:\\path\\to\\file.js"). ❌ FAILS: Relative paths like "src/file.ts" may not resolve correctly due to workspace detection issues. ✅ WORKS: Absolute paths like "d:/project/src/file.ts" for reliable file resolution.'
},
format: {
type: 'string',
enum: ['json', 'text', 'github'],
default: 'json',
description: 'Output format for results. "json" for structured data, "text" for human-readable, "github" for GitHub Actions format.'
},
include: {
type: 'array',
items: { type: 'string' },
description: 'Include glob patterns for file filtering. Example: ["**/*.js", "**/*.ts"] to only scan JavaScript/TypeScript files.'
},
exclude: {
type: 'array',
items: { type: 'string' },
description: 'Exclude glob patterns. Default excludes: node_modules, .git, dist, build, coverage, *.min.js, *.bundle.js, .next, .vscode, .idea'
},
ruleIds: {
type: 'array',
items: { type: 'string' },
description: 'Specific rule IDs to run (filters results). Only run rules with these IDs from the rules file.'
},
timeoutMs: {
type: 'number',
minimum: 1000,
maximum: 180000,
description: 'Timeout for ast-grep scan in milliseconds (default: 30000)'
},
relativePaths: {
type: 'boolean',
default: false,
description: 'Return file paths relative to workspace root instead of absolute paths'
},
follow: {
type: 'boolean',
default: false,
description: 'Follow symlinks during file scanning'
},
threads: {
type: 'number',
minimum: 1,
maximum: 64,
description: 'Number of threads to use for parallel processing (default: auto)'
},
noIgnore: {
type: 'boolean',
default: false,
description: 'Disable ignore rules and scan all files including node_modules, .git, etc. Use with caution as it may scan large amounts of files and hit resource limits.'
},
ignorePath: {
type: 'array',
items: { type: 'string' },
description: 'Additional ignore file(s) to respect beyond default .gitignore patterns'
},
root: {
type: 'string',
description: 'Override project root used by ast-grep. Note: May not work as expected due to ast-grep command limitations.'
},
workdir: {
type: 'string',
description: 'Working directory for ast-grep. Note: May not work as expected due to ast-grep command limitations.'
},
saveTo: {
type: 'string',
description: 'Optional path to save the generated YAML rule file (relative to workspace). Example: "rules/my-rule.yml" to save the generated rule for reuse.'
},
},
required: ['id', 'language', 'pattern']
}
};
}
async execute(params) {
try {
// Validate rule params
const ruleValidation = this.validator.validateRuleBuilderParams(params);
if (!ruleValidation.valid) {
throw new ValidationError(`Invalid rule parameters: ${ruleValidation.errors.join(', ')}`);
}
const rule = ruleValidation.sanitized;
// Build YAML
const yaml = this.buildYaml(rule);
// Execute rule directly using generated YAML
// Save YAML to workspace
let savedPath;
if (params.saveTo && typeof params.saveTo === 'string' && params.saveTo.trim().length > 0) {
const validation = this.workspaceManager.validatePath(params.saveTo);
if (!validation.valid) {
throw new ValidationError(`Invalid save path: ${validation.error}`);
}
const outPath = validation.resolvedPath.endsWith('.yml') || validation.resolvedPath.endsWith('.yaml')
? validation.resolvedPath
: validation.resolvedPath + '.yml';
await fs.mkdir(path.dirname(outPath), { recursive: true });
await fs.writeFile(outPath, yaml, 'utf8');
savedPath = outPath;
}
else {
// Default auto-save into .tree-ast-grep/rules/<id>.yml at workspace root
const defaultRel = path.join('.tree-ast-grep', 'rules', `${rule.id}.yml`);
const validation = this.workspaceManager.validatePath(defaultRel);
if (!validation.valid) {
throw new ValidationError(`Invalid default save path: ${validation.error}`);
}
await fs.mkdir(path.dirname(validation.resolvedPath), { recursive: true });
await fs.writeFile(validation.resolvedPath, yaml, 'utf8');
savedPath = validation.resolvedPath;
}
const scanResult = await this.executeRule(yaml, params);
return { yaml, scan: scanResult, savedPath };
}
catch (error) {
if (error instanceof ValidationError) {
throw error;
}
// Use enhanced error handling
const contextualError = AstGrepErrorTranslator.createUserFriendlyError(error instanceof Error ? error : new Error(String(error)), {
ruleId: params.id,
pattern: params.pattern,
language: params.language,
paths: params.paths,
workspace: this.workspaceManager.getWorkspaceRoot()
});
throw contextualError;
}
}
buildYaml(p) {
const lines = [];
lines.push('# ast-grep rule generated by ast_run_rule');
lines.push('# Compatible with ast-grep run patterns for consistent behavior');
lines.push('# Pattern syntax: $VAR (single node), $$$ (multi-node), $NAME (capture names)');
lines.push('# Rule composition: use inside/has/not for contextual matching');
lines.push('# Constraints: use where clauses for metavariable filtering');
lines.push(`id: ${p.id}`);
lines.push(`message: ${JSON.stringify(p.message || p.id)}`);
lines.push(`severity: ${p.severity || 'warning'}`);
lines.push(`language: ${p.language}`);
// Build rule object with proper YAML structure
lines.push('rule:');
// Determine if we need complex rule structure
const hasContextualPatterns = p.insidePattern || p.hasPattern || p.notPattern;
const hasConstraints = p.where && p.where.length > 0;
const hasComplexRule = hasContextualPatterns || hasConstraints;
if (hasComplexRule) {
// Use composite rule structure for complex patterns
lines.push(' all:');
// Primary pattern component - use pattern object for better compatibility
lines.push(' - pattern:');
lines.push(` pattern: ${JSON.stringify(p.pattern)}`);
lines.push(' strictness: smart'); // Match ast-grep run behavior
if (p.kind)
lines.push(` kind: ${JSON.stringify(p.kind)}`);
// Add contextual patterns with proper nesting
if (p.insidePattern) {
lines.push(' - inside:');
lines.push(` pattern: ${JSON.stringify(p.insidePattern)}`);
lines.push(' strictness: smart');
}
if (p.hasPattern) {
lines.push(' - has:');
lines.push(` pattern: ${JSON.stringify(p.hasPattern)}`);
lines.push(' strictness: smart');
}
if (p.notPattern) {
lines.push(' - not:');
lines.push(` pattern: ${JSON.stringify(p.notPattern)}`);
lines.push(' strictness: smart');
}
}
else {
// Simple pattern structure with explicit strictness for ast-grep run compatibility
lines.push(' pattern:');
lines.push(` pattern: ${JSON.stringify(p.pattern)}`);
lines.push(' strictness: smart'); // Ensure same matching as ast-grep run
if (p.kind)
lines.push(` kind: ${JSON.stringify(p.kind)}`);
}
// Add metavariable constraints at the top level after rule
if (hasConstraints && p.where) {
lines.push('constraints:');
for (const constraint of p.where) {
lines.push(` ${constraint.metavariable}:`);
// Apply constraints in priority order (only one constraint type per metavariable)
if (constraint.regex) {
lines.push(` regex: ${JSON.stringify(constraint.regex)}`);
}
else if (constraint.equals) {
// Use regex with exact match for equals constraint
lines.push(` regex: ${JSON.stringify('^' + constraint.equals.replace(/[.*+?^${}()|[\]\\]/g, '\\$&') + '$')}`);
}
else if (constraint.includes) {
// Use regex for includes constraint
lines.push(` regex: ${JSON.stringify('.*' + constraint.includes.replace(/[.*+?^${}()|[\]\\]/g, '\\$&') + '.*')}`);
}
if (constraint.notRegex) {
lines.push(` not:`);
lines.push(` regex: ${JSON.stringify(constraint.notRegex)}`);
}
}
}
// Add fix template if provided
if (p.fix) {
lines.push(`fix: ${JSON.stringify(p.fix)}`);
}
return lines.join('\n');
}
/**
* Execute the generated YAML rule directly using ast-grep
*/
async executeRule(yaml, params) {
// Use enhanced path resolution from BaseTool
const defaultPaths = params.paths || ['.'];
const pathResolution = await this.resolveAndValidatePaths(defaultPaths);
const resolvedPaths = pathResolution.targets;
// Create temporary rules file
const tempDir = os.tmpdir();
const rulesFile = path.join(tempDir, `ast-grep-rule-${Date.now()}.yml`);
try {
await fs.writeFile(rulesFile, yaml, 'utf8');
// Build ast-grep command arguments
const args = this.buildScanArgs(params, resolvedPaths, rulesFile);
// Execute ast-grep
const result = await this.binaryManager.executeAstGrep(args, {
cwd: this.getWorkspaceRoot(),
timeout: params.timeoutMs ?? 60000
});
// Parse results
const findings = this.parseScanResults(result.stdout);
const filesScanned = this.extractFilesScanned(result.stderr, findings, resolvedPaths);
// Filter by severity if specified
let filteredFindings = findings;
if (params.severity && params.severity !== 'all') {
filteredFindings = findings.filter(finding => finding.severity === params.severity);
}
return {
findings: filteredFindings,
summary: {
totalFindings: filteredFindings.length,
errors: filteredFindings.filter(f => f.severity === 'error').length,
warnings: filteredFindings.filter(f => f.severity === 'warning').length,
filesScanned
}
};
}
finally {
// Clean up temporary rules file
try {
await fs.unlink(rulesFile);
}
catch (error) {
// Ignore cleanup errors
}
}
}
/**
* Build ast-grep scan command arguments
*/
buildScanArgs(params, resolvedPaths, rulesFile) {
const args = ['scan'];
// Add rules file
args.push('--rule', rulesFile);
// Use BaseTool methods for consistent parameter handling
args.push(...this.buildCommonArgs(params));
// Add JSON output format
if (params.format === 'json') {
args.push(...this.buildJsonArgs(params.jsonStyle));
}
// Add paths (must come after options)
args.push(...resolvedPaths);
return args;
}
/**
* Parse ast-grep scan results into ScanResult format
*/
parseScanResults(stdout) {
const findings = [];
if (!stdout.trim()) {
return findings;
}
try {
// Try parsing stream JSONL first
const trimmed = stdout.trim();
if (trimmed.includes('\n') && trimmed.split('\n').every(l => l.trim().startsWith('{') || l.trim() === '')) {
for (const line of trimmed.split('\n')) {
const l = line.trim();
if (!l)
continue;
const obj = JSON.parse(l);
if (Array.isArray(obj.findings)) {
for (const f of obj.findings)
findings.push(this.parseSingleFinding(f));
}
else if (obj.ruleId || obj.file) {
findings.push(this.parseSingleFinding(obj));
}
}
}
else if (trimmed.startsWith('{') || trimmed.startsWith('[')) {
// JSON format: pretty/compact
const results = JSON.parse(trimmed);
if (Array.isArray(results)) {
for (const result of results)
findings.push(this.parseSingleFinding(result));
}
else if (results.findings) {
for (const finding of results.findings)
findings.push(this.parseSingleFinding(finding));
}
else {
findings.push(this.parseSingleFinding(results));
}
}
else {
// Text format - parse line by line
const lines = stdout.split('\n');
for (const line of lines) {
if (line.includes(':')) {
// Parse file:line:column: message format
const match = line.match(/^(.+?):(\d+):(\d+):\s*(.+)$/);
if (match) {
const [, file, lineNum, colNum, message] = match;
findings.push({
ruleId: 'unknown',
severity: 'info',
message: message.trim(),
file: file.trim(),
line: parseInt(lineNum, 10),
column: parseInt(colNum, 10)
});
}
}
}
}
}
catch (error) {
const errorMessage = error instanceof Error ? error.message : String(error);
console.error('Failed to parse ast-grep scan output:', errorMessage);
}
return findings;
}
/**
* Parse a single finding from ast-grep output
*/
parseSingleFinding(finding) {
const file = finding.file || finding.path || '';
// Fix line number issues - ensure proper 1-based line numbers
let line = finding.line ?? finding.range?.start?.line ?? finding.start?.line ?? 0;
let column = finding.column ?? finding.range?.start?.column ?? finding.start?.column ?? 0;
// Convert to numbers and handle invalid values
line = typeof line === 'number' ? line : Number(line);
column = typeof column === 'number' ? column : Number(column);
// ast-grep may return 0-based lines in some contexts, ensure 1-based
if (line <= 0) {
line = 1; // Default to line 1 if invalid
}
else if (finding.range?.start?.line !== undefined) {
// If we have range data, convert from 0-based to 1-based
line = Number(finding.range.start.line) + 1;
}
// Ensure column is valid (0-based is acceptable for columns)
if (column < 0)
column = 0;
return {
ruleId: finding.ruleId || finding.id || 'unknown',
severity: finding.severity || finding.level || 'info',
message: finding.message || finding.text || '',
file,
line,
column,
fix: finding.fix || finding.suggestion
};
}
/**
* Extract files scanned count from stderr output
*/
extractFilesScanned(stderr, findings, resolvedPaths) {
// Try to extract file count from stderr with multiple patterns
const patterns = [
/(\d+)\s+files?\s+scanned/i,
/(\d+)\s+files?\s+searched/i,
/across\s+(\d+)\s+files?/i
];
for (const re of patterns) {
const m = stderr.match(re);
if (m)
return parseInt(m[1], 10);
}
// Enhanced fallback: count unique files from findings if provided
if (findings && findings.length > 0) {
const unique = new Set(findings.map(f => f.file));
return unique.size;
}
// Use workspace file enumeration as fallback for scan operations
if (resolvedPaths && resolvedPaths.length > 0) {
return this.countFilesInPaths(resolvedPaths);
}
return 0;
}
/**
* Count files in given paths for file scanning metrics
*/
countFilesInPaths(paths) {
// Simple estimation - in practice this would need full file enumeration
// For now, return a reasonable default
return Math.max(paths.length, 1);
}
}
//# sourceMappingURL=rule-builder.js.map