From 72a3d1d1688972656c6d3967ca0b02a70e05073d Mon Sep 17 00:00:00 2001 From: Arun Tyagi Date: Tue, 14 Apr 2026 15:37:53 +0530 Subject: [PATCH 1/7] NEW: Implement Strategy Pattern for multi-engine custom rule creation - Created strategy pattern infrastructure for extensible rule creation - Added IRuleCreationStrategy interface defining strategy contract - Implemented XPathRuleStrategy for PMD/XPath rules - Implemented RegexRuleStrategy for regex pattern rules - Created RuleCreationStrategyFactory for engine-based strategy selection - Refactored create_custom_rule tool to use strategy pattern - Tool now supports both engine: "pmd" and engine: "regex" - Added CreateRegexCustomRuleActionImpl for regex rule creation - Created temporary create_regex_rule tool for testing (to be deleted) - Updated provider to use RuleCreationStrategyFactory Benefits: - Single entry point for multiple engines - Easy to extend with new engines - Engine-specific logic isolated in strategies - Maintains backward compatibility with existing PMD functionality Regex rules support: - Inline YAML rules in code-analyzer.yml - Required: regex, violationMessage, tags, severity - Optional: fileExtensions, regexIgnore, includeMetadata - Validates regex format, severity range, file extensions --- .../src/actions/create-regex-custom-rule.ts | 330 ++++++++++++++++++ .../src/provider.ts | 7 +- .../src/strategies/IRuleCreationStrategy.ts | 52 +++ .../src/strategies/RegexRuleStrategy.ts | 87 +++++ .../strategies/RuleCreationStrategyFactory.ts | 59 ++++ .../src/strategies/XPathRuleStrategy.ts | 75 ++++ .../src/tools/create_custom_rule.ts | 219 ++++++++---- .../src/tools/create_regex_rule.ts | 202 +++++++++++ .../actions/create-regex-custom-rule.test.ts | 260 ++++++++++++++ 9 files changed, 1218 insertions(+), 73 deletions(-) create mode 100644 packages/mcp-provider-code-analyzer/src/actions/create-regex-custom-rule.ts create mode 100644 packages/mcp-provider-code-analyzer/src/strategies/IRuleCreationStrategy.ts create mode 100644 packages/mcp-provider-code-analyzer/src/strategies/RegexRuleStrategy.ts create mode 100644 packages/mcp-provider-code-analyzer/src/strategies/RuleCreationStrategyFactory.ts create mode 100644 packages/mcp-provider-code-analyzer/src/strategies/XPathRuleStrategy.ts create mode 100644 packages/mcp-provider-code-analyzer/src/tools/create_regex_rule.ts create mode 100644 packages/mcp-provider-code-analyzer/test/actions/create-regex-custom-rule.test.ts 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..39e0babc 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"; 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"; export class CodeAnalyzerMcpProvider extends McpProvider { public getName(): string { @@ -40,7 +42,8 @@ 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()), + new CreateRegexRuleMcpTool(new CreateRegexCustomRuleActionImpl(), services.getTelemetryService()) ]); } } \ 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 f6d2ae8b..c8d20d7d 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 @@ -3,72 +3,96 @@ import { CallToolResult } from "@modelcontextprotocol/sdk/types.js"; import { McpTool, McpToolConfig, ReleaseState, Toolset, TelemetryService } from "@salesforce/mcp-provider-api"; import * as Constants from "../constants.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; } @@ -91,7 +115,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, @@ -118,6 +184,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."); @@ -134,30 +204,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(); @@ -168,6 +220,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 { @@ -176,3 +246,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/src/tools/create_regex_rule.ts b/packages/mcp-provider-code-analyzer/src/tools/create_regex_rule.ts new file mode 100644 index 00000000..55dd0b94 --- /dev/null +++ b/packages/mcp-provider-code-analyzer/src/tools/create_regex_rule.ts @@ -0,0 +1,202 @@ +import { z } from "zod"; +import { CallToolResult } from "@modelcontextprotocol/sdk/types.js"; +import { McpTool, McpToolConfig, ReleaseState, Toolset, TelemetryService } from "@salesforce/mcp-provider-api"; +import * as Constants from "../constants.js"; +import { + CreateRegexCustomRuleAction, + CreateRegexCustomRuleActionImpl, + CreateRegexCustomRuleInput, + CreateRegexCustomRuleOutput +} from "../actions/create-regex-custom-rule.js"; + +// TEMPORARY TOOL FOR TESTING REGEX RULE CREATION - WILL BE DELETED LATER +const DESCRIPTION: string = + `TEMPORARY TOOL: Create a custom regex rule for pattern matching. + +This tool adds regex-based custom rules directly to code-analyzer.yml. + +Required inputs: +- regex: The regex pattern as string (e.g., "/todo/gi", "/[a-zA-Z0-9]{15,18}/g") +- 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 code-analyzer.yml will be updated + +Optional inputs: +- 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: +- configPath: Path to the updated code-analyzer.yml +- ruleYaml: The generated YAML for the rule`; + +export const inputSchema = z.object({ + regex: z.string().describe("Regex pattern as string (e.g., '/todo/gi'). Must be in format '/pattern/flags'."), + ruleName: z.string().describe("Name for the custom rule."), + description: z.string().describe("Short description of what the rule checks."), + violationMessage: z.string().describe("Message shown when violation is found."), + tags: z.array(z.string()).describe("Array of tags (e.g., ['Security', 'BestPractices'])."), + severity: z.number().int().min(1).max(5).describe("Severity level: 1=Critical, 2=High, 3=Moderate, 4=Low, 5=Info."), + workingDirectory: z.string().describe("Workspace directory where code-analyzer.yml will be created/updated."), + fileExtensions: z.array(z.string()).optional().describe("Optional array of file extensions (e.g., ['.cls', '.trigger'])."), + regexIgnore: z.string().optional().describe("Optional regex pattern to exclude matches (e.g., '/^test/i')."), + includeMetadata: z.boolean().optional().describe("Optional boolean flag for metadata inclusion.") +}); +type InputArgsShape = typeof inputSchema.shape; + +const outputSchema = z.object({ + status: z.string().describe("'success' or an error message."), + ruleYaml: z.string().optional().describe("Generated YAML for the regex rule."), + configPath: z.string().optional().describe("Path to the updated code-analyzer.yml.") +}); +type OutputArgsShape = typeof outputSchema.shape; + +export class CreateRegexRuleMcpTool extends McpTool { + public static readonly NAME: string = "create_regex_rule"; + private readonly action: CreateRegexCustomRuleAction; + private readonly telemetryService?: TelemetryService; + + public constructor( + action: CreateRegexCustomRuleAction = new CreateRegexCustomRuleActionImpl(), + telemetryService?: TelemetryService + ) { + super(); + this.action = action; + this.telemetryService = telemetryService; + } + + public getReleaseState(): ReleaseState { + return ReleaseState.NON_GA; + } + + public getToolsets(): Toolset[] { + return [Toolset.CODE_ANALYSIS]; + } + + public getName(): string { + return CreateRegexRuleMcpTool.NAME; + } + + public getConfig(): McpToolConfig { + return { + title: "Create Regex Rule (TEMPORARY)", + description: DESCRIPTION, + inputSchema: inputSchema.shape, + outputSchema: outputSchema.shape, + annotations: { + readOnlyHint: false, // Writes to code-analyzer.yml + destructiveHint: false, // Does not delete anything + openWorldHint: false, // Local file operations only + }, + }; + } + + public async exec(input: z.infer): Promise { + const validationError = validateInput(input); + if (validationError) { + return validationError; + } + + 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" + }; + + const output: CreateRegexCustomRuleOutput = await this.action.exec(actionInput); + + const message = output.configPath + ? `Regex rule created successfully. Config: ${output.configPath}.` + : output.status; + + if (this.telemetryService && output.status === "success") { + this.telemetryService.sendEvent(Constants.TelemetryEventName, { + source: Constants.TelemetrySource, + sfcaEvent: Constants.McpTelemetryEvents.CUSTOM_RULE_CREATED, + engine: "regex", + ruleName: input.ruleName, + configPath: output.configPath + }); + } + + return { + content: [{ type: "text", text: message }], + structuredContent: output, + isError: output.status !== "success" + }; + } +} + +function validateInput(input: z.infer): CallToolResult | undefined { + const ruleName = input.ruleName?.trim(); + if (!ruleName) { + return buildError("ruleName is required. Provide a name for the custom rule."); + } + + const regex = input.regex?.trim(); + if (!regex) { + return buildError("regex is required. Provide a regex pattern like '/todo/gi'."); + } + + if (!regex.startsWith("/") || regex.lastIndexOf("/") <= 0) { + return buildError("regex must be in format '/pattern/flags' (e.g., '/todo/gi' or '/[0-9]+/g')."); + } + + const description = input.description?.trim(); + if (!description) { + return buildError("description is required. Provide a short description of what the rule checks."); + } + + const violationMessage = input.violationMessage?.trim(); + if (!violationMessage) { + return buildError("violationMessage is required. Provide a message shown when violation is found."); + } + + if (!input.tags || input.tags.length === 0) { + return buildError("tags is required. Provide at least one tag (e.g., ['Custom'])."); + } + + if (input.severity === undefined || input.severity === null) { + return buildError("severity is required. Provide a value between 1 (Critical) and 5 (Info)."); + } + + if (input.severity < 1 || input.severity > 5) { + return buildError("severity must be between 1 (Critical) and 5 (Info)."); + } + + const workingDirectory = input.workingDirectory?.trim(); + if (!workingDirectory) { + return buildError("workingDirectory is required. Provide a directory where files can be written."); + } + + // Validate file extensions if provided + if (input.fileExtensions && input.fileExtensions.length > 0) { + for (const ext of input.fileExtensions) { + if (!ext.startsWith(".")) { + return buildError(`file extension must start with dot: '${ext}'. Use '.cls' not 'cls'.`); + } + } + } + + return undefined; +} + +function buildError(status: string): CallToolResult { + const output = { status }; + return { + content: [{ type: "text", text: JSON.stringify(output) }], + structuredContent: output, + isError: true + }; +} 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:"); + }); +}); From a9bea42b18b1532b7e123dbf4e48c3f01c72e926 Mon Sep 17 00:00:00 2001 From: Arun Tyagi Date: Tue, 14 Apr 2026 17:03:31 +0530 Subject: [PATCH 2/7] FIX: Update tests for strategy pattern implementation - Refactored create_custom_rule.test.ts to use strategy mocks - Updated provider.test.ts to expect 7 tools (added temporary create_regex_rule) - All 228 tests passing - Coverage: 89.05% statements, 76.76% branches --- .../test/provider.test.ts | 4 +- .../test/tools/create_custom_rule.test.ts | 98 +++++++++++++++---- 2 files changed, 80 insertions(+), 22 deletions(-) diff --git a/packages/mcp-provider-code-analyzer/test/provider.test.ts b/packages/mcp-provider-code-analyzer/test/provider.test.ts index 2a79b493..7db95ed3 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"; +import { CreateRegexRuleMcpTool } from "../src/tools/create_regex_rule.js"; describe("Tests for CodeAnalyzerMcpProvider", () => { let services: Services; @@ -23,12 +24,13 @@ describe("Tests for CodeAnalyzerMcpProvider", () => { it("When provideTools is called, then the returned array contains an CodeAnalyzerRunMcpTool instance", async () => { const tools: McpTool[] = await provider.provideTools(services); - expect(tools).toHaveLength(6); + expect(tools).toHaveLength(7); expect(tools[0]).toBeInstanceOf(CodeAnalyzerRunMcpTool); expect(tools[1]).toBeInstanceOf(CodeAnalyzerDescribeRuleMcpTool); expect(tools[2]).toBeInstanceOf(CodeAnalyzerListRulesMcpTool); expect(tools[3]).toBeInstanceOf(CodeAnalyzerQueryResultsMcpTool); expect(tools[4]).toBeInstanceOf(GenerateXpathPromptMcpTool); expect(tools[5]).toBeInstanceOf(CreateCustomRuleMcpTool); + expect(tools[6]).toBeInstanceOf(CreateRegexRuleMcpTool); }); }) \ No newline at end of file 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" }); From 2ecca2c7843a21c76bb6fcbd4a3be8338b284f37 Mon Sep 17 00:00:00 2001 From: Arun Tyagi Date: Tue, 14 Apr 2026 17:19:42 +0530 Subject: [PATCH 3/7] FIX: Remove create_regex_rule from provider registration to fix E2E tests - Commented out create_regex_rule tool registration in provider - Tool code kept in codebase for reference only - Users can test regex rule creation via create_custom_rule with engine: 'regex' - Updated provider test to expect 6 tools instead of 7 - All 228 tests passing - E2E tests should now pass (9 tools expected) --- packages/mcp-provider-code-analyzer/src/provider.ts | 9 +++++---- .../mcp-provider-code-analyzer/test/provider.test.ts | 6 +++--- 2 files changed, 8 insertions(+), 7 deletions(-) diff --git a/packages/mcp-provider-code-analyzer/src/provider.ts b/packages/mcp-provider-code-analyzer/src/provider.ts index 39e0babc..9d4a7e56 100644 --- a/packages/mcp-provider-code-analyzer/src/provider.ts +++ b/packages/mcp-provider-code-analyzer/src/provider.ts @@ -11,10 +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"; +// import { CreateRegexRuleMcpTool } from "./tools/create_regex_rule.js"; // Temporary tool - not registered import { GetAstNodesActionImpl } from "./actions/get-ast-nodes.js"; import { RuleCreationStrategyFactory } from "./strategies/RuleCreationStrategyFactory.js"; -import { CreateRegexCustomRuleActionImpl } from "./actions/create-regex-custom-rule.js"; +// import { CreateRegexCustomRuleActionImpl } from "./actions/create-regex-custom-rule.js"; // Used by strategy export class CodeAnalyzerMcpProvider extends McpProvider { public getName(): string { @@ -42,8 +42,9 @@ export class CodeAnalyzerMcpProvider extends McpProvider { })), new CodeAnalyzerQueryResultsMcpTool(new QueryResultsActionImpl(), services.getTelemetryService()), new GenerateXpathPromptMcpTool(new GetAstNodesActionImpl(), services.getTelemetryService()), - new CreateCustomRuleMcpTool(new RuleCreationStrategyFactory(), services.getTelemetryService()), - new CreateRegexRuleMcpTool(new CreateRegexCustomRuleActionImpl(), 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/test/provider.test.ts b/packages/mcp-provider-code-analyzer/test/provider.test.ts index 7db95ed3..fcc59335 100644 --- a/packages/mcp-provider-code-analyzer/test/provider.test.ts +++ b/packages/mcp-provider-code-analyzer/test/provider.test.ts @@ -7,7 +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"; -import { CreateRegexRuleMcpTool } from "../src/tools/create_regex_rule.js"; +// CreateRegexRuleMcpTool - Not registered in provider describe("Tests for CodeAnalyzerMcpProvider", () => { let services: Services; @@ -24,13 +24,13 @@ describe("Tests for CodeAnalyzerMcpProvider", () => { it("When provideTools is called, then the returned array contains an CodeAnalyzerRunMcpTool instance", async () => { const tools: McpTool[] = await provider.provideTools(services); - expect(tools).toHaveLength(7); + expect(tools).toHaveLength(6); expect(tools[0]).toBeInstanceOf(CodeAnalyzerRunMcpTool); expect(tools[1]).toBeInstanceOf(CodeAnalyzerDescribeRuleMcpTool); expect(tools[2]).toBeInstanceOf(CodeAnalyzerListRulesMcpTool); expect(tools[3]).toBeInstanceOf(CodeAnalyzerQueryResultsMcpTool); expect(tools[4]).toBeInstanceOf(GenerateXpathPromptMcpTool); expect(tools[5]).toBeInstanceOf(CreateCustomRuleMcpTool); - expect(tools[6]).toBeInstanceOf(CreateRegexRuleMcpTool); + // Note: create_regex_rule is NOT registered - test via create_custom_rule with engine: "regex" }); }) \ No newline at end of file From 155eb29198d9e58676e8ae510d6b364472c3499d Mon Sep 17 00:00:00 2001 From: Arun Tyagi Date: Thu, 16 Apr 2026 15:31:49 +0530 Subject: [PATCH 4/7] TEST: Add comprehensive unit tests for strategy pattern MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Added RegexRuleStrategy.test.ts with 26 tests - Added XPathRuleStrategy.test.ts with 20 tests - Added RuleCreationStrategyFactory.test.ts with 13 tests - Total: 59 new tests added - All 287 tests passing Coverage improvements: - RegexRuleStrategy: 8.69% → 100% - XPathRuleStrategy: 11.11% → 100% - RuleCreationStrategyFactory: 38.46% → 100% - Overall: 89.05% → 97.37% Test coverage includes: - Validation logic for all required/optional fields - Error handling for invalid inputs - Execute functionality for rule creation - File system operations (config creation/updates) - Factory pattern (strategy selection and registration) - Edge cases (case sensitivity, whitespace handling) --- .../src/tools/create_regex_rule.ts | 202 --------- .../test/strategies/RegexRuleStrategy.test.ts | 410 ++++++++++++++++++ .../RuleCreationStrategyFactory.test.ts | 201 +++++++++ .../test/strategies/XPathRuleStrategy.test.ts | 324 ++++++++++++++ 4 files changed, 935 insertions(+), 202 deletions(-) delete mode 100644 packages/mcp-provider-code-analyzer/src/tools/create_regex_rule.ts create mode 100644 packages/mcp-provider-code-analyzer/test/strategies/RegexRuleStrategy.test.ts create mode 100644 packages/mcp-provider-code-analyzer/test/strategies/RuleCreationStrategyFactory.test.ts create mode 100644 packages/mcp-provider-code-analyzer/test/strategies/XPathRuleStrategy.test.ts diff --git a/packages/mcp-provider-code-analyzer/src/tools/create_regex_rule.ts b/packages/mcp-provider-code-analyzer/src/tools/create_regex_rule.ts deleted file mode 100644 index 55dd0b94..00000000 --- a/packages/mcp-provider-code-analyzer/src/tools/create_regex_rule.ts +++ /dev/null @@ -1,202 +0,0 @@ -import { z } from "zod"; -import { CallToolResult } from "@modelcontextprotocol/sdk/types.js"; -import { McpTool, McpToolConfig, ReleaseState, Toolset, TelemetryService } from "@salesforce/mcp-provider-api"; -import * as Constants from "../constants.js"; -import { - CreateRegexCustomRuleAction, - CreateRegexCustomRuleActionImpl, - CreateRegexCustomRuleInput, - CreateRegexCustomRuleOutput -} from "../actions/create-regex-custom-rule.js"; - -// TEMPORARY TOOL FOR TESTING REGEX RULE CREATION - WILL BE DELETED LATER -const DESCRIPTION: string = - `TEMPORARY TOOL: Create a custom regex rule for pattern matching. - -This tool adds regex-based custom rules directly to code-analyzer.yml. - -Required inputs: -- regex: The regex pattern as string (e.g., "/todo/gi", "/[a-zA-Z0-9]{15,18}/g") -- 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 code-analyzer.yml will be updated - -Optional inputs: -- 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: -- configPath: Path to the updated code-analyzer.yml -- ruleYaml: The generated YAML for the rule`; - -export const inputSchema = z.object({ - regex: z.string().describe("Regex pattern as string (e.g., '/todo/gi'). Must be in format '/pattern/flags'."), - ruleName: z.string().describe("Name for the custom rule."), - description: z.string().describe("Short description of what the rule checks."), - violationMessage: z.string().describe("Message shown when violation is found."), - tags: z.array(z.string()).describe("Array of tags (e.g., ['Security', 'BestPractices'])."), - severity: z.number().int().min(1).max(5).describe("Severity level: 1=Critical, 2=High, 3=Moderate, 4=Low, 5=Info."), - workingDirectory: z.string().describe("Workspace directory where code-analyzer.yml will be created/updated."), - fileExtensions: z.array(z.string()).optional().describe("Optional array of file extensions (e.g., ['.cls', '.trigger'])."), - regexIgnore: z.string().optional().describe("Optional regex pattern to exclude matches (e.g., '/^test/i')."), - includeMetadata: z.boolean().optional().describe("Optional boolean flag for metadata inclusion.") -}); -type InputArgsShape = typeof inputSchema.shape; - -const outputSchema = z.object({ - status: z.string().describe("'success' or an error message."), - ruleYaml: z.string().optional().describe("Generated YAML for the regex rule."), - configPath: z.string().optional().describe("Path to the updated code-analyzer.yml.") -}); -type OutputArgsShape = typeof outputSchema.shape; - -export class CreateRegexRuleMcpTool extends McpTool { - public static readonly NAME: string = "create_regex_rule"; - private readonly action: CreateRegexCustomRuleAction; - private readonly telemetryService?: TelemetryService; - - public constructor( - action: CreateRegexCustomRuleAction = new CreateRegexCustomRuleActionImpl(), - telemetryService?: TelemetryService - ) { - super(); - this.action = action; - this.telemetryService = telemetryService; - } - - public getReleaseState(): ReleaseState { - return ReleaseState.NON_GA; - } - - public getToolsets(): Toolset[] { - return [Toolset.CODE_ANALYSIS]; - } - - public getName(): string { - return CreateRegexRuleMcpTool.NAME; - } - - public getConfig(): McpToolConfig { - return { - title: "Create Regex Rule (TEMPORARY)", - description: DESCRIPTION, - inputSchema: inputSchema.shape, - outputSchema: outputSchema.shape, - annotations: { - readOnlyHint: false, // Writes to code-analyzer.yml - destructiveHint: false, // Does not delete anything - openWorldHint: false, // Local file operations only - }, - }; - } - - public async exec(input: z.infer): Promise { - const validationError = validateInput(input); - if (validationError) { - return validationError; - } - - 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" - }; - - const output: CreateRegexCustomRuleOutput = await this.action.exec(actionInput); - - const message = output.configPath - ? `Regex rule created successfully. Config: ${output.configPath}.` - : output.status; - - if (this.telemetryService && output.status === "success") { - this.telemetryService.sendEvent(Constants.TelemetryEventName, { - source: Constants.TelemetrySource, - sfcaEvent: Constants.McpTelemetryEvents.CUSTOM_RULE_CREATED, - engine: "regex", - ruleName: input.ruleName, - configPath: output.configPath - }); - } - - return { - content: [{ type: "text", text: message }], - structuredContent: output, - isError: output.status !== "success" - }; - } -} - -function validateInput(input: z.infer): CallToolResult | undefined { - const ruleName = input.ruleName?.trim(); - if (!ruleName) { - return buildError("ruleName is required. Provide a name for the custom rule."); - } - - const regex = input.regex?.trim(); - if (!regex) { - return buildError("regex is required. Provide a regex pattern like '/todo/gi'."); - } - - if (!regex.startsWith("/") || regex.lastIndexOf("/") <= 0) { - return buildError("regex must be in format '/pattern/flags' (e.g., '/todo/gi' or '/[0-9]+/g')."); - } - - const description = input.description?.trim(); - if (!description) { - return buildError("description is required. Provide a short description of what the rule checks."); - } - - const violationMessage = input.violationMessage?.trim(); - if (!violationMessage) { - return buildError("violationMessage is required. Provide a message shown when violation is found."); - } - - if (!input.tags || input.tags.length === 0) { - return buildError("tags is required. Provide at least one tag (e.g., ['Custom'])."); - } - - if (input.severity === undefined || input.severity === null) { - return buildError("severity is required. Provide a value between 1 (Critical) and 5 (Info)."); - } - - if (input.severity < 1 || input.severity > 5) { - return buildError("severity must be between 1 (Critical) and 5 (Info)."); - } - - const workingDirectory = input.workingDirectory?.trim(); - if (!workingDirectory) { - return buildError("workingDirectory is required. Provide a directory where files can be written."); - } - - // Validate file extensions if provided - if (input.fileExtensions && input.fileExtensions.length > 0) { - for (const ext of input.fileExtensions) { - if (!ext.startsWith(".")) { - return buildError(`file extension must start with dot: '${ext}'. Use '.cls' not 'cls'.`); - } - } - } - - return undefined; -} - -function buildError(status: string): CallToolResult { - const output = { status }; - return { - content: [{ type: "text", text: JSON.stringify(output) }], - structuredContent: output, - isError: true - }; -} 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"); + }); + }); +}); From fc68bfc82e158c45a016ba9398e223cd00dea61c Mon Sep 17 00:00:00 2001 From: Arun Tyagi Date: Thu, 16 Apr 2026 15:32:38 +0530 Subject: [PATCH 5/7] RESTORE: Keep create_regex_rule.ts for reference - Restored create_regex_rule.ts that was accidentally deleted - File kept for manual testing reference only (not registered in provider) --- .../src/tools/create_regex_rule.ts | 202 ++++++++++++++++++ 1 file changed, 202 insertions(+) create mode 100644 packages/mcp-provider-code-analyzer/src/tools/create_regex_rule.ts diff --git a/packages/mcp-provider-code-analyzer/src/tools/create_regex_rule.ts b/packages/mcp-provider-code-analyzer/src/tools/create_regex_rule.ts new file mode 100644 index 00000000..55dd0b94 --- /dev/null +++ b/packages/mcp-provider-code-analyzer/src/tools/create_regex_rule.ts @@ -0,0 +1,202 @@ +import { z } from "zod"; +import { CallToolResult } from "@modelcontextprotocol/sdk/types.js"; +import { McpTool, McpToolConfig, ReleaseState, Toolset, TelemetryService } from "@salesforce/mcp-provider-api"; +import * as Constants from "../constants.js"; +import { + CreateRegexCustomRuleAction, + CreateRegexCustomRuleActionImpl, + CreateRegexCustomRuleInput, + CreateRegexCustomRuleOutput +} from "../actions/create-regex-custom-rule.js"; + +// TEMPORARY TOOL FOR TESTING REGEX RULE CREATION - WILL BE DELETED LATER +const DESCRIPTION: string = + `TEMPORARY TOOL: Create a custom regex rule for pattern matching. + +This tool adds regex-based custom rules directly to code-analyzer.yml. + +Required inputs: +- regex: The regex pattern as string (e.g., "/todo/gi", "/[a-zA-Z0-9]{15,18}/g") +- 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 code-analyzer.yml will be updated + +Optional inputs: +- 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: +- configPath: Path to the updated code-analyzer.yml +- ruleYaml: The generated YAML for the rule`; + +export const inputSchema = z.object({ + regex: z.string().describe("Regex pattern as string (e.g., '/todo/gi'). Must be in format '/pattern/flags'."), + ruleName: z.string().describe("Name for the custom rule."), + description: z.string().describe("Short description of what the rule checks."), + violationMessage: z.string().describe("Message shown when violation is found."), + tags: z.array(z.string()).describe("Array of tags (e.g., ['Security', 'BestPractices'])."), + severity: z.number().int().min(1).max(5).describe("Severity level: 1=Critical, 2=High, 3=Moderate, 4=Low, 5=Info."), + workingDirectory: z.string().describe("Workspace directory where code-analyzer.yml will be created/updated."), + fileExtensions: z.array(z.string()).optional().describe("Optional array of file extensions (e.g., ['.cls', '.trigger'])."), + regexIgnore: z.string().optional().describe("Optional regex pattern to exclude matches (e.g., '/^test/i')."), + includeMetadata: z.boolean().optional().describe("Optional boolean flag for metadata inclusion.") +}); +type InputArgsShape = typeof inputSchema.shape; + +const outputSchema = z.object({ + status: z.string().describe("'success' or an error message."), + ruleYaml: z.string().optional().describe("Generated YAML for the regex rule."), + configPath: z.string().optional().describe("Path to the updated code-analyzer.yml.") +}); +type OutputArgsShape = typeof outputSchema.shape; + +export class CreateRegexRuleMcpTool extends McpTool { + public static readonly NAME: string = "create_regex_rule"; + private readonly action: CreateRegexCustomRuleAction; + private readonly telemetryService?: TelemetryService; + + public constructor( + action: CreateRegexCustomRuleAction = new CreateRegexCustomRuleActionImpl(), + telemetryService?: TelemetryService + ) { + super(); + this.action = action; + this.telemetryService = telemetryService; + } + + public getReleaseState(): ReleaseState { + return ReleaseState.NON_GA; + } + + public getToolsets(): Toolset[] { + return [Toolset.CODE_ANALYSIS]; + } + + public getName(): string { + return CreateRegexRuleMcpTool.NAME; + } + + public getConfig(): McpToolConfig { + return { + title: "Create Regex Rule (TEMPORARY)", + description: DESCRIPTION, + inputSchema: inputSchema.shape, + outputSchema: outputSchema.shape, + annotations: { + readOnlyHint: false, // Writes to code-analyzer.yml + destructiveHint: false, // Does not delete anything + openWorldHint: false, // Local file operations only + }, + }; + } + + public async exec(input: z.infer): Promise { + const validationError = validateInput(input); + if (validationError) { + return validationError; + } + + 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" + }; + + const output: CreateRegexCustomRuleOutput = await this.action.exec(actionInput); + + const message = output.configPath + ? `Regex rule created successfully. Config: ${output.configPath}.` + : output.status; + + if (this.telemetryService && output.status === "success") { + this.telemetryService.sendEvent(Constants.TelemetryEventName, { + source: Constants.TelemetrySource, + sfcaEvent: Constants.McpTelemetryEvents.CUSTOM_RULE_CREATED, + engine: "regex", + ruleName: input.ruleName, + configPath: output.configPath + }); + } + + return { + content: [{ type: "text", text: message }], + structuredContent: output, + isError: output.status !== "success" + }; + } +} + +function validateInput(input: z.infer): CallToolResult | undefined { + const ruleName = input.ruleName?.trim(); + if (!ruleName) { + return buildError("ruleName is required. Provide a name for the custom rule."); + } + + const regex = input.regex?.trim(); + if (!regex) { + return buildError("regex is required. Provide a regex pattern like '/todo/gi'."); + } + + if (!regex.startsWith("/") || regex.lastIndexOf("/") <= 0) { + return buildError("regex must be in format '/pattern/flags' (e.g., '/todo/gi' or '/[0-9]+/g')."); + } + + const description = input.description?.trim(); + if (!description) { + return buildError("description is required. Provide a short description of what the rule checks."); + } + + const violationMessage = input.violationMessage?.trim(); + if (!violationMessage) { + return buildError("violationMessage is required. Provide a message shown when violation is found."); + } + + if (!input.tags || input.tags.length === 0) { + return buildError("tags is required. Provide at least one tag (e.g., ['Custom'])."); + } + + if (input.severity === undefined || input.severity === null) { + return buildError("severity is required. Provide a value between 1 (Critical) and 5 (Info)."); + } + + if (input.severity < 1 || input.severity > 5) { + return buildError("severity must be between 1 (Critical) and 5 (Info)."); + } + + const workingDirectory = input.workingDirectory?.trim(); + if (!workingDirectory) { + return buildError("workingDirectory is required. Provide a directory where files can be written."); + } + + // Validate file extensions if provided + if (input.fileExtensions && input.fileExtensions.length > 0) { + for (const ext of input.fileExtensions) { + if (!ext.startsWith(".")) { + return buildError(`file extension must start with dot: '${ext}'. Use '.cls' not 'cls'.`); + } + } + } + + return undefined; +} + +function buildError(status: string): CallToolResult { + const output = { status }; + return { + content: [{ type: "text", text: JSON.stringify(output) }], + structuredContent: output, + isError: true + }; +} From 9f270027f83f66c7680d48df6f13cb2a60b420ed Mon Sep 17 00:00:00 2001 From: Arun Tyagi Date: Thu, 16 Apr 2026 15:34:29 +0530 Subject: [PATCH 6/7] remove regex rule creation tool --- .../src/tools/create_regex_rule.ts | 202 ------------------ 1 file changed, 202 deletions(-) delete mode 100644 packages/mcp-provider-code-analyzer/src/tools/create_regex_rule.ts diff --git a/packages/mcp-provider-code-analyzer/src/tools/create_regex_rule.ts b/packages/mcp-provider-code-analyzer/src/tools/create_regex_rule.ts deleted file mode 100644 index 55dd0b94..00000000 --- a/packages/mcp-provider-code-analyzer/src/tools/create_regex_rule.ts +++ /dev/null @@ -1,202 +0,0 @@ -import { z } from "zod"; -import { CallToolResult } from "@modelcontextprotocol/sdk/types.js"; -import { McpTool, McpToolConfig, ReleaseState, Toolset, TelemetryService } from "@salesforce/mcp-provider-api"; -import * as Constants from "../constants.js"; -import { - CreateRegexCustomRuleAction, - CreateRegexCustomRuleActionImpl, - CreateRegexCustomRuleInput, - CreateRegexCustomRuleOutput -} from "../actions/create-regex-custom-rule.js"; - -// TEMPORARY TOOL FOR TESTING REGEX RULE CREATION - WILL BE DELETED LATER -const DESCRIPTION: string = - `TEMPORARY TOOL: Create a custom regex rule for pattern matching. - -This tool adds regex-based custom rules directly to code-analyzer.yml. - -Required inputs: -- regex: The regex pattern as string (e.g., "/todo/gi", "/[a-zA-Z0-9]{15,18}/g") -- 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 code-analyzer.yml will be updated - -Optional inputs: -- 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: -- configPath: Path to the updated code-analyzer.yml -- ruleYaml: The generated YAML for the rule`; - -export const inputSchema = z.object({ - regex: z.string().describe("Regex pattern as string (e.g., '/todo/gi'). Must be in format '/pattern/flags'."), - ruleName: z.string().describe("Name for the custom rule."), - description: z.string().describe("Short description of what the rule checks."), - violationMessage: z.string().describe("Message shown when violation is found."), - tags: z.array(z.string()).describe("Array of tags (e.g., ['Security', 'BestPractices'])."), - severity: z.number().int().min(1).max(5).describe("Severity level: 1=Critical, 2=High, 3=Moderate, 4=Low, 5=Info."), - workingDirectory: z.string().describe("Workspace directory where code-analyzer.yml will be created/updated."), - fileExtensions: z.array(z.string()).optional().describe("Optional array of file extensions (e.g., ['.cls', '.trigger'])."), - regexIgnore: z.string().optional().describe("Optional regex pattern to exclude matches (e.g., '/^test/i')."), - includeMetadata: z.boolean().optional().describe("Optional boolean flag for metadata inclusion.") -}); -type InputArgsShape = typeof inputSchema.shape; - -const outputSchema = z.object({ - status: z.string().describe("'success' or an error message."), - ruleYaml: z.string().optional().describe("Generated YAML for the regex rule."), - configPath: z.string().optional().describe("Path to the updated code-analyzer.yml.") -}); -type OutputArgsShape = typeof outputSchema.shape; - -export class CreateRegexRuleMcpTool extends McpTool { - public static readonly NAME: string = "create_regex_rule"; - private readonly action: CreateRegexCustomRuleAction; - private readonly telemetryService?: TelemetryService; - - public constructor( - action: CreateRegexCustomRuleAction = new CreateRegexCustomRuleActionImpl(), - telemetryService?: TelemetryService - ) { - super(); - this.action = action; - this.telemetryService = telemetryService; - } - - public getReleaseState(): ReleaseState { - return ReleaseState.NON_GA; - } - - public getToolsets(): Toolset[] { - return [Toolset.CODE_ANALYSIS]; - } - - public getName(): string { - return CreateRegexRuleMcpTool.NAME; - } - - public getConfig(): McpToolConfig { - return { - title: "Create Regex Rule (TEMPORARY)", - description: DESCRIPTION, - inputSchema: inputSchema.shape, - outputSchema: outputSchema.shape, - annotations: { - readOnlyHint: false, // Writes to code-analyzer.yml - destructiveHint: false, // Does not delete anything - openWorldHint: false, // Local file operations only - }, - }; - } - - public async exec(input: z.infer): Promise { - const validationError = validateInput(input); - if (validationError) { - return validationError; - } - - 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" - }; - - const output: CreateRegexCustomRuleOutput = await this.action.exec(actionInput); - - const message = output.configPath - ? `Regex rule created successfully. Config: ${output.configPath}.` - : output.status; - - if (this.telemetryService && output.status === "success") { - this.telemetryService.sendEvent(Constants.TelemetryEventName, { - source: Constants.TelemetrySource, - sfcaEvent: Constants.McpTelemetryEvents.CUSTOM_RULE_CREATED, - engine: "regex", - ruleName: input.ruleName, - configPath: output.configPath - }); - } - - return { - content: [{ type: "text", text: message }], - structuredContent: output, - isError: output.status !== "success" - }; - } -} - -function validateInput(input: z.infer): CallToolResult | undefined { - const ruleName = input.ruleName?.trim(); - if (!ruleName) { - return buildError("ruleName is required. Provide a name for the custom rule."); - } - - const regex = input.regex?.trim(); - if (!regex) { - return buildError("regex is required. Provide a regex pattern like '/todo/gi'."); - } - - if (!regex.startsWith("/") || regex.lastIndexOf("/") <= 0) { - return buildError("regex must be in format '/pattern/flags' (e.g., '/todo/gi' or '/[0-9]+/g')."); - } - - const description = input.description?.trim(); - if (!description) { - return buildError("description is required. Provide a short description of what the rule checks."); - } - - const violationMessage = input.violationMessage?.trim(); - if (!violationMessage) { - return buildError("violationMessage is required. Provide a message shown when violation is found."); - } - - if (!input.tags || input.tags.length === 0) { - return buildError("tags is required. Provide at least one tag (e.g., ['Custom'])."); - } - - if (input.severity === undefined || input.severity === null) { - return buildError("severity is required. Provide a value between 1 (Critical) and 5 (Info)."); - } - - if (input.severity < 1 || input.severity > 5) { - return buildError("severity must be between 1 (Critical) and 5 (Info)."); - } - - const workingDirectory = input.workingDirectory?.trim(); - if (!workingDirectory) { - return buildError("workingDirectory is required. Provide a directory where files can be written."); - } - - // Validate file extensions if provided - if (input.fileExtensions && input.fileExtensions.length > 0) { - for (const ext of input.fileExtensions) { - if (!ext.startsWith(".")) { - return buildError(`file extension must start with dot: '${ext}'. Use '.cls' not 'cls'.`); - } - } - } - - return undefined; -} - -function buildError(status: string): CallToolResult { - const output = { status }; - return { - content: [{ type: "text", text: JSON.stringify(output) }], - structuredContent: output, - isError: true - }; -} From 7ec32cf41ccbb7e7d514930d426878d34030639a Mon Sep 17 00:00:00 2001 From: Arun Tyagi Date: Thu, 16 Apr 2026 18:36:15 +0530 Subject: [PATCH 7/7] FIX: Increase timeout for Windows E2E test - Increased 'should enable 1 tool and a toolset' test timeout from 60s to 90s - Added connection timeout with Promise.race to fail fast on server issues - Connection timeout set to 90s for Windows compatibility This addresses E2E test failures on Windows runners where connection establishment takes longer than on Linux/macOS. --- .../test/e2e/tool-registration.test.ts | 15 +++++++++++++-- 1 file changed, 13 insertions(+), 2 deletions(-) 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 338d44ac..bfac1785 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',