diff --git a/packages/mcp-provider-code-analyzer/src/actions/create-regex-custom-rule.ts b/packages/mcp-provider-code-analyzer/src/actions/create-regex-custom-rule.ts new file mode 100644 index 00000000..ff208e67 --- /dev/null +++ b/packages/mcp-provider-code-analyzer/src/actions/create-regex-custom-rule.ts @@ -0,0 +1,330 @@ +import path from "node:path"; +import fs from "node:fs/promises"; +import fssync from "node:fs"; + +// Creates Regex custom rules directly in code-analyzer.yml/yaml. +// Unlike PMD which uses separate XML files, Regex rules are defined inline in the config. + +export type CreateRegexCustomRuleInput = { + regex: string; + ruleName?: string; + description?: string; + violationMessage?: string; + tags?: string[]; + severity?: number; + fileExtensions?: string[]; + regexIgnore?: string; + includeMetadata?: boolean; + engine?: string; + workingDirectory?: string; +}; + +export type CreateRegexCustomRuleOutput = { + status: string; + ruleYaml?: string; + configPath?: string; +}; + +export interface CreateRegexCustomRuleAction { + exec(input: CreateRegexCustomRuleInput): Promise; +} + +export class CreateRegexCustomRuleActionImpl implements CreateRegexCustomRuleAction { + public async exec(input: CreateRegexCustomRuleInput): Promise { + const normalized = normalizeInput(input); + if ("error" in normalized) { + return { status: normalized.error }; + } + + const configPath = findOrCreateConfigPath(normalized.workingDirectory); + const ruleYaml = buildRegexRuleYaml(normalized); + + await upsertRegexRuleInConfig(configPath, normalized.ruleName, ruleYaml); + + return { + status: "success", + ruleYaml, + configPath + }; + } +} + +type NormalizedInput = { + regex: string; + engine: string; + ruleName: string; + description: string; + violationMessage: string; + tags: string[]; + severity: number; + fileExtensions?: string[]; + regexIgnore?: string; + includeMetadata?: boolean; + workingDirectory: string; +}; + +const DEFAULT_RULE_NAME = "CustomRegexRule"; +const DEFAULT_DESCRIPTION = "Generated regex rule"; +const DEFAULT_VIOLATION_MESSAGE = "Pattern matched"; +const DEFAULT_TAGS = ["Custom"]; +const DEFAULT_SEVERITY = 3; // Moderate + +function normalizeInput(input: CreateRegexCustomRuleInput): NormalizedInput | { error: string } { + const regex = (input.regex ?? "").trim(); + if (!regex) { + return { error: "regex is required" }; + } + + // Validate regex format - should be like "/pattern/flags" + if (!regex.startsWith("/") || regex.lastIndexOf("/") <= 0) { + return { error: "regex must be in format '/pattern/flags' (e.g., '/todo/gi')" }; + } + + const engine = (input.engine ?? "regex").toLowerCase(); + if (engine !== "regex") { + return { error: `engine '${engine}' is not supported by this action` }; + } + + const workingDirectory = input.workingDirectory?.trim(); + if (!workingDirectory) { + return { error: "workingDirectory is required" }; + } + + const ruleName = input.ruleName?.trim() || DEFAULT_RULE_NAME; + const description = input.description?.trim() || DEFAULT_DESCRIPTION; + const violationMessage = input.violationMessage?.trim() || DEFAULT_VIOLATION_MESSAGE; + const tags = input.tags && input.tags.length > 0 ? input.tags : DEFAULT_TAGS; + const severity = Number.isFinite(input.severity) ? (input.severity as number) : DEFAULT_SEVERITY; + + // Validate severity is 1-5 + if (severity < 1 || severity > 5) { + return { error: "severity must be between 1 (Critical) and 5 (Info)" }; + } + + // Validate file extensions format if provided + const fileExtensions = input.fileExtensions; + if (fileExtensions && fileExtensions.length > 0) { + for (const ext of fileExtensions) { + if (!ext.startsWith(".")) { + return { error: `file extension must start with dot: '${ext}'` }; + } + } + } + + return { + regex, + engine, + ruleName, + description, + violationMessage, + tags, + severity, + fileExtensions, + regexIgnore: input.regexIgnore?.trim(), + includeMetadata: input.includeMetadata, + workingDirectory + }; +} + +function buildRegexRuleYaml(input: NormalizedInput): string { + const lines: string[] = []; + + lines.push(` ${input.ruleName}:`); + lines.push(` regex: "${input.regex}"`); + + if (input.regexIgnore) { + lines.push(` regex_ignore: "${input.regexIgnore}"`); + } + + if (input.fileExtensions && input.fileExtensions.length > 0) { + lines.push(` file_extensions:`); + input.fileExtensions.forEach(ext => { + lines.push(` - "${ext}"`); + }); + } + + lines.push(` description: "${escapeYamlString(input.description)}"`); + lines.push(` violation_message: "${escapeYamlString(input.violationMessage)}"`); + + lines.push(` tags:`); + input.tags.forEach(tag => { + lines.push(` - "${tag}"`); + }); + + lines.push(` severity: ${input.severity}`); + + if (input.includeMetadata !== undefined) { + lines.push(` include_metadata: ${input.includeMetadata}`); + } + + return lines.join("\n"); +} + +function escapeYamlString(str: string): string { + return str + .replace(/\\/g, "\\\\") + .replace(/"/g, '\\"') + .replace(/\n/g, "\\n") + .replace(/\r/g, "\\r"); +} + +/** + * Finds existing code-analyzer config file or returns default path for creating new one. + * Priority: code-analyzer.yaml > code-analyzer.yml (matches Code Analyzer Core behavior) + */ +function findOrCreateConfigPath(workingDirectory: string): string { + const yamlPath = path.join(workingDirectory, "code-analyzer.yaml"); + const ymlPath = path.join(workingDirectory, "code-analyzer.yml"); + + if (fssync.existsSync(yamlPath)) { + return yamlPath; + } + if (fssync.existsSync(ymlPath)) { + return ymlPath; + } + + // If neither exists, default to .yml for creating new file + return ymlPath; +} + +async function upsertRegexRuleInConfig( + configPath: string, + ruleName: string, + ruleYaml: string +): Promise { + const existing = await readConfigIfExists(configPath); + + if (!existing) { + // Create new config file with regex engine and rule + await writeNewConfigWithRegexRule(configPath, ruleYaml); + return; + } + + // Check if rule already exists + if (ruleAlreadyExists(existing, ruleName)) { + throw new Error(`Rule '${ruleName}' already exists in config. Please choose a different name or remove the existing rule.`); + } + + // Update existing config + const updated = addRegexRuleToConfig(existing, ruleYaml); + await fs.writeFile(configPath, updated, "utf8"); +} + +function ruleAlreadyExists(configContent: string, ruleName: string): boolean { + // Simple check: look for the rule name pattern under regex custom_rules section + const lines = configContent.split(/\r?\n/); + let inRegexCustomRules = false; + + for (const line of lines) { + const trimmed = line.trim(); + + // Track if we're in the regex.custom_rules section + if (trimmed === "regex:" || trimmed.startsWith("regex:")) { + inRegexCustomRules = true; + continue; + } + + // If we're in regex section and find another engine, we've left the section + if (inRegexCustomRules && line.match(/^\s{2}\w+:/) && !trimmed.startsWith("custom_rules")) { + inRegexCustomRules = false; + } + + // Check for rule name + if (inRegexCustomRules && trimmed === `${ruleName}:`) { + return true; + } + } + + return false; +} + +function addRegexRuleToConfig(configContent: string, ruleYaml: string): string { + const lines = configContent.split(/\r?\n/); + const indices = findRegexCustomRulesIndices(lines); + + // Case 1: engines.regex.custom_rules exists - add rule after custom_rules line + if (indices.customRulesLineIndex !== -1) { + lines.splice(indices.customRulesLineIndex + 1, 0, ruleYaml); + return lines.join("\n"); + } + + // Case 2: engines.regex exists but no custom_rules - add custom_rules section + if (indices.regexLineIndex !== -1) { + lines.splice(indices.regexLineIndex + 1, 0, " custom_rules:", ruleYaml); + return lines.join("\n"); + } + + // Case 3: engines exists but no regex - add regex section + if (indices.enginesLineIndex !== -1) { + lines.splice(indices.enginesLineIndex + 1, 0, " regex:", " custom_rules:", ruleYaml); + return lines.join("\n"); + } + + // Case 4: No engines section - append at end + return appendRegexEngineBlock(configContent, ruleYaml); +} + +function findRegexCustomRulesIndices(lines: string[]): { + enginesLineIndex: number; + regexLineIndex: number; + customRulesLineIndex: number; +} { + let enginesLineIndex = -1; + let regexLineIndex = -1; + let customRulesLineIndex = -1; + + for (let i = 0; i < lines.length; i++) { + const trimmed = lines[i].trim(); + + if (trimmed === "engines:") { + enginesLineIndex = i; + continue; + } + + if (trimmed === "regex:" && enginesLineIndex !== -1) { + regexLineIndex = i; + continue; + } + + if (trimmed === "custom_rules:" && regexLineIndex !== -1) { + customRulesLineIndex = i; + break; + } + } + + return { enginesLineIndex, regexLineIndex, customRulesLineIndex }; +} + +function appendRegexEngineBlock(configContent: string, ruleYaml: string): string { + return [ + configContent.trimEnd(), + "", + "engines:", + " regex:", + " custom_rules:", + ruleYaml + ].join("\n"); +} + +async function writeNewConfigWithRegexRule(configPath: string, ruleYaml: string): Promise { + const content = [ + "engines:", + " regex:", + " custom_rules:", + ruleYaml + ].join("\n"); + + await fs.writeFile(configPath, content, "utf8"); +} + +async function readConfigIfExists(configPath: string): Promise { + try { + return await fs.readFile(configPath, "utf8"); + } catch (error) { + const code = (error as NodeJS.ErrnoException).code; + if (code === "ENOENT") { + return null; + } + throw error; + } +} diff --git a/packages/mcp-provider-code-analyzer/src/provider.ts b/packages/mcp-provider-code-analyzer/src/provider.ts index 59b0a59a..9d4a7e56 100644 --- a/packages/mcp-provider-code-analyzer/src/provider.ts +++ b/packages/mcp-provider-code-analyzer/src/provider.ts @@ -11,8 +11,10 @@ import {DescribeRuleActionImpl} from "./actions/describe-rule.js"; import { ListRulesActionImpl } from "./actions/list-rules.js"; import { GenerateXpathPromptMcpTool } from "./tools/generate_xpath_prompt.js"; import { CreateCustomRuleMcpTool } from "./tools/create_custom_rule.js"; +// import { CreateRegexRuleMcpTool } from "./tools/create_regex_rule.js"; // Temporary tool - not registered import { GetAstNodesActionImpl } from "./actions/get-ast-nodes.js"; -import { CreateXpathCustomRuleActionImpl } from "./actions/create-xpath-custom-rule.js"; +import { RuleCreationStrategyFactory } from "./strategies/RuleCreationStrategyFactory.js"; +// import { CreateRegexCustomRuleActionImpl } from "./actions/create-regex-custom-rule.js"; // Used by strategy export class CodeAnalyzerMcpProvider extends McpProvider { public getName(): string { @@ -40,7 +42,9 @@ export class CodeAnalyzerMcpProvider extends McpProvider { })), new CodeAnalyzerQueryResultsMcpTool(new QueryResultsActionImpl(), services.getTelemetryService()), new GenerateXpathPromptMcpTool(new GetAstNodesActionImpl(), services.getTelemetryService()), - new CreateCustomRuleMcpTool(new CreateXpathCustomRuleActionImpl(), services.getTelemetryService()) + new CreateCustomRuleMcpTool(new RuleCreationStrategyFactory(), services.getTelemetryService()) + // NOTE: create_regex_rule tool is NOT registered - kept in codebase for reference only + // Use create_custom_rule with engine: "regex" to test regex rule creation via strategy pattern ]); } } \ No newline at end of file diff --git a/packages/mcp-provider-code-analyzer/src/strategies/IRuleCreationStrategy.ts b/packages/mcp-provider-code-analyzer/src/strategies/IRuleCreationStrategy.ts new file mode 100644 index 00000000..45839071 --- /dev/null +++ b/packages/mcp-provider-code-analyzer/src/strategies/IRuleCreationStrategy.ts @@ -0,0 +1,52 @@ +// Interface for rule creation strategies supporting different engines + +export type RuleCreationInput = { + engine: string; + ruleName: string; + description: string; + workingDirectory: string; + + // PMD/XPath specific fields + xpath?: string; + language?: string; + priority?: number; + + // Regex specific fields + regex?: string; + violationMessage?: string; + tags?: string[]; + severity?: number; + fileExtensions?: string[]; + regexIgnore?: string; + includeMetadata?: boolean; +}; + +export type ValidationResult = { + isValid: boolean; + errors: string[]; +}; + +export type RuleCreationOutput = { + status: string; + configPath?: string; + rulesetPath?: string; // PMD only - path to XML file + ruleYaml?: string; // Regex only - generated YAML + ruleXml?: string; // PMD only - generated XML content +}; + +export interface IRuleCreationStrategy { + /** + * Validate engine-specific inputs + */ + validate(input: RuleCreationInput): ValidationResult; + + /** + * Execute rule creation + */ + execute(input: RuleCreationInput): Promise; + + /** + * Get the engine name this strategy supports + */ + getSupportedEngine(): string; +} diff --git a/packages/mcp-provider-code-analyzer/src/strategies/RegexRuleStrategy.ts b/packages/mcp-provider-code-analyzer/src/strategies/RegexRuleStrategy.ts new file mode 100644 index 00000000..abb65628 --- /dev/null +++ b/packages/mcp-provider-code-analyzer/src/strategies/RegexRuleStrategy.ts @@ -0,0 +1,87 @@ +import { + IRuleCreationStrategy, + RuleCreationInput, + ValidationResult, + RuleCreationOutput +} from "./IRuleCreationStrategy.js"; +import { + CreateRegexCustomRuleActionImpl, + CreateRegexCustomRuleInput +} from "../actions/create-regex-custom-rule.js"; + +/** + * Strategy for creating Regex engine custom rules. + * Rules are added directly to code-analyzer.yml under engines.regex.custom_rules + */ +export class RegexRuleStrategy implements IRuleCreationStrategy { + private readonly action: CreateRegexCustomRuleActionImpl; + + constructor() { + this.action = new CreateRegexCustomRuleActionImpl(); + } + + public getSupportedEngine(): string { + return "regex"; + } + + public validate(input: RuleCreationInput): ValidationResult { + const errors: string[] = []; + + // Validate regex pattern + const regex = input.regex?.trim(); + if (!regex) { + errors.push("regex is required for regex engine"); + } else if (!regex.startsWith("/") || regex.lastIndexOf("/") <= 0) { + errors.push("regex must be in format '/pattern/flags' (e.g., '/todo/gi')"); + } + + // Validate violation message + if (!input.violationMessage?.trim()) { + errors.push("violationMessage is required for regex engine"); + } + + // Validate tags + if (!input.tags || input.tags.length === 0) { + errors.push("tags is required for regex engine (provide at least one tag)"); + } + + // Validate severity + if (input.severity === undefined || input.severity === null) { + errors.push("severity is required for regex engine"); + } else if (input.severity < 1 || input.severity > 5) { + errors.push("severity must be between 1 (Critical) and 5 (Info)"); + } + + // Validate file extensions format if provided + if (input.fileExtensions && input.fileExtensions.length > 0) { + for (const ext of input.fileExtensions) { + if (!ext.startsWith(".")) { + errors.push(`file extension must start with dot: '${ext}' (use '.cls' not 'cls')`); + } + } + } + + return { + isValid: errors.length === 0, + errors + }; + } + + public async execute(input: RuleCreationInput): Promise { + const actionInput: CreateRegexCustomRuleInput = { + regex: input.regex!, + ruleName: input.ruleName, + description: input.description, + violationMessage: input.violationMessage!, + tags: input.tags!, + severity: input.severity!, + workingDirectory: input.workingDirectory, + fileExtensions: input.fileExtensions, + regexIgnore: input.regexIgnore, + includeMetadata: input.includeMetadata, + engine: "regex" + }; + + return await this.action.exec(actionInput); + } +} diff --git a/packages/mcp-provider-code-analyzer/src/strategies/RuleCreationStrategyFactory.ts b/packages/mcp-provider-code-analyzer/src/strategies/RuleCreationStrategyFactory.ts new file mode 100644 index 00000000..2fc8e70a --- /dev/null +++ b/packages/mcp-provider-code-analyzer/src/strategies/RuleCreationStrategyFactory.ts @@ -0,0 +1,59 @@ +import { IRuleCreationStrategy } from "./IRuleCreationStrategy.js"; +import { XPathRuleStrategy } from "./XPathRuleStrategy.js"; +import { RegexRuleStrategy } from "./RegexRuleStrategy.js"; + +/** + * Factory for creating rule creation strategies based on engine type. + * Supports dynamic strategy registration and retrieval. + */ +export class RuleCreationStrategyFactory { + private readonly strategies: Map; + + constructor() { + this.strategies = new Map(); + + // Register default strategies + this.registerStrategy(new XPathRuleStrategy()); + this.registerStrategy(new RegexRuleStrategy()); + } + + /** + * Register a new strategy + */ + public registerStrategy(strategy: IRuleCreationStrategy): void { + const engine = strategy.getSupportedEngine().toLowerCase(); + this.strategies.set(engine, strategy); + } + + /** + * Get strategy for the specified engine + * @throws Error if engine is not supported + */ + public createStrategy(engine: string): IRuleCreationStrategy { + const normalizedEngine = engine.toLowerCase().trim(); + const strategy = this.strategies.get(normalizedEngine); + + if (!strategy) { + const supportedEngines = this.getSupportedEngines().join(", "); + throw new Error( + `Unsupported engine: '${engine}'. Supported engines: ${supportedEngines}` + ); + } + + return strategy; + } + + /** + * Get list of all supported engine names + */ + public getSupportedEngines(): string[] { + return Array.from(this.strategies.keys()); + } + + /** + * Check if an engine is supported + */ + public isEngineSupported(engine: string): boolean { + return this.strategies.has(engine.toLowerCase().trim()); + } +} diff --git a/packages/mcp-provider-code-analyzer/src/strategies/XPathRuleStrategy.ts b/packages/mcp-provider-code-analyzer/src/strategies/XPathRuleStrategy.ts new file mode 100644 index 00000000..bc037aac --- /dev/null +++ b/packages/mcp-provider-code-analyzer/src/strategies/XPathRuleStrategy.ts @@ -0,0 +1,75 @@ +import { + IRuleCreationStrategy, + RuleCreationInput, + ValidationResult, + RuleCreationOutput +} from "./IRuleCreationStrategy.js"; +import { + CreateXpathCustomRuleActionImpl, + CreateXpathCustomRuleInput +} from "../actions/create-xpath-custom-rule.js"; + +/** + * Strategy for creating PMD XPath-based custom rules. + * Rules are stored in separate XML files and referenced in code-analyzer.yml + */ +export class XPathRuleStrategy implements IRuleCreationStrategy { + private readonly action: CreateXpathCustomRuleActionImpl; + + constructor() { + this.action = new CreateXpathCustomRuleActionImpl(); + } + + public getSupportedEngine(): string { + return "pmd"; + } + + public validate(input: RuleCreationInput): ValidationResult { + const errors: string[] = []; + + // Validate xpath + const xpath = input.xpath?.trim(); + if (!xpath) { + const langLower = input.language?.toLowerCase() || ""; + if (langLower === "apex" || langLower === "visualforce") { + errors.push( + "xpath is required for PMD engine. " + + "For Apex and Visualforce, use tool 'get_ast_nodes_to_generate_xpath' to generate the XPath." + ); + } else { + errors.push("xpath is required for PMD engine. Provide a valid XPath expression."); + } + } + + // Validate language + if (!input.language?.trim()) { + errors.push("language is required for PMD engine (e.g., 'apex', 'visualforce')"); + } + + // Validate priority + if (input.priority === undefined || input.priority === null) { + errors.push("priority is required for PMD engine (provide a value between 1 and 5)"); + } else if (input.priority < 1 || input.priority > 5) { + errors.push("priority must be between 1 and 5"); + } + + return { + isValid: errors.length === 0, + errors + }; + } + + public async execute(input: RuleCreationInput): Promise { + const actionInput: CreateXpathCustomRuleInput = { + xpath: input.xpath!, + ruleName: input.ruleName, + description: input.description, + language: input.language!, + priority: input.priority!, + workingDirectory: input.workingDirectory, + engine: "pmd" + }; + + return await this.action.exec(actionInput); + } +} diff --git a/packages/mcp-provider-code-analyzer/src/tools/create_custom_rule.ts b/packages/mcp-provider-code-analyzer/src/tools/create_custom_rule.ts index 62411606..79d3cde1 100644 --- a/packages/mcp-provider-code-analyzer/src/tools/create_custom_rule.ts +++ b/packages/mcp-provider-code-analyzer/src/tools/create_custom_rule.ts @@ -4,72 +4,96 @@ import { McpTool, McpToolConfig, ReleaseState, Toolset, TelemetryService } from import * as Constants from "../constants.js"; import { sanitizePath } from "../utils.js"; import { - CreateXpathCustomRuleAction, - CreateXpathCustomRuleActionImpl, - CreateXpathCustomRuleInput, - CreateXpathCustomRuleOutput -} from "../actions/create-xpath-custom-rule.js"; + IRuleCreationStrategy, + RuleCreationInput, + RuleCreationOutput +} from "../strategies/IRuleCreationStrategy.js"; +import { RuleCreationStrategyFactory } from "../strategies/RuleCreationStrategyFactory.js"; -// MCP tool wrapper that validates input and delegates rule creation. +// MCP tool wrapper that validates input and delegates rule creation to appropriate strategy. const DESCRIPTION: string = - `Purpose: Create a custom rule using a provided XPath expression. -Use this tool after an XPath has been generated for a specific violation pattern. + `Purpose: Create a custom rule for code analysis. +Supports two engines: +1. PMD (XPath-based rules) - for Apex, Visualforce, and other languages +2. Regex (Pattern-based rules) - for pattern matching across file types + +=== For PMD Engine === Workflow for Apex and Visualforce: -- For Apex and Visualforce languages, first call "get_ast_nodes_to_generate_xpath" to get AST nodes and generate the XPath. -- Then call this tool with the generated XPath. - -Workflow for other languages: -- Generate the XPath expression manually for your scenario. -- Then call this tool with the XPath. - -If xpath is not provided and engine is "pmd": -- Call the tool "get_ast_nodes_to_generate_xpath" (for Apex/Visualforce) to generate the XPath. -- Then call this tool again with the generated XPath. - -Inputs (required): -- xpath: The XPath expression that should match the violation. -- ruleName: Name for the custom rule. -- description: A short description/message for the rule. -- language: Language for the rule (e.g., "apex"). -- engine: Engine name (e.g., "pmd"). -- priority: PMD priority (1-5). -- workingDirectory: Workspace directory where the ruleset XML and code-analyzer.yml will be created (or updated). - -Output: -- rulesetPath: Path to the generated custom ruleset XML. -- configPath: Path to the updated code-analyzer.yml that references the custom ruleset.`; +- First call "get_ast_nodes_to_generate_xpath" to generate the XPath +- Then call this tool with the generated XPath + +Required inputs: +- engine: "pmd" +- xpath: The XPath expression that should match the violation +- ruleName: Name for the custom rule +- description: A short description/message for the rule +- language: Language for the rule (e.g., "apex", "visualforce") +- priority: PMD priority (1-5) +- workingDirectory: Workspace directory where files will be created + +Output: Creates a ruleset XML file and updates code-analyzer.yml + +=== For Regex Engine === +Required inputs: +- engine: "regex" +- regex: The regex pattern as string (e.g., "/todo/gi") +- ruleName: Name for the custom rule +- description: Rule description +- violationMessage: Message shown when violation is found +- tags: Array of tags (e.g., ["Security", "BestPractices"]) +- severity: Number 1-5 (1=Critical, 2=High, 3=Moderate, 4=Low, 5=Info) +- workingDirectory: Workspace directory where config will be updated + +Optional inputs for Regex: +- fileExtensions: Array of file extensions to scan (e.g., [".cls", ".trigger"]) +- regexIgnore: Pattern to exclude from matches (e.g., "/^test/i") +- includeMetadata: Boolean flag for metadata inclusion + +Output: Updates code-analyzer.yml directly with the regex rule`; export const inputSchema = z.object({ - xpath: z.string().optional().describe("XPath expression that should match the violation (required for PMD)."), + engine: z.string().describe("Analysis engine: 'pmd' or 'regex'"), ruleName: z.string().describe("Name for the custom rule."), - description: z.string().describe("Short description or message for the rule."), - language: z.string().describe("Language for the rule (e.g., 'apex')."), - engine: z.string().describe("Analysis engine (e.g., 'pmd')."), - priority: z.number().int().min(1).max(5).describe("PMD priority (1-5)."), - workingDirectory: z.string().describe("Workspace directory where code-analyzer.yml will be created (or updated).") + description: z.string().describe("Short description of what the rule checks."), + workingDirectory: z.string().describe("Workspace directory where files will be created (or updated)."), + + // PMD/XPath specific fields + xpath: z.string().optional().describe("XPath expression that should match the violation (required for PMD)."), + language: z.string().optional().describe("Language for the rule (e.g., 'apex') - required for PMD."), + priority: z.number().int().min(1).max(5).optional().describe("PMD priority (1-5) - required for PMD."), + + // Regex specific fields + regex: z.string().optional().describe("Regex pattern as string (e.g., '/todo/gi') - required for Regex."), + violationMessage: z.string().optional().describe("Message shown when violation is found - required for Regex."), + tags: z.array(z.string()).optional().describe("Array of tags (e.g., ['Security']) - required for Regex."), + severity: z.number().int().min(1).max(5).optional().describe("Severity 1-5 (1=Critical, 5=Info) - required for Regex."), + fileExtensions: z.array(z.string()).optional().describe("Optional array of file extensions for Regex (e.g., ['.cls'])."), + regexIgnore: z.string().optional().describe("Optional regex pattern to exclude matches for Regex engine."), + includeMetadata: z.boolean().optional().describe("Optional boolean flag for metadata inclusion in Regex rules.") }); type InputArgsShape = typeof inputSchema.shape; const outputSchema = z.object({ - status: z.string().describe(`'success' or an error message.`), + status: z.string().describe("'success' or an error message."), ruleXml: z.string().optional().describe("Generated PMD ruleset XML for the custom rule."), rulesetPath: z.string().optional().describe("Path to the generated PMD ruleset XML."), - configPath: z.string().optional().describe("Path to the generated code-analyzer.yml.") + configPath: z.string().optional().describe("Path to the generated/updated code-analyzer.yml."), + ruleYaml: z.string().optional().describe("Generated YAML for regex rules.") }); type OutputArgsShape = typeof outputSchema.shape; export class CreateCustomRuleMcpTool extends McpTool { public static readonly NAME: string = "create_custom_rule"; - private readonly action: CreateXpathCustomRuleAction; + private readonly strategyFactory: RuleCreationStrategyFactory; private readonly telemetryService?: TelemetryService; public constructor( - action: CreateXpathCustomRuleAction = new CreateXpathCustomRuleActionImpl(), + strategyFactory: RuleCreationStrategyFactory = new RuleCreationStrategyFactory(), telemetryService?: TelemetryService ) { super(); - this.action = action; + this.strategyFactory = strategyFactory; this.telemetryService = telemetryService; } @@ -92,7 +116,7 @@ export class CreateCustomRuleMcpTool extends McpTool): Promise { - const validationError = validateInput(input); - if (validationError) { - return validationError; + // Step 1: Validate common inputs + const commonValidationError = validateCommonInputs(input); + if (commonValidationError) { + return commonValidationError; } - const output: CreateXpathCustomRuleOutput = await this.action.exec(input as CreateXpathCustomRuleInput); - const message = output.rulesetPath && output.configPath - ? `Custom rule created. Ruleset: ${output.rulesetPath}. Code Analyzer config: ${output.configPath}.` - : output.status; + + // Step 2: Get strategy from factory + let strategy: IRuleCreationStrategy; + try { + strategy = this.strategyFactory.createStrategy(input.engine); + } catch (error) { + const supportedEngines = this.strategyFactory.getSupportedEngines().join(", "); + return buildError( + `Unsupported engine: '${input.engine}'. Supported engines: ${supportedEngines}` + ); + } + + // Step 3: Convert to RuleCreationInput + const ruleInput: RuleCreationInput = { + engine: input.engine, + ruleName: input.ruleName, + description: input.description, + workingDirectory: input.workingDirectory, + xpath: input.xpath, + language: input.language, + priority: input.priority, + regex: input.regex, + violationMessage: input.violationMessage, + tags: input.tags, + severity: input.severity, + fileExtensions: input.fileExtensions, + regexIgnore: input.regexIgnore, + includeMetadata: input.includeMetadata + }; + + // Step 4: Engine-specific validation + const validation = strategy.validate(ruleInput); + if (!validation.isValid) { + return buildError(validation.errors.join("; ")); + } + + // Step 5: Execute rule creation + let output: RuleCreationOutput; + try { + output = await strategy.execute(ruleInput); + } catch (error) { + return buildError(getErrorMessage(error)); + } + + // Step 6: Emit telemetry if (this.telemetryService && output.status === "success") { this.telemetryService.sendEvent(Constants.TelemetryEventName, { source: Constants.TelemetrySource, @@ -119,6 +185,10 @@ export class CreateCustomRuleMcpTool extends McpTool): CallToolResult | undefined { +function validateCommonInputs(input: z.infer): CallToolResult | undefined { const ruleName = input.ruleName?.trim(); if (!ruleName) { return buildError("ruleName is required. Provide a name for the custom rule."); @@ -135,30 +205,12 @@ function validateInput(input: z.infer): CallToolResult | und const description = input.description?.trim(); if (!description) { - return buildError("description is required. Provide a short description or message for the rule."); - } - - const language = input.language?.trim(); - if (!language) { - return buildError("language is required. Provide a language such as 'apex'."); + return buildError("description is required. Provide a short description of what the rule checks."); } const engine = input.engine?.trim(); if (!engine) { - return buildError("engine is required. Provide an engine such as 'pmd'."); - } - - const xpath = input.xpath?.trim(); - if (engine.toLowerCase() === "pmd" && !xpath) { - const langLower = language.toLowerCase(); - if (langLower === "apex" || langLower === "visualforce") { - return buildError("xpath is required for engine 'pmd'. For Apex and Visualforce, use tool 'get_ast_nodes_to_generate_xpath' to generate the XPath."); - } - return buildError("xpath is required for engine 'pmd'. Provide a valid XPath expression for your scenario."); - } - - if (input.priority === undefined || input.priority === null) { - return buildError("priority is required. Provide a value between 1 and 5."); + return buildError("engine is required. Provide an engine such as 'pmd' or 'regex'."); } const workingDirectory = input.workingDirectory?.trim(); @@ -173,6 +225,24 @@ function validateInput(input: z.infer): CallToolResult | und return undefined; } +function buildSuccessMessage(output: RuleCreationOutput, engine: string): string { + if (output.status !== "success") { + return output.status; + } + + if (engine.toLowerCase() === "pmd") { + return output.rulesetPath && output.configPath + ? `Custom PMD rule created successfully. Ruleset: ${output.rulesetPath}. Config: ${output.configPath}.` + : output.status; + } else if (engine.toLowerCase() === "regex") { + return output.configPath + ? `Custom Regex rule created successfully. Config: ${output.configPath}.` + : output.status; + } + + return output.status; +} + function buildError(status: string): CallToolResult { const output = { status }; return { @@ -181,3 +251,10 @@ function buildError(status: string): CallToolResult { isError: true }; } + +function getErrorMessage(error: unknown): string { + if (error instanceof Error) { + return error.message; + } + return String(error); +} diff --git a/packages/mcp-provider-code-analyzer/test/actions/create-regex-custom-rule.test.ts b/packages/mcp-provider-code-analyzer/test/actions/create-regex-custom-rule.test.ts new file mode 100644 index 00000000..ae9df235 --- /dev/null +++ b/packages/mcp-provider-code-analyzer/test/actions/create-regex-custom-rule.test.ts @@ -0,0 +1,260 @@ +import path from "node:path"; +import fs from "node:fs/promises"; +import os from "node:os"; +import { + CreateRegexCustomRuleActionImpl, + CreateRegexCustomRuleInput, + CreateRegexCustomRuleOutput +} from "../../src/actions/create-regex-custom-rule.js"; + +describe("CreateRegexCustomRuleAction tests", () => { + let tempDir: string; + let action: CreateRegexCustomRuleActionImpl; + + beforeEach(async () => { + tempDir = await fs.mkdtemp(path.join(os.tmpdir(), "regex-rule-test-")); + action = new CreateRegexCustomRuleActionImpl(); + }); + + afterEach(async () => { + await fs.rm(tempDir, { recursive: true, force: true }); + }); + + it("should create a new config file with regex rule", async () => { + const input: CreateRegexCustomRuleInput = { + regex: "/todo/gi", + ruleName: "NoTodos", + description: "Detects TODO comments", + violationMessage: "TODO comment found", + tags: ["BestPractices"], + severity: 3, + workingDirectory: tempDir, + engine: "regex" + }; + + const output: CreateRegexCustomRuleOutput = await action.exec(input); + + expect(output.status).toBe("success"); + expect(output.configPath).toBeDefined(); + expect(output.ruleYaml).toBeDefined(); + + // Verify config file was created + const configContent = await fs.readFile(output.configPath!, "utf8"); + expect(configContent).toContain("engines:"); + expect(configContent).toContain("regex:"); + expect(configContent).toContain("custom_rules:"); + expect(configContent).toContain("NoTodos:"); + expect(configContent).toContain('regex: "/todo/gi"'); + expect(configContent).toContain('description: "Detects TODO comments"'); + expect(configContent).toContain('violation_message: "TODO comment found"'); + expect(configContent).toContain('"BestPractices"'); + expect(configContent).toContain("severity: 3"); + }); + + it("should create rule with all optional fields", async () => { + const input: CreateRegexCustomRuleInput = { + regex: "/[a-zA-Z0-9]{15,18}/g", + ruleName: "NoHardcodedIds", + description: "Detects hardcoded Salesforce IDs", + violationMessage: "Hardcoded ID detected", + tags: ["Security", "BestPractices"], + severity: 2, + fileExtensions: [".cls", ".trigger"], + regexIgnore: "/^(000|001)/", + includeMetadata: true, + workingDirectory: tempDir, + engine: "regex" + }; + + const output: CreateRegexCustomRuleOutput = await action.exec(input); + + expect(output.status).toBe("success"); + + const configContent = await fs.readFile(output.configPath!, "utf8"); + expect(configContent).toContain('regex: "/[a-zA-Z0-9]{15,18}/g"'); + expect(configContent).toContain('regex_ignore: "/^(000|001)/"'); + expect(configContent).toContain('file_extensions:'); + expect(configContent).toContain('".cls"'); + expect(configContent).toContain('".trigger"'); + expect(configContent).toContain('"Security"'); + expect(configContent).toContain('"BestPractices"'); + expect(configContent).toContain("severity: 2"); + expect(configContent).toContain("include_metadata: true"); + }); + + it("should add rule to existing config file", async () => { + // Create initial config with one rule + const configPath = path.join(tempDir, "code-analyzer.yml"); + const initialConfig = `engines: + regex: + custom_rules: + ExistingRule: + regex: "/test/g" + description: "Existing rule" + violation_message: "Test violation" + tags: + - "Test" + severity: 4 +`; + await fs.writeFile(configPath, initialConfig, "utf8"); + + const input: CreateRegexCustomRuleInput = { + regex: "/fixme/gi", + ruleName: "NoFixme", + description: "Detects FIXME comments", + violationMessage: "FIXME comment found", + tags: ["CodeQuality"], + severity: 3, + workingDirectory: tempDir, + engine: "regex" + }; + + const output: CreateRegexCustomRuleOutput = await action.exec(input); + + expect(output.status).toBe("success"); + + const configContent = await fs.readFile(output.configPath!, "utf8"); + // Both rules should exist + expect(configContent).toContain("ExistingRule:"); + expect(configContent).toContain("NoFixme:"); + expect(configContent).toContain('regex: "/test/g"'); + expect(configContent).toContain('regex: "/fixme/gi"'); + }); + + it("should reject duplicate rule name", async () => { + // Create initial config + const input1: CreateRegexCustomRuleInput = { + regex: "/todo/gi", + ruleName: "NoTodos", + description: "First todo rule", + violationMessage: "TODO found", + tags: ["Test"], + severity: 3, + workingDirectory: tempDir, + engine: "regex" + }; + + await action.exec(input1); + + // Try to create rule with same name + const input2: CreateRegexCustomRuleInput = { + regex: "/fixme/gi", + ruleName: "NoTodos", // Same name + description: "Second rule", + violationMessage: "FIXME found", + tags: ["Test"], + severity: 3, + workingDirectory: tempDir, + engine: "regex" + }; + + await expect(action.exec(input2)).rejects.toThrow(/already exists/); + }); + + it("should reject missing required fields", async () => { + const input: CreateRegexCustomRuleInput = { + regex: "", // Empty regex + ruleName: "TestRule", + description: "Test", + violationMessage: "Test violation", + tags: ["Test"], + severity: 3, + workingDirectory: tempDir, + engine: "regex" + }; + + const output: CreateRegexCustomRuleOutput = await action.exec(input); + + expect(output.status).toContain("regex is required"); + }); + + it("should reject invalid regex format", async () => { + const input: CreateRegexCustomRuleInput = { + regex: "todo", // Missing slashes and flags + ruleName: "TestRule", + description: "Test", + violationMessage: "Test violation", + tags: ["Test"], + severity: 3, + workingDirectory: tempDir, + engine: "regex" + }; + + const output: CreateRegexCustomRuleOutput = await action.exec(input); + + expect(output.status).toContain("format"); + }); + + it("should reject invalid severity", async () => { + const input: CreateRegexCustomRuleInput = { + regex: "/todo/gi", + ruleName: "TestRule", + description: "Test", + violationMessage: "Test violation", + tags: ["Test"], + severity: 10, // Invalid: must be 1-5 + workingDirectory: tempDir, + engine: "regex" + }; + + const output: CreateRegexCustomRuleOutput = await action.exec(input); + + expect(output.status).toContain("severity must be between 1"); + }); + + it("should reject invalid file extension format", async () => { + const input: CreateRegexCustomRuleInput = { + regex: "/todo/gi", + ruleName: "TestRule", + description: "Test", + violationMessage: "Test violation", + tags: ["Test"], + severity: 3, + fileExtensions: ["cls"], // Missing dot + workingDirectory: tempDir, + engine: "regex" + }; + + const output: CreateRegexCustomRuleOutput = await action.exec(input); + + expect(output.status).toContain("must start with dot"); + }); + + it("should handle existing config with other engines", async () => { + // Create config with PMD rules + const configPath = path.join(tempDir, "code-analyzer.yml"); + const initialConfig = `engines: + pmd: + custom_rulesets: + - "custom-rules/my-pmd-rules.xml" + +rules: + pmd: + WhileLoopsMustUseBraces: + severity: HIGH +`; + await fs.writeFile(configPath, initialConfig, "utf8"); + + const input: CreateRegexCustomRuleInput = { + regex: "/todo/gi", + ruleName: "NoTodos", + description: "Detects TODO comments", + violationMessage: "TODO found", + tags: ["CodeQuality"], + severity: 3, + workingDirectory: tempDir, + engine: "regex" + }; + + const output: CreateRegexCustomRuleOutput = await action.exec(input); + + expect(output.status).toBe("success"); + + const configContent = await fs.readFile(output.configPath!, "utf8"); + // Both engines should exist + expect(configContent).toContain("pmd:"); + expect(configContent).toContain("regex:"); + expect(configContent).toContain("WhileLoopsMustUseBraces:"); + expect(configContent).toContain("NoTodos:"); + }); +}); diff --git a/packages/mcp-provider-code-analyzer/test/provider.test.ts b/packages/mcp-provider-code-analyzer/test/provider.test.ts index 2a79b493..fcc59335 100644 --- a/packages/mcp-provider-code-analyzer/test/provider.test.ts +++ b/packages/mcp-provider-code-analyzer/test/provider.test.ts @@ -7,6 +7,7 @@ import { CodeAnalyzerListRulesMcpTool } from "../src/tools/list_code_analyzer_ru import { CodeAnalyzerQueryResultsMcpTool } from "../src/tools/query_code_analyzer_results.js"; import { GenerateXpathPromptMcpTool } from "../src/tools/generate_xpath_prompt.js"; import { CreateCustomRuleMcpTool } from "../src/tools/create_custom_rule.js"; +// CreateRegexRuleMcpTool - Not registered in provider describe("Tests for CodeAnalyzerMcpProvider", () => { let services: Services; @@ -30,5 +31,6 @@ describe("Tests for CodeAnalyzerMcpProvider", () => { expect(tools[3]).toBeInstanceOf(CodeAnalyzerQueryResultsMcpTool); expect(tools[4]).toBeInstanceOf(GenerateXpathPromptMcpTool); expect(tools[5]).toBeInstanceOf(CreateCustomRuleMcpTool); + // Note: create_regex_rule is NOT registered - test via create_custom_rule with engine: "regex" }); }) \ No newline at end of file diff --git a/packages/mcp-provider-code-analyzer/test/strategies/RegexRuleStrategy.test.ts b/packages/mcp-provider-code-analyzer/test/strategies/RegexRuleStrategy.test.ts new file mode 100644 index 00000000..fc0f4e5d --- /dev/null +++ b/packages/mcp-provider-code-analyzer/test/strategies/RegexRuleStrategy.test.ts @@ -0,0 +1,410 @@ +import { describe, expect, it, beforeEach, afterEach, vi } from "vitest"; +import { RegexRuleStrategy } from "../../src/strategies/RegexRuleStrategy.js"; +import { RuleCreationInput, ValidationResult, RuleCreationOutput } from "../../src/strategies/IRuleCreationStrategy.js"; +import * as fs from "node:fs/promises"; +import * as os from "node:os"; +import * as path from "node:path"; + +describe("RegexRuleStrategy tests", () => { + let strategy: RegexRuleStrategy; + let tempDir: string; + + beforeEach(async () => { + strategy = new RegexRuleStrategy(); + tempDir = await fs.mkdtemp(path.join(os.tmpdir(), "regex-strategy-test-")); + }); + + afterEach(async () => { + await fs.rm(tempDir, { recursive: true, force: true }); + }); + + describe("getSupportedEngine", () => { + it("should return 'regex'", () => { + expect(strategy.getSupportedEngine()).toBe("regex"); + }); + }); + + describe("validate", () => { + it("should pass validation with all required fields", () => { + const input: RuleCreationInput = { + engine: "regex", + ruleName: "NoTodos", + description: "Detects TODO comments", + workingDirectory: tempDir, + regex: "/todo/gi", + violationMessage: "TODO found", + tags: ["BestPractices"], + severity: 3 + }; + + const result: ValidationResult = strategy.validate(input); + + expect(result.isValid).toBe(true); + expect(result.errors).toEqual([]); + }); + + it("should fail validation when regex is missing", () => { + const input: RuleCreationInput = { + engine: "regex", + ruleName: "NoTodos", + description: "Detects TODO comments", + workingDirectory: tempDir, + violationMessage: "TODO found", + tags: ["BestPractices"], + severity: 3 + }; + + const result: ValidationResult = strategy.validate(input); + + expect(result.isValid).toBe(false); + expect(result.errors).toContain("regex is required for regex engine"); + }); + + it("should fail validation when regex is empty string", () => { + const input: RuleCreationInput = { + engine: "regex", + ruleName: "NoTodos", + description: "Detects TODO comments", + workingDirectory: tempDir, + regex: " ", + violationMessage: "TODO found", + tags: ["BestPractices"], + severity: 3 + }; + + const result: ValidationResult = strategy.validate(input); + + expect(result.isValid).toBe(false); + expect(result.errors).toContain("regex is required for regex engine"); + }); + + it("should fail validation when regex format is invalid (no slashes)", () => { + const input: RuleCreationInput = { + engine: "regex", + ruleName: "NoTodos", + description: "Detects TODO comments", + workingDirectory: tempDir, + regex: "todo", + violationMessage: "TODO found", + tags: ["BestPractices"], + severity: 3 + }; + + const result: ValidationResult = strategy.validate(input); + + expect(result.isValid).toBe(false); + expect(result.errors).toContain("regex must be in format '/pattern/flags' (e.g., '/todo/gi')"); + }); + + it("should fail validation when violationMessage is missing", () => { + const input: RuleCreationInput = { + engine: "regex", + ruleName: "NoTodos", + description: "Detects TODO comments", + workingDirectory: tempDir, + regex: "/todo/gi", + tags: ["BestPractices"], + severity: 3 + }; + + const result: ValidationResult = strategy.validate(input); + + expect(result.isValid).toBe(false); + expect(result.errors).toContain("violationMessage is required for regex engine"); + }); + + it("should fail validation when violationMessage is empty string", () => { + const input: RuleCreationInput = { + engine: "regex", + ruleName: "NoTodos", + description: "Detects TODO comments", + workingDirectory: tempDir, + regex: "/todo/gi", + violationMessage: " ", + tags: ["BestPractices"], + severity: 3 + }; + + const result: ValidationResult = strategy.validate(input); + + expect(result.isValid).toBe(false); + expect(result.errors).toContain("violationMessage is required for regex engine"); + }); + + it("should fail validation when tags is missing", () => { + const input: RuleCreationInput = { + engine: "regex", + ruleName: "NoTodos", + description: "Detects TODO comments", + workingDirectory: tempDir, + regex: "/todo/gi", + violationMessage: "TODO found", + severity: 3 + }; + + const result: ValidationResult = strategy.validate(input); + + expect(result.isValid).toBe(false); + expect(result.errors).toContain("tags is required for regex engine (provide at least one tag)"); + }); + + it("should fail validation when tags is empty array", () => { + const input: RuleCreationInput = { + engine: "regex", + ruleName: "NoTodos", + description: "Detects TODO comments", + workingDirectory: tempDir, + regex: "/todo/gi", + violationMessage: "TODO found", + tags: [], + severity: 3 + }; + + const result: ValidationResult = strategy.validate(input); + + expect(result.isValid).toBe(false); + expect(result.errors).toContain("tags is required for regex engine (provide at least one tag)"); + }); + + it("should fail validation when severity is missing", () => { + const input: RuleCreationInput = { + engine: "regex", + ruleName: "NoTodos", + description: "Detects TODO comments", + workingDirectory: tempDir, + regex: "/todo/gi", + violationMessage: "TODO found", + tags: ["BestPractices"] + }; + + const result: ValidationResult = strategy.validate(input); + + expect(result.isValid).toBe(false); + expect(result.errors).toContain("severity is required for regex engine"); + }); + + it("should fail validation when severity is less than 1", () => { + const input: RuleCreationInput = { + engine: "regex", + ruleName: "NoTodos", + description: "Detects TODO comments", + workingDirectory: tempDir, + regex: "/todo/gi", + violationMessage: "TODO found", + tags: ["BestPractices"], + severity: 0 + }; + + const result: ValidationResult = strategy.validate(input); + + expect(result.isValid).toBe(false); + expect(result.errors).toContain("severity must be between 1 (Critical) and 5 (Info)"); + }); + + it("should fail validation when severity is greater than 5", () => { + const input: RuleCreationInput = { + engine: "regex", + ruleName: "NoTodos", + description: "Detects TODO comments", + workingDirectory: tempDir, + regex: "/todo/gi", + violationMessage: "TODO found", + tags: ["BestPractices"], + severity: 6 + }; + + const result: ValidationResult = strategy.validate(input); + + expect(result.isValid).toBe(false); + expect(result.errors).toContain("severity must be between 1 (Critical) and 5 (Info)"); + }); + + it("should fail validation when file extension does not start with dot", () => { + const input: RuleCreationInput = { + engine: "regex", + ruleName: "NoTodos", + description: "Detects TODO comments", + workingDirectory: tempDir, + regex: "/todo/gi", + violationMessage: "TODO found", + tags: ["BestPractices"], + severity: 3, + fileExtensions: ["cls", ".trigger"] + }; + + const result: ValidationResult = strategy.validate(input); + + expect(result.isValid).toBe(false); + expect(result.errors).toContain("file extension must start with dot: 'cls' (use '.cls' not 'cls')"); + }); + + it("should pass validation with valid file extensions", () => { + const input: RuleCreationInput = { + engine: "regex", + ruleName: "NoTodos", + description: "Detects TODO comments", + workingDirectory: tempDir, + regex: "/todo/gi", + violationMessage: "TODO found", + tags: ["BestPractices"], + severity: 3, + fileExtensions: [".cls", ".trigger"] + }; + + const result: ValidationResult = strategy.validate(input); + + expect(result.isValid).toBe(true); + expect(result.errors).toEqual([]); + }); + + it("should return multiple errors when multiple fields are invalid", () => { + const input: RuleCreationInput = { + engine: "regex", + ruleName: "NoTodos", + description: "Detects TODO comments", + workingDirectory: tempDir, + regex: "invalid", + violationMessage: "", + tags: [], + severity: 10 + }; + + const result: ValidationResult = strategy.validate(input); + + expect(result.isValid).toBe(false); + expect(result.errors.length).toBeGreaterThan(1); + expect(result.errors).toContain("regex must be in format '/pattern/flags' (e.g., '/todo/gi')"); + expect(result.errors).toContain("violationMessage is required for regex engine"); + expect(result.errors).toContain("tags is required for regex engine (provide at least one tag)"); + expect(result.errors).toContain("severity must be between 1 (Critical) and 5 (Info)"); + }); + }); + + describe("execute", () => { + it("should create a regex rule successfully", async () => { + const input: RuleCreationInput = { + engine: "regex", + ruleName: "NoTodos", + description: "Detects TODO comments", + workingDirectory: tempDir, + regex: "/todo/gi", + violationMessage: "TODO comment found", + tags: ["BestPractices", "CodeQuality"], + severity: 3 + }; + + const result: RuleCreationOutput = await strategy.execute(input); + + expect(result.status).toBe("success"); + expect(result.configPath).toBeDefined(); + expect(result.ruleYaml).toBeDefined(); + expect(result.ruleYaml).toContain("NoTodos:"); + expect(result.ruleYaml).toContain('regex: "/todo/gi"'); + expect(result.ruleYaml).toContain('violation_message: "TODO comment found"'); + expect(result.ruleYaml).toContain('"BestPractices"'); + expect(result.ruleYaml).toContain('"CodeQuality"'); + expect(result.ruleYaml).toContain("severity: 3"); + }); + + it("should create a regex rule with all optional fields", async () => { + const input: RuleCreationInput = { + engine: "regex", + ruleName: "NoHardcodedIds", + description: "Detects hardcoded IDs", + workingDirectory: tempDir, + regex: "/[a-zA-Z0-9]{15}/g", + violationMessage: "Hardcoded ID found", + tags: ["Security"], + severity: 2, + fileExtensions: [".cls", ".trigger"], + regexIgnore: "/^000/", + includeMetadata: true + }; + + const result: RuleCreationOutput = await strategy.execute(input); + + expect(result.status).toBe("success"); + expect(result.ruleYaml).toContain('regex_ignore: "/^000/"'); + expect(result.ruleYaml).toContain("file_extensions:"); + expect(result.ruleYaml).toContain('".cls"'); + expect(result.ruleYaml).toContain('".trigger"'); + expect(result.ruleYaml).toContain("include_metadata: true"); + }); + + it("should create config file if it does not exist", async () => { + const input: RuleCreationInput = { + engine: "regex", + ruleName: "NoTodos", + description: "Detects TODO comments", + workingDirectory: tempDir, + regex: "/todo/gi", + violationMessage: "TODO found", + tags: ["BestPractices"], + severity: 3 + }; + + const result: RuleCreationOutput = await strategy.execute(input); + + expect(result.status).toBe("success"); + expect(result.configPath).toBeDefined(); + + const configContent = await fs.readFile(result.configPath!, "utf8"); + expect(configContent).toContain("engines:"); + expect(configContent).toContain("regex:"); + expect(configContent).toContain("custom_rules:"); + expect(configContent).toContain("NoTodos:"); + }); + + it("should add rule to existing config file", async () => { + // Create initial config + const configPath = path.join(tempDir, "code-analyzer.yml"); + const initialConfig = `engines: + regex: + custom_rules: + ExistingRule: + regex: "/test/g" + description: "Test" + violation_message: "Test violation" + tags: + - "Test" + severity: 4 +`; + await fs.writeFile(configPath, initialConfig, "utf8"); + + const input: RuleCreationInput = { + engine: "regex", + ruleName: "NoTodos", + description: "Detects TODO comments", + workingDirectory: tempDir, + regex: "/todo/gi", + violationMessage: "TODO found", + tags: ["BestPractices"], + severity: 3 + }; + + const result: RuleCreationOutput = await strategy.execute(input); + + expect(result.status).toBe("success"); + + const configContent = await fs.readFile(result.configPath!, "utf8"); + expect(configContent).toContain("ExistingRule:"); + expect(configContent).toContain("NoTodos:"); + }); + + it("should throw error when working directory does not exist", async () => { + const input: RuleCreationInput = { + engine: "regex", + ruleName: "TestRule", + description: "Test", + workingDirectory: "/nonexistent/path/that/does/not/exist", + regex: "/test/g", + violationMessage: "Test violation", + tags: ["Test"], + severity: 3 + }; + + // Should throw an error when trying to write to nonexistent directory + await expect(strategy.execute(input)).rejects.toThrow(); + }); + }); +}); diff --git a/packages/mcp-provider-code-analyzer/test/strategies/RuleCreationStrategyFactory.test.ts b/packages/mcp-provider-code-analyzer/test/strategies/RuleCreationStrategyFactory.test.ts new file mode 100644 index 00000000..2168dac8 --- /dev/null +++ b/packages/mcp-provider-code-analyzer/test/strategies/RuleCreationStrategyFactory.test.ts @@ -0,0 +1,201 @@ +import { describe, expect, it, beforeEach } from "vitest"; +import { RuleCreationStrategyFactory } from "../../src/strategies/RuleCreationStrategyFactory.js"; +import { IRuleCreationStrategy } from "../../src/strategies/IRuleCreationStrategy.js"; +import { XPathRuleStrategy } from "../../src/strategies/XPathRuleStrategy.js"; +import { RegexRuleStrategy } from "../../src/strategies/RegexRuleStrategy.js"; + +describe("RuleCreationStrategyFactory tests", () => { + let factory: RuleCreationStrategyFactory; + + beforeEach(() => { + factory = new RuleCreationStrategyFactory(); + }); + + describe("constructor", () => { + it("should register default strategies on construction", () => { + expect(factory.getSupportedEngines()).toContain("pmd"); + expect(factory.getSupportedEngines()).toContain("regex"); + }); + + it("should return exactly 2 default strategies", () => { + expect(factory.getSupportedEngines()).toHaveLength(2); + }); + }); + + describe("createStrategy", () => { + it("should return XPathRuleStrategy for 'pmd' engine", () => { + const strategy = factory.createStrategy("pmd"); + + expect(strategy).toBeInstanceOf(XPathRuleStrategy); + expect(strategy.getSupportedEngine()).toBe("pmd"); + }); + + it("should return RegexRuleStrategy for 'regex' engine", () => { + const strategy = factory.createStrategy("regex"); + + expect(strategy).toBeInstanceOf(RegexRuleStrategy); + expect(strategy.getSupportedEngine()).toBe("regex"); + }); + + it("should be case-insensitive for engine names", () => { + const strategy1 = factory.createStrategy("PMD"); + const strategy2 = factory.createStrategy("Pmd"); + const strategy3 = factory.createStrategy("REGEX"); + const strategy4 = factory.createStrategy("Regex"); + + expect(strategy1).toBeInstanceOf(XPathRuleStrategy); + expect(strategy2).toBeInstanceOf(XPathRuleStrategy); + expect(strategy3).toBeInstanceOf(RegexRuleStrategy); + expect(strategy4).toBeInstanceOf(RegexRuleStrategy); + }); + + it("should trim whitespace from engine names", () => { + const strategy1 = factory.createStrategy(" pmd "); + const strategy2 = factory.createStrategy(" regex "); + + expect(strategy1).toBeInstanceOf(XPathRuleStrategy); + expect(strategy2).toBeInstanceOf(RegexRuleStrategy); + }); + + it("should throw error for unsupported engine", () => { + expect(() => factory.createStrategy("eslint")).toThrow(); + }); + + it("should include supported engines list in error message", () => { + try { + factory.createStrategy("eslint"); + expect.fail("Should have thrown error"); + } catch (error) { + expect((error as Error).message).toContain("eslint"); + expect((error as Error).message).toContain("Unsupported engine"); + expect((error as Error).message).toContain("pmd"); + expect((error as Error).message).toContain("regex"); + } + }); + + it("should throw error for empty string engine", () => { + expect(() => factory.createStrategy("")).toThrow(); + }); + + it("should throw error for whitespace-only engine", () => { + expect(() => factory.createStrategy(" ")).toThrow(); + }); + }); + + describe("getSupportedEngines", () => { + it("should return array of supported engine names", () => { + const engines = factory.getSupportedEngines(); + + expect(Array.isArray(engines)).toBe(true); + expect(engines).toContain("pmd"); + expect(engines).toContain("regex"); + }); + + it("should return engine names in lowercase", () => { + const engines = factory.getSupportedEngines(); + + engines.forEach(engine => { + expect(engine).toBe(engine.toLowerCase()); + }); + }); + }); + + describe("isEngineSupported", () => { + it("should return true for 'pmd' engine", () => { + expect(factory.isEngineSupported("pmd")).toBe(true); + }); + + it("should return true for 'regex' engine", () => { + expect(factory.isEngineSupported("regex")).toBe(true); + }); + + it("should return false for unsupported engine", () => { + expect(factory.isEngineSupported("eslint")).toBe(false); + }); + + it("should be case-insensitive", () => { + expect(factory.isEngineSupported("PMD")).toBe(true); + expect(factory.isEngineSupported("REGEX")).toBe(true); + expect(factory.isEngineSupported("Pmd")).toBe(true); + expect(factory.isEngineSupported("Regex")).toBe(true); + }); + + it("should trim whitespace", () => { + expect(factory.isEngineSupported(" pmd ")).toBe(true); + expect(factory.isEngineSupported(" regex ")).toBe(true); + }); + + it("should return false for empty string", () => { + expect(factory.isEngineSupported("")).toBe(false); + }); + + it("should return false for whitespace-only string", () => { + expect(factory.isEngineSupported(" ")).toBe(false); + }); + }); + + describe("registerStrategy", () => { + it("should allow registering a new strategy", () => { + class MockStrategy implements IRuleCreationStrategy { + getSupportedEngine(): string { + return "eslint"; + } + validate() { + return { isValid: true, errors: [] }; + } + async execute() { + return { status: "success" }; + } + } + + const mockStrategy = new MockStrategy(); + factory.registerStrategy(mockStrategy); + + expect(factory.getSupportedEngines()).toContain("eslint"); + expect(factory.isEngineSupported("eslint")).toBe(true); + + const strategy = factory.createStrategy("eslint"); + expect(strategy).toBe(mockStrategy); + }); + + it("should allow overriding an existing strategy", () => { + class CustomPmdStrategy implements IRuleCreationStrategy { + getSupportedEngine(): string { + return "pmd"; + } + validate() { + return { isValid: true, errors: [] }; + } + async execute() { + return { status: "custom-success" }; + } + } + + const customStrategy = new CustomPmdStrategy(); + factory.registerStrategy(customStrategy); + + const strategy = factory.createStrategy("pmd"); + expect(strategy).toBe(customStrategy); + }); + + it("should normalize engine name to lowercase when registering", () => { + class UpperCaseStrategy implements IRuleCreationStrategy { + getSupportedEngine(): string { + return "UPPERCASE"; + } + validate() { + return { isValid: true, errors: [] }; + } + async execute() { + return { status: "success" }; + } + } + + factory.registerStrategy(new UpperCaseStrategy()); + + // Should be able to access with lowercase + expect(factory.isEngineSupported("uppercase")).toBe(true); + expect(factory.getSupportedEngines()).toContain("uppercase"); + }); + }); +}); diff --git a/packages/mcp-provider-code-analyzer/test/strategies/XPathRuleStrategy.test.ts b/packages/mcp-provider-code-analyzer/test/strategies/XPathRuleStrategy.test.ts new file mode 100644 index 00000000..7d6f27e4 --- /dev/null +++ b/packages/mcp-provider-code-analyzer/test/strategies/XPathRuleStrategy.test.ts @@ -0,0 +1,324 @@ +import { describe, expect, it, beforeEach, afterEach } from "vitest"; +import { XPathRuleStrategy } from "../../src/strategies/XPathRuleStrategy.js"; +import { RuleCreationInput, ValidationResult, RuleCreationOutput } from "../../src/strategies/IRuleCreationStrategy.js"; +import * as fs from "node:fs/promises"; +import * as os from "node:os"; +import * as path from "node:path"; + +describe("XPathRuleStrategy tests", () => { + let strategy: XPathRuleStrategy; + let tempDir: string; + + beforeEach(async () => { + strategy = new XPathRuleStrategy(); + tempDir = await fs.mkdtemp(path.join(os.tmpdir(), "xpath-strategy-test-")); + }); + + afterEach(async () => { + await fs.rm(tempDir, { recursive: true, force: true }); + }); + + describe("getSupportedEngine", () => { + it("should return 'pmd'", () => { + expect(strategy.getSupportedEngine()).toBe("pmd"); + }); + }); + + describe("validate", () => { + it("should pass validation with all required fields", () => { + const input: RuleCreationInput = { + engine: "pmd", + ruleName: "NoEmptyIfStatements", + description: "Detects empty if statements", + workingDirectory: tempDir, + xpath: "//IfStatement[EmptyStatement]", + language: "apex", + priority: 3 + }; + + const result: ValidationResult = strategy.validate(input); + + expect(result.isValid).toBe(true); + expect(result.errors).toEqual([]); + }); + + it("should fail validation when xpath is missing", () => { + const input: RuleCreationInput = { + engine: "pmd", + ruleName: "NoEmptyIfStatements", + description: "Detects empty if statements", + workingDirectory: tempDir, + language: "apex", + priority: 3 + }; + + const result: ValidationResult = strategy.validate(input); + + expect(result.isValid).toBe(false); + expect(result.errors[0]).toContain("xpath is required for PMD engine"); + }); + + it("should fail validation when xpath is empty string", () => { + const input: RuleCreationInput = { + engine: "pmd", + ruleName: "NoEmptyIfStatements", + description: "Detects empty if statements", + workingDirectory: tempDir, + xpath: " ", + language: "apex", + priority: 3 + }; + + const result: ValidationResult = strategy.validate(input); + + expect(result.isValid).toBe(false); + expect(result.errors[0]).toContain("xpath is required for PMD engine"); + }); + + it("should provide Apex-specific guidance when xpath is missing for Apex", () => { + const input: RuleCreationInput = { + engine: "pmd", + ruleName: "NoEmptyIfStatements", + description: "Detects empty if statements", + workingDirectory: tempDir, + language: "apex", + priority: 3 + }; + + const result: ValidationResult = strategy.validate(input); + + expect(result.isValid).toBe(false); + expect(result.errors[0]).toContain("get_ast_nodes_to_generate_xpath"); + }); + + it("should provide Visualforce-specific guidance when xpath is missing for Visualforce", () => { + const input: RuleCreationInput = { + engine: "pmd", + ruleName: "NoEmptyIfStatements", + description: "Detects empty if statements", + workingDirectory: tempDir, + language: "visualforce", + priority: 3 + }; + + const result: ValidationResult = strategy.validate(input); + + expect(result.isValid).toBe(false); + expect(result.errors[0]).toContain("get_ast_nodes_to_generate_xpath"); + }); + + it("should provide generic guidance when xpath is missing for other languages", () => { + const input: RuleCreationInput = { + engine: "pmd", + ruleName: "NoEmptyIfStatements", + description: "Detects empty if statements", + workingDirectory: tempDir, + language: "java", + priority: 3 + }; + + const result: ValidationResult = strategy.validate(input); + + expect(result.isValid).toBe(false); + expect(result.errors[0]).toContain("valid XPath expression"); + expect(result.errors[0]).not.toContain("get_ast_nodes_to_generate_xpath"); + }); + + it("should fail validation when language is missing", () => { + const input: RuleCreationInput = { + engine: "pmd", + ruleName: "NoEmptyIfStatements", + description: "Detects empty if statements", + workingDirectory: tempDir, + xpath: "//IfStatement[EmptyStatement]", + priority: 3 + }; + + const result: ValidationResult = strategy.validate(input); + + expect(result.isValid).toBe(false); + expect(result.errors).toContain("language is required for PMD engine (e.g., 'apex', 'visualforce')"); + }); + + it("should fail validation when language is empty string", () => { + const input: RuleCreationInput = { + engine: "pmd", + ruleName: "NoEmptyIfStatements", + description: "Detects empty if statements", + workingDirectory: tempDir, + xpath: "//IfStatement[EmptyStatement]", + language: " ", + priority: 3 + }; + + const result: ValidationResult = strategy.validate(input); + + expect(result.isValid).toBe(false); + expect(result.errors).toContain("language is required for PMD engine (e.g., 'apex', 'visualforce')"); + }); + + it("should fail validation when priority is missing", () => { + const input: RuleCreationInput = { + engine: "pmd", + ruleName: "NoEmptyIfStatements", + description: "Detects empty if statements", + workingDirectory: tempDir, + xpath: "//IfStatement[EmptyStatement]", + language: "apex" + }; + + const result: ValidationResult = strategy.validate(input); + + expect(result.isValid).toBe(false); + expect(result.errors).toContain("priority is required for PMD engine (provide a value between 1 and 5)"); + }); + + it("should fail validation when priority is less than 1", () => { + const input: RuleCreationInput = { + engine: "pmd", + ruleName: "NoEmptyIfStatements", + description: "Detects empty if statements", + workingDirectory: tempDir, + xpath: "//IfStatement[EmptyStatement]", + language: "apex", + priority: 0 + }; + + const result: ValidationResult = strategy.validate(input); + + expect(result.isValid).toBe(false); + expect(result.errors).toContain("priority must be between 1 and 5"); + }); + + it("should fail validation when priority is greater than 5", () => { + const input: RuleCreationInput = { + engine: "pmd", + ruleName: "NoEmptyIfStatements", + description: "Detects empty if statements", + workingDirectory: tempDir, + xpath: "//IfStatement[EmptyStatement]", + language: "apex", + priority: 6 + }; + + const result: ValidationResult = strategy.validate(input); + + expect(result.isValid).toBe(false); + expect(result.errors).toContain("priority must be between 1 and 5"); + }); + + it("should return multiple errors when multiple fields are invalid", () => { + const input: RuleCreationInput = { + engine: "pmd", + ruleName: "NoEmptyIfStatements", + description: "Detects empty if statements", + workingDirectory: tempDir, + priority: 10 + }; + + const result: ValidationResult = strategy.validate(input); + + expect(result.isValid).toBe(false); + expect(result.errors.length).toBeGreaterThan(1); + }); + }); + + describe("execute", () => { + it("should create a PMD XPath rule successfully", async () => { + const input: RuleCreationInput = { + engine: "pmd", + ruleName: "NoEmptyIfStatements", + description: "Detects empty if statements", + workingDirectory: tempDir, + xpath: "//IfStatement[EmptyStatement]", + language: "apex", + priority: 3 + }; + + const result: RuleCreationOutput = await strategy.execute(input); + + expect(result.status).toBe("success"); + expect(result.rulesetPath).toBeDefined(); + expect(result.configPath).toBeDefined(); + expect(result.ruleXml).toBeDefined(); + expect(result.ruleXml).toContain("NoEmptyIfStatements"); + expect(result.ruleXml).toContain("//IfStatement[EmptyStatement]"); + expect(result.ruleXml).toContain("apex"); + expect(result.ruleXml).toContain("3"); + }); + + it("should create XML file in custom-rules directory", async () => { + const input: RuleCreationInput = { + engine: "pmd", + ruleName: "NoEmptyIfStatements", + description: "Detects empty if statements", + workingDirectory: tempDir, + xpath: "//IfStatement[EmptyStatement]", + language: "apex", + priority: 3 + }; + + const result: RuleCreationOutput = await strategy.execute(input); + + expect(result.status).toBe("success"); + expect(result.rulesetPath).toContain("custom-rules"); + expect(result.rulesetPath).toContain("pmd-rules.xml"); + + const xmlContent = await fs.readFile(result.rulesetPath!, "utf8"); + expect(xmlContent).toContain(''); + expect(xmlContent).toContain(" { + const input: RuleCreationInput = { + engine: "pmd", + ruleName: "NoEmptyIfStatements", + description: "Detects empty if statements", + workingDirectory: tempDir, + xpath: "//IfStatement[EmptyStatement]", + language: "apex", + priority: 3 + }; + + const result: RuleCreationOutput = await strategy.execute(input); + + expect(result.status).toBe("success"); + expect(result.configPath).toBeDefined(); + + const configContent = await fs.readFile(result.configPath!, "utf8"); + expect(configContent).toContain("engines:"); + expect(configContent).toContain("pmd:"); + expect(configContent).toContain("custom_rulesets:"); + }); + + it("should add ruleset reference to existing config file", async () => { + // Create initial config + const configPath = path.join(tempDir, "code-analyzer.yml"); + const initialConfig = `engines: + pmd: + custom_rulesets: + - "custom-rules/existing-pmd-rules.xml" +`; + await fs.writeFile(configPath, initialConfig, "utf8"); + + const input: RuleCreationInput = { + engine: "pmd", + ruleName: "NoEmptyIfStatements", + description: "Detects empty if statements", + workingDirectory: tempDir, + xpath: "//IfStatement[EmptyStatement]", + language: "apex", + priority: 3 + }; + + const result: RuleCreationOutput = await strategy.execute(input); + + expect(result.status).toBe("success"); + + const configContent = await fs.readFile(result.configPath!, "utf8"); + expect(configContent).toContain("existing-pmd-rules.xml"); + expect(configContent).toContain("noemptyifstatements-pmd-rules.xml"); + }); + }); +}); diff --git a/packages/mcp-provider-code-analyzer/test/tools/create_custom_rule.test.ts b/packages/mcp-provider-code-analyzer/test/tools/create_custom_rule.test.ts index 6a1332fa..6aaba52f 100644 --- a/packages/mcp-provider-code-analyzer/test/tools/create_custom_rule.test.ts +++ b/packages/mcp-provider-code-analyzer/test/tools/create_custom_rule.test.ts @@ -1,14 +1,43 @@ import { describe, expect, it, vi } from "vitest"; import { CreateCustomRuleMcpTool } from "../../src/tools/create_custom_rule.js"; +import { RuleCreationStrategyFactory } from "../../src/strategies/RuleCreationStrategyFactory.js"; +import { IRuleCreationStrategy, RuleCreationInput, RuleCreationOutput, ValidationResult } from "../../src/strategies/IRuleCreationStrategy.js"; -const actionExecMock = vi.fn(); +const strategyExecuteMock = vi.fn(); +const strategyValidateMock = vi.fn(); const telemetrySendMock = vi.fn(); +// Mock strategy +class MockStrategy implements IRuleCreationStrategy { + getSupportedEngine(): string { + return "pmd"; + } + + validate(_input: RuleCreationInput): ValidationResult { + return strategyValidateMock(); + } + + async execute(input: RuleCreationInput): Promise { + return strategyExecuteMock(input); + } +} + +// Mock factory +class MockStrategyFactory extends RuleCreationStrategyFactory { + private mockStrategy = new MockStrategy(); + + createStrategy(engine: string): IRuleCreationStrategy { + if (engine.toLowerCase() === "pmd" || engine.toLowerCase() === "regex" || engine.toLowerCase() === "eslint") { + return this.mockStrategy; + } + return super.createStrategy(engine); + } +} + function buildTool() { + const mockFactory = new MockStrategyFactory(); return new CreateCustomRuleMcpTool( - { - exec: actionExecMock - }, + mockFactory, { sendEvent: telemetrySendMock } @@ -40,7 +69,7 @@ describe("CreateCustomRuleMcpTool", () => { }); expect(result.structuredContent?.status).toContain("ruleName is required"); - expect(actionExecMock).not.toHaveBeenCalled(); + expect(strategyExecuteMock).not.toHaveBeenCalled(); }); it("returns validation error when description is missing", async () => { @@ -56,10 +85,15 @@ describe("CreateCustomRuleMcpTool", () => { }); expect(result.structuredContent?.status).toContain("description is required"); - expect(actionExecMock).not.toHaveBeenCalled(); + expect(strategyExecuteMock).not.toHaveBeenCalled(); }); - it("returns validation error when language is missing", async () => { + it("returns validation error when language is missing for PMD", async () => { + strategyValidateMock.mockReturnValueOnce({ + isValid: false, + errors: ["language is required for PMD engine (e.g., 'apex', 'visualforce')"] + }); + const tool = buildTool(); const result = await tool.exec({ xpath: "//MethodCallExpression", @@ -72,7 +106,7 @@ describe("CreateCustomRuleMcpTool", () => { }); expect(result.structuredContent?.status).toContain("language is required"); - expect(actionExecMock).not.toHaveBeenCalled(); + expect(strategyExecuteMock).not.toHaveBeenCalled(); }); it("returns validation error when engine is missing", async () => { @@ -88,10 +122,15 @@ describe("CreateCustomRuleMcpTool", () => { }); expect(result.structuredContent?.status).toContain("engine is required"); - expect(actionExecMock).not.toHaveBeenCalled(); + expect(strategyExecuteMock).not.toHaveBeenCalled(); }); it("returns validation error when xpath is missing for PMD", async () => { + strategyValidateMock.mockReturnValueOnce({ + isValid: false, + errors: ["xpath is required for PMD engine. For Apex and Visualforce, use tool 'get_ast_nodes_to_generate_xpath' to generate the XPath."] + }); + const tool = buildTool(); const result = await tool.exec({ xpath: " ", @@ -103,12 +142,16 @@ describe("CreateCustomRuleMcpTool", () => { workingDirectory: "/tmp" }); - expect(result.structuredContent?.status).toContain("xpath is required for engine 'pmd'"); - expect(actionExecMock).not.toHaveBeenCalled(); + expect(result.structuredContent?.status).toContain("xpath is required"); + expect(strategyExecuteMock).not.toHaveBeenCalled(); }); it("allows missing xpath for non-PMD engines", async () => { - actionExecMock.mockResolvedValueOnce({ + strategyValidateMock.mockReturnValueOnce({ + isValid: true, + errors: [] + }); + strategyExecuteMock.mockResolvedValueOnce({ status: "success", rulesetPath: "/tmp/custom.xml", configPath: "/tmp/code-analyzer.yml" @@ -125,10 +168,15 @@ describe("CreateCustomRuleMcpTool", () => { workingDirectory: "/tmp" }); - expect(actionExecMock).toHaveBeenCalledTimes(1); + expect(strategyExecuteMock).toHaveBeenCalledTimes(1); }); - it("returns validation error when priority is missing", async () => { + it("returns validation error when priority is missing for PMD", async () => { + strategyValidateMock.mockReturnValueOnce({ + isValid: false, + errors: ["priority is required for PMD engine (provide a value between 1 and 5)"] + }); + const tool = buildTool(); const result = await tool.exec({ xpath: "//MethodCallExpression", @@ -141,7 +189,7 @@ describe("CreateCustomRuleMcpTool", () => { } as unknown as Parameters[0]); expect(result.structuredContent?.status).toContain("priority is required"); - expect(actionExecMock).not.toHaveBeenCalled(); + expect(strategyExecuteMock).not.toHaveBeenCalled(); }); it("returns validation error when workingDirectory is missing", async () => { @@ -157,11 +205,15 @@ describe("CreateCustomRuleMcpTool", () => { }); expect(result.structuredContent?.status).toContain("workingDirectory is required"); - expect(actionExecMock).not.toHaveBeenCalled(); + expect(strategyExecuteMock).not.toHaveBeenCalled(); }); - it("delegates to action and emits telemetry on success", async () => { - actionExecMock.mockResolvedValueOnce({ + it("delegates to strategy and emits telemetry on success", async () => { + strategyValidateMock.mockReturnValueOnce({ + isValid: true, + errors: [] + }); + strategyExecuteMock.mockResolvedValueOnce({ status: "success", rulesetPath: "/tmp/custom.xml", configPath: "/tmp/code-analyzer.yml" @@ -178,13 +230,17 @@ describe("CreateCustomRuleMcpTool", () => { workingDirectory: "/tmp" }); - expect(actionExecMock).toHaveBeenCalledTimes(1); - expect(result.content?.[0]?.text).toContain("Custom rule created"); + expect(strategyExecuteMock).toHaveBeenCalledTimes(1); + expect(result.content?.[0]?.text).toContain("Custom PMD rule created successfully"); expect(telemetrySendMock).toHaveBeenCalledTimes(1); }); it("does not emit telemetry on failure", async () => { - actionExecMock.mockResolvedValueOnce({ + strategyValidateMock.mockReturnValueOnce({ + isValid: true, + errors: [] + }); + strategyExecuteMock.mockResolvedValueOnce({ status: "something failed" }); diff --git a/packages/mcp-provider-dx-core/test/e2e/tool-registration.test.ts b/packages/mcp-provider-dx-core/test/e2e/tool-registration.test.ts index 9e4d57df..bfa3e874 100644 --- a/packages/mcp-provider-dx-core/test/e2e/tool-registration.test.ts +++ b/packages/mcp-provider-dx-core/test/e2e/tool-registration.test.ts @@ -28,7 +28,18 @@ async function getMcpClient(opts: { args: string[] }) { args: opts.args, }); - await client.connect(transport); + // Add a connection timeout to fail fast if the server doesn't respond + // Increased to 90s for Windows compatibility + const connectionTimeout = new Promise((_, reject) => + setTimeout(() => reject(new Error('MCP client connection timeout')), 90000) + ); + + try { + await Promise.race([client.connect(transport), connectionTimeout]); + } catch (err) { + await client.close(); + throw err; + } return client; } @@ -78,7 +89,7 @@ describe('specific tool registration', function() { }); it('should enable 1 tool and a toolset', async function() { - this.timeout(60000); // Set 60 second timeout for this test + this.timeout(90000); // Increased to 90 seconds for Windows compatibility const client = await getMcpClient({ args: [ '--orgs',