From 9f4ae1610eaaca66bf2acd84ac52cc2ec5835119 Mon Sep 17 00:00:00 2001 From: Arun Tyagi Date: Wed, 4 Feb 2026 15:59:37 +0530 Subject: [PATCH 01/25] create action declaration which helps in returning ast nodes of sample code --- .../src/actions/get-ast-nodes.ts | 15 +++++++++++++++ 1 file changed, 15 insertions(+) create mode 100644 packages/mcp-provider-code-analyzer/src/actions/get-ast-nodes.ts diff --git a/packages/mcp-provider-code-analyzer/src/actions/get-ast-nodes.ts b/packages/mcp-provider-code-analyzer/src/actions/get-ast-nodes.ts new file mode 100644 index 00000000..95a1c4f1 --- /dev/null +++ b/packages/mcp-provider-code-analyzer/src/actions/get-ast-nodes.ts @@ -0,0 +1,15 @@ +/** + * Returns a list of AST node identifiers for the given source code. + * Placeholder implementation: returns an empty list. + * + * @param code - The source code as a string + * @param language - The language of the source code (e.g., "typescript", "javascript", "apex") + * @returns An array of strings representing AST nodes + */ +export function getAstNodes(code: string, language: string): string[] { + // TODO: Implement language-specific parsing to extract AST nodes + void code; // avoid unused param for placeholder + void language; // avoid unused param for placeholder + return []; +} + From a5d483620e49cba13b2efb234b758bcbd6468f67 Mon Sep 17 00:00:00 2001 From: aruntyagiTutu Date: Wed, 4 Feb 2026 16:30:57 +0530 Subject: [PATCH 02/25] @W-21085164 - method to extract ast nodes from ast xml (#369) * extractAstNodes from xml implementation * optimize the method using regex * extract ast nodes from xml --- .../src/actions/get-ast-nodes.ts | 104 +++++++++++++++++- 1 file changed, 99 insertions(+), 5 deletions(-) diff --git a/packages/mcp-provider-code-analyzer/src/actions/get-ast-nodes.ts b/packages/mcp-provider-code-analyzer/src/actions/get-ast-nodes.ts index 95a1c4f1..86dfb073 100644 --- a/packages/mcp-provider-code-analyzer/src/actions/get-ast-nodes.ts +++ b/packages/mcp-provider-code-analyzer/src/actions/get-ast-nodes.ts @@ -1,15 +1,109 @@ /** * Returns a list of AST node identifiers for the given source code. - * Placeholder implementation: returns an empty list. + * Minimal implementation with zero external dependencies: + * - For 'xml' or 'html' languages, returns tag names encountered in document order (unique, case-preserving). + * - For other languages, returns an empty list (placeholder). + * + * This function is intentionally lightweight to avoid runtime dependencies. * * @param code - The source code as a string - * @param language - The language of the source code (e.g., "typescript", "javascript", "apex") + * @param language - The language of the source code (e.g., "xml", "html", "typescript", "javascript", "apex") * @returns An array of strings representing AST nodes */ export function getAstNodes(code: string, language: string): string[] { - // TODO: Implement language-specific parsing to extract AST nodes - void code; // avoid unused param for placeholder - void language; // avoid unused param for placeholder + const lang = (language ?? '').toLowerCase().trim(); + return []; } + +import { XMLParser } from "fast-xml-parser"; +import * as fs from "fs"; + +interface AstNode { + nodeName: string; + attributes: Record; + parent?: string; + ancestors: string[]; +} + +const parser = new XMLParser({ + ignoreAttributes: false, + attributeNamePrefix: "@_", +}); + +/** + * Recursively traverse XML object tree + */ +function traverse( + node: any, + nodeName: string, + ancestors: string[], + parent?: string, + result: AstNode[] = [] +) { + if (typeof node !== "object" || node === null) return result; + + // Extract attributes + const attributes: Record = {}; + for (const key of Object.keys(node)) { + if (key.startsWith("@_")) { + attributes[key.substring(2)] = String(node[key]); + } + } + + // Store current node + result.push({ + nodeName, + attributes, + parent, + ancestors, + }); + + // Traverse children + for (const key of Object.keys(node)) { + if (key.startsWith("@_") || key === "#text") continue; + + const child = node[key]; + + if (Array.isArray(child)) { + for (const c of child) { + traverse(c, key, [...ancestors, nodeName], nodeName, result); + } + } else { + traverse(child, key, [...ancestors, nodeName], nodeName, result); + } + } + + return result; +} + +/** + * Load and process AST XML + */ +function extractAstNodes(xmlPath: string): AstNode[] { + const xml = fs.readFileSync(xmlPath, "utf8"); + const parsed = parser.parse(xml); + + const rootName = Object.keys(parsed)[0]; + const rootNode = parsed[rootName]; + + return traverse(rootNode, rootName, []); +} + +// ---------- USAGE ---------- + +const astNodes = extractAstNodes("ast.xml"); + +// Print summary +console.log(`Total nodes: ${astNodes.length}`); + +// Sample output +console.log(astNodes.slice(0, 10)); + +// Optional: write to file for inspection +fs.writeFileSync( + "ast-nodes.json", + JSON.stringify(astNodes, null, 2), + "utf8" +); From 2f41b079159ab457b79e3e09960f954993df6828 Mon Sep 17 00:00:00 2001 From: aruntyagiTutu Date: Wed, 4 Feb 2026 17:22:58 +0530 Subject: [PATCH 03/25] orchestartion setps for xpath creation for apex code (#370) --- .../src/actions/get-ast-nodes.ts | 27 ++++++++++++++++++- 1 file changed, 26 insertions(+), 1 deletion(-) diff --git a/packages/mcp-provider-code-analyzer/src/actions/get-ast-nodes.ts b/packages/mcp-provider-code-analyzer/src/actions/get-ast-nodes.ts index 86dfb073..81b931e3 100644 --- a/packages/mcp-provider-code-analyzer/src/actions/get-ast-nodes.ts +++ b/packages/mcp-provider-code-analyzer/src/actions/get-ast-nodes.ts @@ -12,7 +12,32 @@ */ export function getAstNodes(code: string, language: string): string[] { const lang = (language ?? '').toLowerCase().trim(); - + // 1. Read user utterance and normalize rule intent (engine, language, rule type) + + // 2. Generate minimal Apex sample code representing the rule violation + + // 3. Run PMD ast-dump on generated Apex code to produce AST XML + + // 4. Parse AST XML and extract all AST nodes with hierarchy information + + // 5. Identify and filter relevant AST nodes required for the rule logic + + // 6. Enrich AST nodes using cached AST metadata (descriptions, attributes) + + // 7. Prepare structured prompt input using rule intent + relevant AST nodes + + // 8. Call LLM to generate XPath expression based on AST structure + + // 9. Validate generated XPath against extracted AST nodes + + // 10. Generate custom PMD rule XML using rule template and XPath + + // 11. Create or update custom PMD rules XML file + + // 12. Create or update code-analyzer configuration to reference custom rules + + // 13. (Optional) Run PMD with sample code to validate rule behavior + return []; } From 835f99e5e3c08ecce0d34c1be63ba4e4fbefa84b Mon Sep 17 00:00:00 2001 From: aruntyagiTutu Date: Thu, 5 Feb 2026 12:53:45 +0530 Subject: [PATCH 04/25] tool declaration (#371) --- .../src/tools/generate_xpath_prompt.ts | 78 +++++++++++++++++++ 1 file changed, 78 insertions(+) create mode 100644 packages/mcp-provider-code-analyzer/src/tools/generate_xpath_prompt.ts diff --git a/packages/mcp-provider-code-analyzer/src/tools/generate_xpath_prompt.ts b/packages/mcp-provider-code-analyzer/src/tools/generate_xpath_prompt.ts new file mode 100644 index 00000000..9abc281c --- /dev/null +++ b/packages/mcp-provider-code-analyzer/src/tools/generate_xpath_prompt.ts @@ -0,0 +1,78 @@ +import { z } from "zod"; +import { CallToolResult } from "@modelcontextprotocol/sdk/types.js"; +import { McpTool, McpToolConfig, ReleaseState, Toolset } from "@salesforce/mcp-provider-api"; + +const DESCRIPTION: string = + `Purpose: First step for creating a PMD XPath-based custom rule. + Use this tool when the user asks to create a custom rule (especially PMD/XPath). + + Inputs (required): + - sampleCode: A short snippet that SHOULD violate the intended rule. + - Ensure this snippet truly triggers the exact pattern you want to catch. + - Keep it minimal yet self-contained (parsable/compilable); remove unrelated noise. + - Prefer realistic code over contrived examples to avoid brittle XPath. + - language: Programming language of sampleCode (e.g., "apex", "xml"). + - engine: Analysis engine (e.g., "pmd"). + + Output: + - prompt: A concise, high-signal prompt that guides an LLM to extract the AST context needed for XPath authoring from the sampleCode. + + Note: This tool only prepares the prompt. A subsequent tool will use these details to generate the final XPath-based custom rule.`; + +export const inputSchema = z.object({ + sampleCode: z.string().describe("Sample code which violates the rule user is looking to generate a custom PMD rule for."), + language: z.string().describe("Programming language of the sample code (e.g., 'apex', 'javascript', 'xml')."), + engine: z.string().describe("Target analysis engine (e.g., 'pmd').") // right now it is only for pmd, but as it will be extended to other engines, it is included here +}); +type InputArgsShape = typeof inputSchema.shape; + +const outputSchema = z.object({ + status: z.string().describe(`'success' or an error message.`), + prompt: z.string().describe('Generated prompt text to guide XPath creation.') +}); +type OutputArgsShape = typeof outputSchema.shape; + +export class GenerateXpathPromptMcpTool extends McpTool { + public static readonly NAME: string = 'get_ast_nodes_to_generate_xpath'; + + public constructor() { + super(); + } + + public getReleaseState(): ReleaseState { + return ReleaseState.NON_GA; + } + + public getToolsets(): Toolset[] { + return [Toolset.CODE_ANALYSIS]; + } + + public getName(): string { + return GenerateXpathPromptMcpTool.NAME; + } + + public getConfig(): McpToolConfig { + return { + title: "Generate XPath Prompt", + description: DESCRIPTION, + inputSchema: inputSchema.shape, + outputSchema: outputSchema.shape, + annotations: { + readOnlyHint: true + } + }; + } + + // Intentionally minimal stub; will be implemented later + public async exec(_input: z.infer): Promise { + const output = { + status: "success", + prompt: "" + }; + return { + content: [{ type: "text", text: JSON.stringify(output) }], + structuredContent: output + }; + } +} + From a955aec9029757a746276646598ee5e6585ad0b0 Mon Sep 17 00:00:00 2001 From: aruntyagiTutu Date: Thu, 5 Feb 2026 17:35:41 +0530 Subject: [PATCH 05/25] @W-21085164 - mcp tool added in provider (#372) * mcp tool added in provider * add GenerateXpathPromptMcpTool in test * update test --- .../src/actions/get-ast-nodes.ts | 134 +++++++++++++++--- .../src/provider.ts | 5 +- .../src/tools/generate_xpath_prompt.ts | 13 +- .../test/provider.test.ts | 4 +- .../test/e2e/tool-registration.test.ts | 3 +- 5 files changed, 136 insertions(+), 23 deletions(-) diff --git a/packages/mcp-provider-code-analyzer/src/actions/get-ast-nodes.ts b/packages/mcp-provider-code-analyzer/src/actions/get-ast-nodes.ts index 81b931e3..79f661f5 100644 --- a/packages/mcp-provider-code-analyzer/src/actions/get-ast-nodes.ts +++ b/packages/mcp-provider-code-analyzer/src/actions/get-ast-nodes.ts @@ -1,3 +1,118 @@ +export type GetAstNodesInput = { + code: string; + language: string; +}; + +export type GetAstNodesOutput = { + status: string; + nodes: string[]; +}; + +export interface GetAstNodesAction { + exec(input: GetAstNodesInput): Promise; +} + +export class GetAstNodesActionImpl implements GetAstNodesAction { + public async exec(input: GetAstNodesInput): Promise { + try { + const nodes = extractAstNodeNames(input.code, input.language); + return { status: "success", nodes }; + } catch (e) { + return { status: (e as Error)?.message ?? String(e), nodes: [] }; + } + } +} + +// ---- Minimal, dependency-free extraction helpers (xml/html only for now) ---- +function extractAstNodeNames(code: string, language: string): string[] { + const lang = (language ?? "").toLowerCase().trim(); + if (lang === "xml" || lang === "html") { + return extractXmlTagNames(code); + } + // Placeholder for other languages + return []; +} + +function extractXmlTagNames(xmlLike: string): string[] { + const results: string[] = []; + const seen = new Set(); + const length = xmlLike.length; + + let i = 0; + while (i < length) { + const ch = xmlLike.charCodeAt(i); + if (ch !== CharCode.LT) { + i++; + continue; + } + const next = charAt(xmlLike, i + 1); + if (next === "/" || next === "!" || next === "?") { + i = advanceToNextTagEnd(xmlLike, i + 1); + continue; + } + let j = i + 1; + while (j < length && isWhitespace(xmlLike.charCodeAt(j))) j++; + if (j >= length || !isNameStartChar(xmlLike.charCodeAt(j))) { + i = advanceToNextTagEnd(xmlLike, j); + continue; + } + const start = j; + j++; + while (j < length && isNameChar(xmlLike.charCodeAt(j))) j++; + const name = xmlLike.slice(start, j); + const key = name.toLowerCase(); + if (!seen.has(key)) { + seen.add(key); + results.push(name); + } + i = advanceToNextTagEnd(xmlLike, j); + } + return results; +} + +const enum CharCode { + LT = 60, + GT = 62, + SPACE = 32, + TAB = 9, + CR = 13, + LF = 10, + UNDERSCORE = 95, + COLON = 58, + DOT = 46, + DASH = 45 +} + +function charAt(s: string, idx: number): string { + return idx < s.length ? (s[idx] as string) : ""; +} + +function isWhitespace(code: number): boolean { + return code === CharCode.SPACE || code === CharCode.TAB || code === CharCode.CR || code === CharCode.LF; +} + +function isAsciiLetter(code: number): boolean { + return (code >= 65 && code <= 90) || (code >= 97 && code <= 122); +} + +function isDigit(code: number): boolean { + return code >= 48 && code <= 57; +} + +function isNameStartChar(code: number): boolean { + return isAsciiLetter(code) || code === CharCode.UNDERSCORE || code === CharCode.COLON; +} + +function isNameChar(code: number): boolean { + return isNameStartChar(code) || isDigit(code) || code === CharCode.DOT || code === CharCode.DASH; +} + +function advanceToNextTagEnd(s: string, idx: number): number { + const len = s.length; + while (idx < len && s.charCodeAt(idx) !== CharCode.GT) idx++; + return Math.min(idx + 1, len); +} + /** * Returns a list of AST node identifiers for the given source code. * Minimal implementation with zero external dependencies: @@ -41,6 +156,8 @@ export function getAstNodes(code: string, language: string): string[] { return []; } +export default getAstNodes; + import { XMLParser } from "fast-xml-parser"; import * as fs from "fs"; @@ -115,20 +232,3 @@ function extractAstNodes(xmlPath: string): AstNode[] { return traverse(rootNode, rootName, []); } - -// ---------- USAGE ---------- - -const astNodes = extractAstNodes("ast.xml"); - -// Print summary -console.log(`Total nodes: ${astNodes.length}`); - -// Sample output -console.log(astNodes.slice(0, 10)); - -// Optional: write to file for inspection -fs.writeFileSync( - "ast-nodes.json", - JSON.stringify(astNodes, null, 2), - "utf8" -); diff --git a/packages/mcp-provider-code-analyzer/src/provider.ts b/packages/mcp-provider-code-analyzer/src/provider.ts index 75fd90ba..33615501 100644 --- a/packages/mcp-provider-code-analyzer/src/provider.ts +++ b/packages/mcp-provider-code-analyzer/src/provider.ts @@ -9,6 +9,8 @@ import {EnginePluginsFactory, EnginePluginsFactoryImpl} from "./factories/Engine import {RunAnalyzerActionImpl} from "./actions/run-analyzer.js"; import {DescribeRuleActionImpl} from "./actions/describe-rule.js"; import { ListRulesActionImpl } from "./actions/list-rules.js"; +import { GenerateXpathPromptMcpTool } from "./tools/generate_xpath_prompt.js"; +import { GetAstNodesActionImpl } from "./actions/get-ast-nodes.js"; export class CodeAnalyzerMcpProvider extends McpProvider { public getName(): string { @@ -34,7 +36,8 @@ export class CodeAnalyzerMcpProvider extends McpProvider { enginePluginsFactory, telemetryService: services.getTelemetryService() })), - new CodeAnalyzerQueryResultsMcpTool(new QueryResultsActionImpl(), services.getTelemetryService()) + new CodeAnalyzerQueryResultsMcpTool(new QueryResultsActionImpl(), services.getTelemetryService()), + new GenerateXpathPromptMcpTool(new GetAstNodesActionImpl(), services.getTelemetryService()) ]); } } \ No newline at end of file diff --git a/packages/mcp-provider-code-analyzer/src/tools/generate_xpath_prompt.ts b/packages/mcp-provider-code-analyzer/src/tools/generate_xpath_prompt.ts index 9abc281c..c0271133 100644 --- a/packages/mcp-provider-code-analyzer/src/tools/generate_xpath_prompt.ts +++ b/packages/mcp-provider-code-analyzer/src/tools/generate_xpath_prompt.ts @@ -1,6 +1,7 @@ import { z } from "zod"; import { CallToolResult } from "@modelcontextprotocol/sdk/types.js"; -import { McpTool, McpToolConfig, ReleaseState, Toolset } from "@salesforce/mcp-provider-api"; +import { McpTool, McpToolConfig, ReleaseState, TelemetryService, Toolset } from "@salesforce/mcp-provider-api"; +import { GetAstNodesActionImpl, type GetAstNodesAction, type GetAstNodesInput, type GetAstNodesOutput } from "../actions/get-ast-nodes.js"; const DESCRIPTION: string = `Purpose: First step for creating a PMD XPath-based custom rule. @@ -34,11 +35,17 @@ type OutputArgsShape = typeof outputSchema.shape; export class GenerateXpathPromptMcpTool extends McpTool { public static readonly NAME: string = 'get_ast_nodes_to_generate_xpath'; + private readonly action: GetAstNodesAction; + private readonly telemetryService?: TelemetryService; - public constructor() { + public constructor( + action: GetAstNodesAction = new GetAstNodesActionImpl(), + telemetryService?: TelemetryService + ) { super(); + this.action = action; + this.telemetryService = telemetryService; } - public getReleaseState(): ReleaseState { return ReleaseState.NON_GA; } diff --git a/packages/mcp-provider-code-analyzer/test/provider.test.ts b/packages/mcp-provider-code-analyzer/test/provider.test.ts index efd4cfc7..c03cffcc 100644 --- a/packages/mcp-provider-code-analyzer/test/provider.test.ts +++ b/packages/mcp-provider-code-analyzer/test/provider.test.ts @@ -5,6 +5,7 @@ import { StubServices } from "./test-doubles.js"; import { CodeAnalyzerDescribeRuleMcpTool } from "../src/tools/describe_code_analyzer_rule.js"; import { CodeAnalyzerListRulesMcpTool } from "../src/tools/list_code_analyzer_rules.js"; import { CodeAnalyzerQueryResultsMcpTool } from "../src/tools/query_code_analyzer_results.js"; +import { GenerateXpathPromptMcpTool } from "../src/tools/generate_xpath_prompt.js"; describe("Tests for CodeAnalyzerMcpProvider", () => { let services: Services; @@ -21,10 +22,11 @@ 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(4); + expect(tools).toHaveLength(5); 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); }); }) \ No newline at end of file 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 ac12b86f..d4bf42b2 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 @@ -90,7 +90,7 @@ describe('specific tool registration', () => { try { const initialTools = (await client.listTools()).tools.map((t) => t.name).sort(); - expect(initialTools.length).to.equal(7); + expect(initialTools.length).to.equal(8); expect(initialTools).to.deep.equal( [ 'run_soql_query', @@ -100,6 +100,7 @@ describe('specific tool registration', () => { 'run_code_analyzer', 'list_code_analyzer_rules', 'query_code_analyzer_results', + 'get_ast_nodes_to_generate_xpath', ].sort(), ); } catch (err) { From e78f9469cb620d01e6cd603b5010c080d096c907 Mon Sep 17 00:00:00 2001 From: aruntyagiTutu Date: Fri, 6 Feb 2026 15:32:44 +0530 Subject: [PATCH 06/25] ast nodes generation via pmd cli method (#376) --- .../src/actions/get-ast-nodes.ts | 194 +++--------------- .../src/ast/extract-ast-nodes.ts | 84 ++++++++ .../src/ast/generate-ast-xml.ts | 43 ++++ .../src/tools/generate_xpath_prompt.ts | 54 ++++- .../mcp-provider-code-analyzer/src/utils.ts | 4 +- .../test/actions/get-ast-nodes.test.ts | 49 +++++ 6 files changed, 258 insertions(+), 170 deletions(-) create mode 100644 packages/mcp-provider-code-analyzer/src/ast/extract-ast-nodes.ts create mode 100644 packages/mcp-provider-code-analyzer/src/ast/generate-ast-xml.ts create mode 100644 packages/mcp-provider-code-analyzer/test/actions/get-ast-nodes.test.ts diff --git a/packages/mcp-provider-code-analyzer/src/actions/get-ast-nodes.ts b/packages/mcp-provider-code-analyzer/src/actions/get-ast-nodes.ts index 79f661f5..38daedec 100644 --- a/packages/mcp-provider-code-analyzer/src/actions/get-ast-nodes.ts +++ b/packages/mcp-provider-code-analyzer/src/actions/get-ast-nodes.ts @@ -1,3 +1,6 @@ +import { generateAstXmlFromSource } from "../ast/generate-ast-xml.js"; +import { type AstNode, extractAstNodesFromXml } from "../ast/extract-ast-nodes.js"; + export type GetAstNodesInput = { code: string; language: string; @@ -5,7 +8,7 @@ export type GetAstNodesInput = { export type GetAstNodesOutput = { status: string; - nodes: string[]; + nodes: AstNode[]; }; export interface GetAstNodesAction { @@ -15,7 +18,18 @@ export interface GetAstNodesAction { export class GetAstNodesActionImpl implements GetAstNodesAction { public async exec(input: GetAstNodesInput): Promise { try { - const nodes = extractAstNodeNames(input.code, input.language); + const pmdBinPath = process.env.PMD_BIN_PATH ?? "/Users/arun.tyagi/Downloads/pmd-bin-7.21.0/bin"; + if (!pmdBinPath) { + throw new Error("Missing PMD bin path. Provide pmdBinPath or set PMD_BIN_PATH."); + } + + // TODO: Spike note: + // - Currently shelling out to the PMD CLI (`./pmd ast-dump`) to generate AST XML. + // - This is a temporary approach for early prototyping and should not be considered final. + // - Replace this with a direct PMD Java API integration or a Code Analyzer core API call. + // - When replacing, remove dependency on local PMD bin path and avoid spawning external processes. + const astXml = await generateAstXmlFromSource(input.code, input.language, pmdBinPath); + const nodes = extractAstNodesFromXml(astXml); return { status: "success", nodes }; } catch (e) { return { status: (e as Error)?.message ?? String(e), nodes: [] }; @@ -23,94 +37,19 @@ export class GetAstNodesActionImpl implements GetAstNodesAction { } } -// ---- Minimal, dependency-free extraction helpers (xml/html only for now) ---- -function extractAstNodeNames(code: string, language: string): string[] { - const lang = (language ?? "").toLowerCase().trim(); - if (lang === "xml" || lang === "html") { - return extractXmlTagNames(code); - } - // Placeholder for other languages - return []; -} - -function extractXmlTagNames(xmlLike: string): string[] { - const results: string[] = []; - const seen = new Set(); - const length = xmlLike.length; - - let i = 0; - while (i < length) { - const ch = xmlLike.charCodeAt(i); - if (ch !== CharCode.LT) { - i++; - continue; - } - const next = charAt(xmlLike, i + 1); - if (next === "/" || next === "!" || next === "?") { - i = advanceToNextTagEnd(xmlLike, i + 1); - continue; - } - let j = i + 1; - while (j < length && isWhitespace(xmlLike.charCodeAt(j))) j++; - if (j >= length || !isNameStartChar(xmlLike.charCodeAt(j))) { - i = advanceToNextTagEnd(xmlLike, j); - continue; - } - const start = j; - j++; - while (j < length && isNameChar(xmlLike.charCodeAt(j))) j++; - const name = xmlLike.slice(start, j); - const key = name.toLowerCase(); - if (!seen.has(key)) { - seen.add(key); - results.push(name); - } - i = advanceToNextTagEnd(xmlLike, j); - } - return results; -} - -const enum CharCode { - LT = 60, - GT = 62, - SPACE = 32, - TAB = 9, - CR = 13, - LF = 10, - UNDERSCORE = 95, - COLON = 58, - DOT = 46, - DASH = 45 -} - -function charAt(s: string, idx: number): string { - return idx < s.length ? (s[idx] as string) : ""; -} - -function isWhitespace(code: number): boolean { - return code === CharCode.SPACE || code === CharCode.TAB || code === CharCode.CR || code === CharCode.LF; -} - -function isAsciiLetter(code: number): boolean { - return (code >= 65 && code <= 90) || (code >= 97 && code <= 122); -} -function isDigit(code: number): boolean { - return code >= 48 && code <= 57; -} - -function isNameStartChar(code: number): boolean { - return isAsciiLetter(code) || code === CharCode.UNDERSCORE || code === CharCode.COLON; -} - -function isNameChar(code: number): boolean { - return isNameStartChar(code) || isDigit(code) || code === CharCode.DOT || code === CharCode.DASH; -} - -function advanceToNextTagEnd(s: string, idx: number): number { - const len = s.length; - while (idx < len && s.charCodeAt(idx) !== CharCode.GT) idx++; - return Math.min(idx + 1, len); +/** + * Generates AST XML for the given source code using PMD CLI. + * This is a utility-style export so it can be wired into the action later + * without altering the existing flow in this file. + */ +export async function generateAstXml( + code: string, + language: string, + pmdBinPath: string +): Promise { + const { generateAstXmlFromSource } = await import("../ast/generate-ast-xml.js"); + return generateAstXmlFromSource(code, language, pmdBinPath); } /** @@ -155,80 +94,3 @@ export function getAstNodes(code: string, language: string): string[] { return []; } - -export default getAstNodes; - - -import { XMLParser } from "fast-xml-parser"; -import * as fs from "fs"; - -interface AstNode { - nodeName: string; - attributes: Record; - parent?: string; - ancestors: string[]; -} - -const parser = new XMLParser({ - ignoreAttributes: false, - attributeNamePrefix: "@_", -}); - -/** - * Recursively traverse XML object tree - */ -function traverse( - node: any, - nodeName: string, - ancestors: string[], - parent?: string, - result: AstNode[] = [] -) { - if (typeof node !== "object" || node === null) return result; - - // Extract attributes - const attributes: Record = {}; - for (const key of Object.keys(node)) { - if (key.startsWith("@_")) { - attributes[key.substring(2)] = String(node[key]); - } - } - - // Store current node - result.push({ - nodeName, - attributes, - parent, - ancestors, - }); - - // Traverse children - for (const key of Object.keys(node)) { - if (key.startsWith("@_") || key === "#text") continue; - - const child = node[key]; - - if (Array.isArray(child)) { - for (const c of child) { - traverse(c, key, [...ancestors, nodeName], nodeName, result); - } - } else { - traverse(child, key, [...ancestors, nodeName], nodeName, result); - } - } - - return result; -} - -/** - * Load and process AST XML - */ -function extractAstNodes(xmlPath: string): AstNode[] { - const xml = fs.readFileSync(xmlPath, "utf8"); - const parsed = parser.parse(xml); - - const rootName = Object.keys(parsed)[0]; - const rootNode = parsed[rootName]; - - return traverse(rootNode, rootName, []); -} diff --git a/packages/mcp-provider-code-analyzer/src/ast/extract-ast-nodes.ts b/packages/mcp-provider-code-analyzer/src/ast/extract-ast-nodes.ts new file mode 100644 index 00000000..eb4f3398 --- /dev/null +++ b/packages/mcp-provider-code-analyzer/src/ast/extract-ast-nodes.ts @@ -0,0 +1,84 @@ +import { XMLParser } from "fast-xml-parser"; +import * as fs from "node:fs"; + +export interface AstNode { + nodeName: string; + attributes: Record; + parent?: string; + ancestors: string[]; +} + +const parser = new XMLParser({ + ignoreAttributes: false, + attributeNamePrefix: "@_", +}); + +/** + * Recursively traverse XML object tree. + */ +function traverse( + node: any, + nodeName: string, + ancestors: string[], + parent?: string, + result: AstNode[] = [] +) { + if (typeof node !== "object" || node === null) return result; + + // Extract attributes + const attributes: Record = {}; + for (const key of Object.keys(node)) { + if (key.startsWith("@_")) { + attributes[key.substring(2)] = String(node[key]); + } + } + + // Store current node + result.push({ + nodeName, + attributes, + parent, + ancestors, + }); + + // Traverse children + for (const key of Object.keys(node)) { + if (key.startsWith("@_") || key === "#text") continue; + + const child = node[key]; + + if (Array.isArray(child)) { + for (const c of child) { + traverse(c, key, [...ancestors, nodeName], nodeName, result); + } + } else { + traverse(child, key, [...ancestors, nodeName], nodeName, result); + } + } + + return result; +} + +function parseAstXml(xml: string): AstNode[] { + const parsed = parser.parse(xml); + + const rootName = Object.keys(parsed)[0]; + const rootNode = parsed[rootName]; + + return traverse(rootNode, rootName, []); +} + +/** + * Load and process AST XML from a file path. + */ +export function extractAstNodes(xmlPath: string): AstNode[] { + const xml = fs.readFileSync(xmlPath, "utf8"); + return parseAstXml(xml); +} + +/** + * Process AST XML from a raw XML string. + */ +export function extractAstNodesFromXml(xml: string): AstNode[] { + return parseAstXml(xml); +} diff --git a/packages/mcp-provider-code-analyzer/src/ast/generate-ast-xml.ts b/packages/mcp-provider-code-analyzer/src/ast/generate-ast-xml.ts new file mode 100644 index 00000000..ecbe670c --- /dev/null +++ b/packages/mcp-provider-code-analyzer/src/ast/generate-ast-xml.ts @@ -0,0 +1,43 @@ +import os from "node:os"; +import path from "node:path"; +import fs from "node:fs/promises"; +import { execFile } from "node:child_process"; +import { promisify } from "node:util"; +import { getErrorMessage } from "../utils.js"; + +const execFileAsync = promisify(execFile); + +function sanitizeExtension(language: string): string { + const cleaned = (language ?? "").toLowerCase().replace(/[^a-z0-9]/g, ""); + return cleaned.length > 0 ? cleaned : "txt"; +} + +/** + * Generates AST XML for the given source code using the PMD CLI. + * Assumes the PMD bin folder path is provided and will be used as the cwd. + */ +export async function generateAstXmlFromSource( + code: string, + language: string, + pmdBinPath: string +): Promise { + const tempDir = await fs.mkdtemp(path.join(os.tmpdir(), "pmd-ast-")); + const sourceFile = path.join(tempDir, `source.${sanitizeExtension(language)}`); + + try { + await fs.writeFile(sourceFile, code, "utf8"); + const { stdout } = await execFileAsync( + "./pmd", + ["ast-dump", "--language", language, "--format", "xml", "--file", sourceFile], + { + cwd: pmdBinPath, + maxBuffer: 10 * 1024 * 1024 + } + ); + return stdout.trim(); + } catch (error) { + throw new Error(`Failed to generate AST XML via PMD: ${getErrorMessage(error)}`); + } finally { + await fs.rm(tempDir, { recursive: true, force: true }); + } +} diff --git a/packages/mcp-provider-code-analyzer/src/tools/generate_xpath_prompt.ts b/packages/mcp-provider-code-analyzer/src/tools/generate_xpath_prompt.ts index c0271133..754a95e3 100644 --- a/packages/mcp-provider-code-analyzer/src/tools/generate_xpath_prompt.ts +++ b/packages/mcp-provider-code-analyzer/src/tools/generate_xpath_prompt.ts @@ -70,10 +70,46 @@ export class GenerateXpathPromptMcpTool extends McpTool): Promise { + public async exec(input: z.infer): Promise { + const validationError = validateInput(input); + if (validationError) { + return validationError; + } + + const astResult = await this.action.exec({ + code: input.sampleCode, + language: input.language + }); + if (astResult.status !== "success") { + const output = { + status: astResult.status, + prompt: "" + }; + return { + content: [{ type: "text", text: JSON.stringify(output) }], + structuredContent: output + }; + } + const output = { status: "success", + prompt: JSON.stringify({ + language: input.language, + astNodes: astResult.nodes + }) + }; + return { + content: [{ type: "text", text: JSON.stringify(output) }], + structuredContent: output + }; + } +} + +function validateInput(input: z.infer): CallToolResult | undefined { + const language = input.language?.trim(); + if (!language) { + const output = { + status: "language is required", prompt: "" }; return { @@ -81,5 +117,19 @@ export class GenerateXpathPromptMcpTool extends McpTool + + + + + + + + + + + + + + +`.trim(); + +const generateAstXmlFromSourceMock = vi.fn().mockResolvedValue(sampleXml); + +vi.mock("../../src/ast/generate-ast-xml.js", () => ({ + generateAstXmlFromSource: generateAstXmlFromSourceMock +})); + +describe("GetAstNodesActionImpl", () => { + it("generates AST nodes from sample Apex code", async () => { + const { GetAstNodesActionImpl } = await import("../../src/actions/get-ast-nodes.js"); + const action = new GetAstNodesActionImpl(); + + const input = { + sampleCode: "public with sharing class NestedIfExample {\n public static void checkDepth(Integer a, Integer b, Integer c, Integer d) {\n if (a > 0) {\n if (b > 0) {\n if (c > 0) {\n if (d > 0) {\n System.debug('Depth 4: violation');\n }\n }\n }\n }\n }\n}", + language: "apex", + engine: "pmd" + }; + + const result = await action.exec({ + code: input.sampleCode, + language: input.language + }); + + expect(generateAstXmlFromSourceMock).toHaveBeenCalledTimes(1); + expect(result.status).toBe("success"); + console.log(result.nodes); + expect(result.nodes.length).toBeGreaterThan(0); + expect(result.nodes[0]?.nodeName).toBe("CompilationUnit"); + }); +}); From 1b75821b1fa84a0e7639cb9db19f02d334a18dcc Mon Sep 17 00:00:00 2001 From: aruntyagiTutu Date: Mon, 9 Feb 2026 14:30:33 +0530 Subject: [PATCH 07/25] ast nodes generation via pmd cli method (#377) From 5718c3dd3f1b2034be9b56f1dbae7ee97e7c1c5b Mon Sep 17 00:00:00 2001 From: aruntyagiTutu Date: Mon, 9 Feb 2026 14:49:51 +0530 Subject: [PATCH 08/25] @W-21102083 - creation ast local cache (#378) * get metadata of ast nodes of apex langugae * pmd apex ast cache --- .../mcp-provider-code-analyzer/package.json | 2 +- .../src/actions/get-ast-nodes.ts | 19 +- .../src/ast/metadata/apex-ast-reference.ts | 70 + .../src/data/pmd/apex-ast-reference.json | 2680 +++++++++++++++++ .../src/tools/generate_xpath_prompt.ts | 51 +- 5 files changed, 2817 insertions(+), 5 deletions(-) create mode 100644 packages/mcp-provider-code-analyzer/src/ast/metadata/apex-ast-reference.ts create mode 100644 packages/mcp-provider-code-analyzer/src/data/pmd/apex-ast-reference.json diff --git a/packages/mcp-provider-code-analyzer/package.json b/packages/mcp-provider-code-analyzer/package.json index dfc7157d..36b62203 100644 --- a/packages/mcp-provider-code-analyzer/package.json +++ b/packages/mcp-provider-code-analyzer/package.json @@ -41,7 +41,7 @@ "package.json" ], "scripts": { - "build": "tsc --build tsconfig.build.json --verbose", + "build": "tsc --build tsconfig.build.json --verbose && cp -R src/data dist/data", "clean": "tsc --build tsconfig.build.json --clean", "clean-all": "yarn clean && rimraf node_modules", "lint": "eslint **/*.ts", diff --git a/packages/mcp-provider-code-analyzer/src/actions/get-ast-nodes.ts b/packages/mcp-provider-code-analyzer/src/actions/get-ast-nodes.ts index 38daedec..2eff930f 100644 --- a/packages/mcp-provider-code-analyzer/src/actions/get-ast-nodes.ts +++ b/packages/mcp-provider-code-analyzer/src/actions/get-ast-nodes.ts @@ -1,5 +1,6 @@ import { generateAstXmlFromSource } from "../ast/generate-ast-xml.js"; import { type AstNode, extractAstNodesFromXml } from "../ast/extract-ast-nodes.js"; +import { getApexAstNodeMetadataByNames, type ApexAstNodeMetadata } from "../ast/metadata/apex-ast-reference.js"; export type GetAstNodesInput = { code: string; @@ -9,6 +10,7 @@ export type GetAstNodesInput = { export type GetAstNodesOutput = { status: string; nodes: AstNode[]; + metadata: ApexAstNodeMetadata[]; }; export interface GetAstNodesAction { @@ -30,9 +32,12 @@ export class GetAstNodesActionImpl implements GetAstNodesAction { // - When replacing, remove dependency on local PMD bin path and avoid spawning external processes. const astXml = await generateAstXmlFromSource(input.code, input.language, pmdBinPath); const nodes = extractAstNodesFromXml(astXml); - return { status: "success", nodes }; + const language = input.language?.toLowerCase().trim(); + const nodeNames = Array.from(new Set(nodes.map((node) => node.nodeName))); + const metadata = await getCachedMetadataByLanguage(language, nodeNames); + return { status: "success", nodes, metadata }; } catch (e) { - return { status: (e as Error)?.message ?? String(e), nodes: [] }; + return { status: (e as Error)?.message ?? String(e), nodes: [], metadata: [] }; } } } @@ -94,3 +99,13 @@ export function getAstNodes(code: string, language: string): string[] { return []; } + +async function getCachedMetadataByLanguage( + language: string, + nodeNames: string[] +): Promise { + if (language === "apex") { + return getApexAstNodeMetadataByNames(nodeNames); + } + return []; +} diff --git a/packages/mcp-provider-code-analyzer/src/ast/metadata/apex-ast-reference.ts b/packages/mcp-provider-code-analyzer/src/ast/metadata/apex-ast-reference.ts new file mode 100644 index 00000000..2e5e5ed9 --- /dev/null +++ b/packages/mcp-provider-code-analyzer/src/ast/metadata/apex-ast-reference.ts @@ -0,0 +1,70 @@ +import fs from "node:fs/promises"; + +export type ApexAstAttribute = { + name: string; + type: string; + description: string; + inherited_from?: string; +}; + +export type ApexAstNodeMetadata = { + name: string; + description?: string; + category?: string; + extends?: string; + implements?: string[]; + attributes?: ApexAstAttribute[]; +}; + +type ApexAstReference = { + description?: string; + source?: string; + extraction_date?: string; + total_nodes?: number; + version?: string; + note?: string; + nodes: ApexAstNodeMetadata[]; +}; + +let cachedReference: ApexAstReference | undefined; + +async function loadApexAstReference(): Promise { + if (cachedReference) { + return cachedReference; + } + const fileUrl = new URL("../../data/pmd/apex-ast-reference.json", import.meta.url); + const raw = await fs.readFile(fileUrl, "utf8"); + cachedReference = JSON.parse(raw) as ApexAstReference; + return cachedReference; +} + +/** + * Fetch metadata for a set of Apex AST node names. + * - Preserves input order. + * - Ignores names that are not found. + */ +export async function getApexAstNodeMetadataByNames( + nodeNames: string[] +): Promise { + const reference = await loadApexAstReference(); + const index = new Map( + reference.nodes.map((node) => [node.name.toLowerCase(), node]) + ); + + const results: ApexAstNodeMetadata[] = []; + for (const name of nodeNames) { + const normalized = name.toLowerCase(); + let node = index.get(normalized); + if (!node) { + node = reference.nodes.find((candidate) => + (candidate.implements ?? []).some((iface) => + iface.toLowerCase().includes(`<${normalized}>`) + ) + ); + } + if (node) { + results.push(node); + } + } + return results; +} diff --git a/packages/mcp-provider-code-analyzer/src/data/pmd/apex-ast-reference.json b/packages/mcp-provider-code-analyzer/src/data/pmd/apex-ast-reference.json new file mode 100644 index 00000000..20a97200 --- /dev/null +++ b/packages/mcp-provider-code-analyzer/src/data/pmd/apex-ast-reference.json @@ -0,0 +1,2680 @@ +{ + "description": "PMD Apex AST Nodes - Complete Reference", + "source": "Extracted from PMD 7.x source code", + "extraction_date": "2025-12-03", + "total_nodes": 97, + "version": "7.x", + "note": "Includes inherited attributes from parent classes (marked with inherited_from field)", + "nodes": [ + { + "name": "Annotation", + "description": "Represents an annotation like @AuraEnabled, @TestVisible, @InvocableMethod", + "category": "Modifiers", + "extends": "AbstractApexNode.Single", + "implements": [], + "attributes": [ + { + "name": "@Name", + "type": "string", + "description": "Returns the normalized annotation name for known, valid annotations. The normalized name is in PascalCase. If an unknown annotation is used, the raw name (as in the source code) is returned." + }, + { + "name": "@RawName", + "type": "string", + "description": "Returns the annotation name as it appears in the source code. This allows to verify the casing." + }, + { + "name": "@Image", + "type": "string", + "description": "Returns the annotation name as it appears in the source code. This allows to verify the casing." + }, + { + "name": "@isResolved", + "type": "boolean", + "description": "Returns the annotation name as it appears in the source code. This allows to verify the casing." + }, + { + "name": "@RealLoc", + "type": "boolean", + "description": "Returns true if this node has a real source location", + "inherited_from": "AbstractApexNode.Single" + }, + { + "name": "@DefiningType", + "type": "string", + "description": "Returns the fully qualified name of the enclosing type", + "inherited_from": "AbstractApexNode.Single" + } + ] + }, + { + "name": "AnnotationParameter", + "description": "Represents a parameter of an annotation", + "category": "Declarations", + "extends": "AbstractApexNode.Single", + "implements": [], + "attributes": [ + { + "name": "@Name", + "type": "string", + "description": "The name of this node" + }, + { + "name": "@Value", + "type": "string", + "description": "The value of this node" + }, + { + "name": "@BooleanValue", + "type": "boolean", + "description": "The booleanvalue of this node" + }, + { + "name": "@Image", + "type": "string", + "description": "The image of this node" + }, + { + "name": "@Name", + "type": "boolean", + "description": "Checks whether this annotation parameter has the given name. The check is done case-insensitive.", + "parameters": [ + "@NonNull String name" + ] + }, + { + "name": "@RealLoc", + "type": "boolean", + "description": "Returns true if this node has a real source location", + "inherited_from": "AbstractApexNode.Single" + }, + { + "name": "@DefiningType", + "type": "string", + "description": "Returns the fully qualified name of the enclosing type", + "inherited_from": "AbstractApexNode.Single" + } + ] + }, + { + "name": "AnonymousClass", + "description": "Represents anonymous class", + "category": "Declarations", + "extends": "AbstractApexNode.Single", + "implements": [], + "attributes": [ + { + "name": "@Image", + "type": "string", + "description": "The image of this node" + }, + { + "name": "@RealLoc", + "type": "boolean", + "description": "Returns true if this node has a real source location", + "inherited_from": "AbstractApexNode.Single" + }, + { + "name": "@DefiningType", + "type": "string", + "description": "Returns the fully qualified name of the enclosing type", + "inherited_from": "AbstractApexNode.Single" + } + ] + }, + { + "name": "ApexFile", + "description": "Represents apex file", + "category": "Other", + "extends": "AbstractApexNode.Single", + "implements": [ + "RootNode" + ], + "attributes": [ + { + "name": "@AstInfo", + "type": "string", + "description": "The astinfo of this node" + }, + { + "name": "@MainNode", + "type": "string", + "description": "The mainnode of this node" + }, + { + "name": "@GlobalIssues", + "type": "array", + "description": "The globalissues of this node" + }, + { + "name": "@DefiningType", + "type": "string", + "description": "The definingtype of this node" + }, + { + "name": "@RealLoc", + "type": "boolean", + "description": "Returns true if this node has a real source location", + "inherited_from": "AbstractApexNode.Single" + } + ] + }, + { + "name": "ArrayLoadExpression", + "description": "Represents array load expression", + "category": "Expressions", + "extends": "AbstractApexNode.Single", + "implements": [], + "attributes": [ + { + "name": "@RealLoc", + "type": "boolean", + "description": "Returns true if this node has a real source location", + "inherited_from": "AbstractApexNode.Single" + }, + { + "name": "@DefiningType", + "type": "string", + "description": "Returns the fully qualified name of the enclosing type", + "inherited_from": "AbstractApexNode.Single" + } + ] + }, + { + "name": "ArrayStoreExpression", + "description": "Represents array store expression", + "category": "Expressions", + "extends": "AbstractApexNode.Single", + "implements": [], + "attributes": [ + { + "name": "@RealLoc", + "type": "boolean", + "description": "Returns true if this node has a real source location", + "inherited_from": "AbstractApexNode.Single" + }, + { + "name": "@DefiningType", + "type": "string", + "description": "Returns the fully qualified name of the enclosing type", + "inherited_from": "AbstractApexNode.Single" + } + ] + }, + { + "name": "AssignmentExpression", + "description": "Represents assignment expression", + "category": "Expressions", + "extends": "AbstractApexNode.Single", + "implements": [], + "attributes": [ + { + "name": "@Op", + "type": "string", + "description": "The op of this node" + }, + { + "name": "@RealLoc", + "type": "boolean", + "description": "Returns true if this node has a real source location", + "inherited_from": "AbstractApexNode.Single" + }, + { + "name": "@DefiningType", + "type": "string", + "description": "Returns the fully qualified name of the enclosing type", + "inherited_from": "AbstractApexNode.Single" + } + ] + }, + { + "name": "BinaryExpression", + "description": "Represents a binary expression (operations with two operands like ==, !=, +, -, &&, ||)", + "category": "Expressions", + "extends": "AbstractApexNode.Single", + "implements": [], + "attributes": [ + { + "name": "@Op", + "type": "string", + "description": "The op of this node" + }, + { + "name": "@RealLoc", + "type": "boolean", + "description": "Returns true if this node has a real source location", + "inherited_from": "AbstractApexNode.Single" + }, + { + "name": "@DefiningType", + "type": "string", + "description": "Returns the fully qualified name of the enclosing type", + "inherited_from": "AbstractApexNode.Single" + } + ] + }, + { + "name": "BindExpressions", + "description": "Represents bind expressions", + "category": "Expressions", + "extends": "AbstractApexNode.Single", + "implements": [], + "attributes": [ + { + "name": "@RealLoc", + "type": "boolean", + "description": "Returns true if this node has a real source location", + "inherited_from": "AbstractApexNode.Single" + }, + { + "name": "@DefiningType", + "type": "string", + "description": "Returns the fully qualified name of the enclosing type", + "inherited_from": "AbstractApexNode.Single" + } + ] + }, + { + "name": "BlockStatement", + "description": "Represents block statement", + "category": "Statements", + "extends": "AbstractApexNode.Single", + "implements": [], + "attributes": [ + { + "name": "@CurlyBrace", + "type": "boolean", + "description": "Whether this node hasCurlyBrace" + }, + { + "name": "@RealLoc", + "type": "boolean", + "description": "Returns true if this node has a real source location", + "inherited_from": "AbstractApexNode.Single" + }, + { + "name": "@DefiningType", + "type": "string", + "description": "Returns the fully qualified name of the enclosing type", + "inherited_from": "AbstractApexNode.Single" + } + ] + }, + { + "name": "BooleanExpression", + "description": "Represents boolean expression", + "category": "Expressions", + "extends": "AbstractApexNode.Single", + "implements": [], + "attributes": [ + { + "name": "@Op", + "type": "string", + "description": "The op of this node" + }, + { + "name": "@RealLoc", + "type": "boolean", + "description": "Returns true if this node has a real source location", + "inherited_from": "AbstractApexNode.Single" + }, + { + "name": "@DefiningType", + "type": "string", + "description": "Returns the fully qualified name of the enclosing type", + "inherited_from": "AbstractApexNode.Single" + } + ] + }, + { + "name": "BreakStatement", + "description": "Represents break statement", + "category": "Statements", + "extends": "AbstractApexNode.Single", + "implements": [], + "attributes": [ + { + "name": "@RealLoc", + "type": "boolean", + "description": "Returns true if this node has a real source location", + "inherited_from": "AbstractApexNode.Single" + }, + { + "name": "@DefiningType", + "type": "string", + "description": "Returns the fully qualified name of the enclosing type", + "inherited_from": "AbstractApexNode.Single" + } + ] + }, + { + "name": "CastExpression", + "description": "Represents cast expression", + "category": "Expressions", + "extends": "AbstractApexNode.Single", + "implements": [], + "attributes": [ + { + "name": "@Type", + "type": "string", + "description": "Returns the target type name of the cast expression." + }, + { + "name": "@RealLoc", + "type": "boolean", + "description": "Returns true if this node has a real source location", + "inherited_from": "AbstractApexNode.Single" + }, + { + "name": "@DefiningType", + "type": "string", + "description": "Returns the fully qualified name of the enclosing type", + "inherited_from": "AbstractApexNode.Single" + } + ] + }, + { + "name": "CatchBlockStatement", + "description": "Represents catch block statement", + "category": "Statements", + "extends": "AbstractApexCommentContainerNode", + "implements": [], + "attributes": [ + { + "name": "@ExceptionType", + "type": "string", + "description": "The exceptiontype of this node" + }, + { + "name": "@VariableName", + "type": "string", + "description": "The variablename of this node" + }, + { + "name": "@Body", + "type": "string", + "description": "The body of this node" + }, + { + "name": "@ContainsComment", + "type": "boolean", + "description": "Returns true if this node contains a comment", + "inherited_from": "AbstractApexCommentContainerNode" + }, + { + "name": "@RealLoc", + "type": "boolean", + "description": "Returns true if this node has a real source location", + "inherited_from": "AbstractApexNode.Single" + }, + { + "name": "@DefiningType", + "type": "string", + "description": "Returns the fully qualified name of the enclosing type", + "inherited_from": "AbstractApexNode.Single" + } + ] + }, + { + "name": "ClassRefExpression", + "description": "Represents class ref expression", + "category": "Declarations", + "extends": "AbstractApexNode.Single", + "implements": [], + "attributes": [ + { + "name": "@RealLoc", + "type": "boolean", + "description": "Returns true if this node has a real source location", + "inherited_from": "AbstractApexNode.Single" + }, + { + "name": "@DefiningType", + "type": "string", + "description": "Returns the fully qualified name of the enclosing type", + "inherited_from": "AbstractApexNode.Single" + } + ] + }, + { + "name": "ConstructorPreamble", + "description": "Represents constructor preamble", + "category": "Declarations", + "extends": "AbstractApexNode.Single", + "implements": [], + "attributes": [ + { + "name": "@RealLoc", + "type": "boolean", + "description": "Returns true if this node has a real source location", + "inherited_from": "AbstractApexNode.Single" + }, + { + "name": "@DefiningType", + "type": "string", + "description": "Returns the fully qualified name of the enclosing type", + "inherited_from": "AbstractApexNode.Single" + } + ] + }, + { + "name": "ConstructorPreambleStatement", + "description": "Represents constructor preamble statement", + "category": "Declarations", + "extends": "AbstractApexNode.Single", + "implements": [], + "attributes": [ + { + "name": "@RealLoc", + "type": "boolean", + "description": "Returns true if this node has a real source location", + "inherited_from": "AbstractApexNode.Single" + }, + { + "name": "@DefiningType", + "type": "string", + "description": "Returns the fully qualified name of the enclosing type", + "inherited_from": "AbstractApexNode.Single" + } + ] + }, + { + "name": "ContinueStatement", + "description": "Represents continue statement", + "category": "Statements", + "extends": "AbstractApexNode.Single", + "implements": [], + "attributes": [ + { + "name": "@RealLoc", + "type": "boolean", + "description": "Returns true if this node has a real source location", + "inherited_from": "AbstractApexNode.Single" + }, + { + "name": "@DefiningType", + "type": "string", + "description": "Returns the fully qualified name of the enclosing type", + "inherited_from": "AbstractApexNode.Single" + } + ] + }, + { + "name": "DmlDeleteStatement", + "description": "Represents a DML delete operation", + "category": "Statements", + "extends": "AbstractDmlStatement", + "implements": [], + "attributes": [ + { + "name": "@RealLoc", + "type": "boolean", + "description": "Returns true if this node has a real source location", + "inherited_from": "AbstractApexNode.Single" + }, + { + "name": "@DefiningType", + "type": "string", + "description": "Returns the fully qualified name of the enclosing type", + "inherited_from": "AbstractApexNode.Single" + } + ] + }, + { + "name": "DmlInsertStatement", + "description": "Represents a DML insert operation", + "category": "Statements", + "extends": "AbstractDmlStatement", + "implements": [], + "attributes": [ + { + "name": "@RealLoc", + "type": "boolean", + "description": "Returns true if this node has a real source location", + "inherited_from": "AbstractApexNode.Single" + }, + { + "name": "@DefiningType", + "type": "string", + "description": "Returns the fully qualified name of the enclosing type", + "inherited_from": "AbstractApexNode.Single" + } + ] + }, + { + "name": "DmlMergeStatement", + "description": "Represents dml merge statement", + "category": "Statements", + "extends": "AbstractDmlStatement", + "implements": [], + "attributes": [ + { + "name": "@RealLoc", + "type": "boolean", + "description": "Returns true if this node has a real source location", + "inherited_from": "AbstractApexNode.Single" + }, + { + "name": "@DefiningType", + "type": "string", + "description": "Returns the fully qualified name of the enclosing type", + "inherited_from": "AbstractApexNode.Single" + } + ] + }, + { + "name": "DmlUndeleteStatement", + "description": "Represents dml undelete statement", + "category": "Statements", + "extends": "AbstractDmlStatement", + "implements": [], + "attributes": [ + { + "name": "@RealLoc", + "type": "boolean", + "description": "Returns true if this node has a real source location", + "inherited_from": "AbstractApexNode.Single" + }, + { + "name": "@DefiningType", + "type": "string", + "description": "Returns the fully qualified name of the enclosing type", + "inherited_from": "AbstractApexNode.Single" + } + ] + }, + { + "name": "DmlUpdateStatement", + "description": "Represents a DML update operation", + "category": "Statements", + "extends": "AbstractDmlStatement", + "implements": [], + "attributes": [ + { + "name": "@RealLoc", + "type": "boolean", + "description": "Returns true if this node has a real source location", + "inherited_from": "AbstractApexNode.Single" + }, + { + "name": "@DefiningType", + "type": "string", + "description": "Returns the fully qualified name of the enclosing type", + "inherited_from": "AbstractApexNode.Single" + } + ] + }, + { + "name": "DmlUpsertStatement", + "description": "Represents dml upsert statement", + "category": "Statements", + "extends": "AbstractDmlStatement", + "implements": [], + "attributes": [ + { + "name": "@RealLoc", + "type": "boolean", + "description": "Returns true if this node has a real source location", + "inherited_from": "AbstractApexNode.Single" + }, + { + "name": "@DefiningType", + "type": "string", + "description": "Returns the fully qualified name of the enclosing type", + "inherited_from": "AbstractApexNode.Single" + } + ] + }, + { + "name": "DoLoopStatement", + "description": "Represents do loop statement", + "category": "Statements", + "extends": "AbstractApexNode.Single", + "implements": [], + "attributes": [ + { + "name": "@RealLoc", + "type": "boolean", + "description": "Returns true if this node has a real source location", + "inherited_from": "AbstractApexNode.Single" + }, + { + "name": "@DefiningType", + "type": "string", + "description": "Returns the fully qualified name of the enclosing type", + "inherited_from": "AbstractApexNode.Single" + } + ] + }, + { + "name": "ElseWhenBlock", + "description": "Represents else when block", + "category": "Statements", + "extends": "AbstractApexNode.Single", + "implements": [], + "attributes": [ + { + "name": "@RealLoc", + "type": "boolean", + "description": "Returns true if this node has a real source location", + "inherited_from": "AbstractApexNode.Single" + }, + { + "name": "@DefiningType", + "type": "string", + "description": "Returns the fully qualified name of the enclosing type", + "inherited_from": "AbstractApexNode.Single" + } + ] + }, + { + "name": "EmptyReferenceExpression", + "description": "Represents empty reference expression", + "category": "Expressions", + "extends": "AbstractApexNode.Empty", + "implements": [], + "attributes": [ + { + "name": "@DefiningType", + "type": "string", + "description": "The definingtype of this node" + }, + { + "name": "@RealLoc", + "type": "boolean", + "description": "Returns true if this node has a real source location", + "inherited_from": "AbstractApexNode.Empty" + } + ] + }, + { + "name": "Expression", + "description": "Represents expression", + "category": "Expressions", + "extends": "AbstractApexNode.Single", + "implements": [], + "attributes": [ + { + "name": "@RealLoc", + "type": "boolean", + "description": "Returns true if this node has a real source location", + "inherited_from": "AbstractApexNode.Single" + }, + { + "name": "@DefiningType", + "type": "string", + "description": "Returns the fully qualified name of the enclosing type", + "inherited_from": "AbstractApexNode.Single" + } + ] + }, + { + "name": "ExpressionStatement", + "description": "Represents expression statement", + "category": "Expressions", + "extends": "AbstractApexNode.Single", + "implements": [], + "attributes": [ + { + "name": "@RealLoc", + "type": "boolean", + "description": "Returns true if this node has a real source location", + "inherited_from": "AbstractApexNode.Single" + }, + { + "name": "@DefiningType", + "type": "string", + "description": "Returns the fully qualified name of the enclosing type", + "inherited_from": "AbstractApexNode.Single" + } + ] + }, + { + "name": "Field", + "description": "Represents field", + "category": "Declarations", + "extends": "AbstractApexNode.Many", + "implements": [], + "attributes": [ + { + "name": "@Image", + "type": "string", + "description": "The image of this node" + }, + { + "name": "@Type", + "type": "string", + "description": "Returns the type name.

This includes any type arguments. (This is tested.) If the type is a primitive, its case will be normalized." + }, + { + "name": "@Modifiers", + "type": "node", + "description": "Returns the type name.

This includes any type arguments. (This is tested.) If the type is a primitive, its case will be normalized." + }, + { + "name": "@Name", + "type": "string", + "description": "Returns the type name.

This includes any type arguments. (This is tested.) If the type is a primitive, its case will be normalized." + }, + { + "name": "@Value", + "type": "string", + "description": "Returns the type name.

This includes any type arguments. (This is tested.) If the type is a primitive, its case will be normalized." + }, + { + "name": "@RealLoc", + "type": "boolean", + "description": "Returns the type name.

This includes any type arguments. (This is tested.) If the type is a primitive, its case will be normalized." + }, + { + "name": "@DefiningType", + "type": "string", + "description": "Returns the fully qualified name of the enclosing type", + "inherited_from": "AbstractApexNode.Many" + } + ] + }, + { + "name": "FieldDeclaration", + "description": "Represents field declaration", + "category": "Declarations", + "extends": "AbstractApexNode.Single", + "implements": [], + "attributes": [ + { + "name": "@Image", + "type": "string", + "description": "The image of this node" + }, + { + "name": "@Name", + "type": "string", + "description": "The name of this node" + }, + { + "name": "@RealLoc", + "type": "boolean", + "description": "Returns true if this node has a real source location", + "inherited_from": "AbstractApexNode.Single" + }, + { + "name": "@DefiningType", + "type": "string", + "description": "Returns the fully qualified name of the enclosing type", + "inherited_from": "AbstractApexNode.Single" + } + ] + }, + { + "name": "FieldDeclarationStatements", + "description": "Represents field declaration statements", + "category": "Declarations", + "extends": "AbstractApexNode.Single", + "implements": [], + "attributes": [ + { + "name": "@Modifiers", + "type": "node", + "description": "The modifiers of this node" + }, + { + "name": "@TypeName", + "type": "string", + "description": "Returns the type name.

This includes any type arguments. If the type is a primitive, its case will be normalized." + }, + { + "name": "@TypeArguments", + "type": "array", + "description": "This returns the first level of the type arguments. If there are nested types (e.g. {@code List>}), then these returned types contain themselves type arguments.

Note: This method only exists for this AST type and in no other type, even though type arguments are possible e.g. for {@link ASTVariableDeclaration#getType()}." + }, + { + "name": "@RealLoc", + "type": "boolean", + "description": "Returns true if this node has a real source location", + "inherited_from": "AbstractApexNode.Single" + }, + { + "name": "@DefiningType", + "type": "string", + "description": "Returns the fully qualified name of the enclosing type", + "inherited_from": "AbstractApexNode.Single" + } + ] + }, + { + "name": "ForEachStatement", + "description": "Represents for each statement", + "category": "Statements", + "extends": "AbstractApexNode.Single", + "implements": [], + "attributes": [ + { + "name": "@RealLoc", + "type": "boolean", + "description": "Returns true if this node has a real source location", + "inherited_from": "AbstractApexNode.Single" + }, + { + "name": "@DefiningType", + "type": "string", + "description": "Returns the fully qualified name of the enclosing type", + "inherited_from": "AbstractApexNode.Single" + } + ] + }, + { + "name": "ForLoopStatement", + "description": "Represents a for loop statement", + "category": "Statements", + "extends": "AbstractApexNode.Single", + "implements": [], + "attributes": [ + { + "name": "@RealLoc", + "type": "boolean", + "description": "Returns true if this node has a real source location", + "inherited_from": "AbstractApexNode.Single" + }, + { + "name": "@DefiningType", + "type": "string", + "description": "Returns the fully qualified name of the enclosing type", + "inherited_from": "AbstractApexNode.Single" + } + ] + }, + { + "name": "FormalComment", + "description": "Represents formal comment", + "category": "Statements", + "extends": "AbstractApexNode.Empty", + "implements": [], + "attributes": [ + { + "name": "@Image", + "type": "string", + "description": "The image of this node" + }, + { + "name": "@RealLoc", + "type": "boolean", + "description": "Returns true if this node has a real source location", + "inherited_from": "AbstractApexNode.Empty" + }, + { + "name": "@DefiningType", + "type": "string", + "description": "Returns the fully qualified name of the enclosing type", + "inherited_from": "AbstractApexNode.Empty" + } + ] + }, + { + "name": "IdentifierCase", + "description": "Represents identifier case", + "category": "Other", + "extends": "AbstractApexNode.Single", + "implements": [], + "attributes": [ + { + "name": "@RealLoc", + "type": "boolean", + "description": "Returns true if this node has a real source location", + "inherited_from": "AbstractApexNode.Single" + }, + { + "name": "@DefiningType", + "type": "string", + "description": "Returns the fully qualified name of the enclosing type", + "inherited_from": "AbstractApexNode.Single" + } + ] + }, + { + "name": "IfBlockStatement", + "description": "Represents an if statement block", + "category": "Statements", + "extends": "AbstractApexNode.Single", + "implements": [], + "attributes": [ + { + "name": "@RealLoc", + "type": "boolean", + "description": "Returns true if this node has a real source location", + "inherited_from": "AbstractApexNode.Single" + }, + { + "name": "@DefiningType", + "type": "string", + "description": "Returns the fully qualified name of the enclosing type", + "inherited_from": "AbstractApexNode.Single" + } + ] + }, + { + "name": "IfElseBlockStatement", + "description": "Represents if else block statement", + "category": "Statements", + "extends": "AbstractApexNode.Single", + "implements": [], + "attributes": [ + { + "name": "@ElseStatement", + "type": "boolean", + "description": "Whether this node hasElseStatement" + }, + { + "name": "@RealLoc", + "type": "boolean", + "description": "Returns true if this node has a real source location", + "inherited_from": "AbstractApexNode.Single" + }, + { + "name": "@DefiningType", + "type": "string", + "description": "Returns the fully qualified name of the enclosing type", + "inherited_from": "AbstractApexNode.Single" + } + ] + }, + { + "name": "IllegalStoreExpression", + "description": "Represents illegal store expression", + "category": "Expressions", + "extends": "AbstractApexNode.Single", + "implements": [], + "attributes": [ + { + "name": "@RealLoc", + "type": "boolean", + "description": "Returns true if this node has a real source location", + "inherited_from": "AbstractApexNode.Single" + }, + { + "name": "@DefiningType", + "type": "string", + "description": "Returns the fully qualified name of the enclosing type", + "inherited_from": "AbstractApexNode.Single" + } + ] + }, + { + "name": "InstanceOfExpression", + "description": "Represents instance of expression", + "category": "Expressions", + "extends": "AbstractApexNode.Single", + "implements": [], + "attributes": [ + { + "name": "@RealLoc", + "type": "boolean", + "description": "Returns true if this node has a real source location", + "inherited_from": "AbstractApexNode.Single" + }, + { + "name": "@DefiningType", + "type": "string", + "description": "Returns the fully qualified name of the enclosing type", + "inherited_from": "AbstractApexNode.Single" + } + ] + }, + { + "name": "InvalidDependentCompilation", + "description": "Represents invalid dependent compilation", + "category": "Other", + "extends": "AbstractApexNode.Single", + "implements": [], + "attributes": [ + { + "name": "@Image", + "type": "string", + "description": "The image of this node" + }, + { + "name": "@RealLoc", + "type": "boolean", + "description": "Returns true if this node has a real source location", + "inherited_from": "AbstractApexNode.Single" + }, + { + "name": "@DefiningType", + "type": "string", + "description": "Returns the fully qualified name of the enclosing type", + "inherited_from": "AbstractApexNode.Single" + } + ] + }, + { + "name": "JavaMethodCallExpression", + "description": "Represents java method call expression", + "category": "Declarations", + "extends": "AbstractApexNode.Single", + "implements": [], + "attributes": [ + { + "name": "@RealLoc", + "type": "boolean", + "description": "Returns true if this node has a real source location", + "inherited_from": "AbstractApexNode.Single" + }, + { + "name": "@DefiningType", + "type": "string", + "description": "Returns the fully qualified name of the enclosing type", + "inherited_from": "AbstractApexNode.Single" + } + ] + }, + { + "name": "JavaVariableExpression", + "description": "Represents java variable expression", + "category": "Declarations", + "extends": "AbstractApexNode.Single", + "implements": [], + "attributes": [ + { + "name": "@RealLoc", + "type": "boolean", + "description": "Returns true if this node has a real source location", + "inherited_from": "AbstractApexNode.Single" + }, + { + "name": "@DefiningType", + "type": "string", + "description": "Returns the fully qualified name of the enclosing type", + "inherited_from": "AbstractApexNode.Single" + } + ] + }, + { + "name": "LiteralCase", + "description": "Represents literal case", + "category": "Expressions", + "extends": "AbstractApexNode.Single", + "implements": [], + "attributes": [ + { + "name": "@RealLoc", + "type": "boolean", + "description": "Returns true if this node has a real source location", + "inherited_from": "AbstractApexNode.Single" + }, + { + "name": "@DefiningType", + "type": "string", + "description": "Returns the fully qualified name of the enclosing type", + "inherited_from": "AbstractApexNode.Single" + } + ] + }, + { + "name": "LiteralExpression", + "description": "Represents a literal value (string, number, boolean, null)", + "category": "Expressions", + "extends": "AbstractApexNode.Single", + "implements": [], + "attributes": [ + { + "name": "@LiteralType", + "type": "string", + "description": "The literaltype of this node" + }, + { + "name": "@isString", + "type": "boolean", + "description": "Whether this node isString" + }, + { + "name": "@isBoolean", + "type": "boolean", + "description": "Whether this node isBoolean" + }, + { + "name": "@isInteger", + "type": "boolean", + "description": "Whether this node isInteger" + }, + { + "name": "@isDouble", + "type": "boolean", + "description": "Whether this node isDouble" + }, + { + "name": "@isLong", + "type": "boolean", + "description": "Whether this node isLong" + }, + { + "name": "@isDecimal", + "type": "boolean", + "description": "Whether this node isDecimal" + }, + { + "name": "@isNull", + "type": "boolean", + "description": "Whether this node isNull" + }, + { + "name": "@Image", + "type": "string", + "description": "The image of this node" + }, + { + "name": "@Name", + "type": "string", + "description": "Returns the name of this literal when it is labeled in an object initializer with named arguments ({@link ASTNewKeyValueObjectExpression}).

For example, in the Apex code

{@code new X(a = 1, b = 2) }
, the {@link ASTLiteralExpression} corresponding to {@code 2} will have the {@code name} \"{@code b}\"." + }, + { + "name": "@RealLoc", + "type": "boolean", + "description": "Returns true if this node has a real source location", + "inherited_from": "AbstractApexNode.Single" + }, + { + "name": "@DefiningType", + "type": "string", + "description": "Returns the fully qualified name of the enclosing type", + "inherited_from": "AbstractApexNode.Single" + } + ] + }, + { + "name": "MapEntryNode", + "description": "Represents map entry node", + "category": "Other", + "extends": "AbstractApexNode.Many", + "implements": [], + "attributes": [ + { + "name": "@Image", + "type": "string", + "description": "The image of this node" + }, + { + "name": "@RealLoc", + "type": "boolean", + "description": "Returns true if this node has a real source location", + "inherited_from": "AbstractApexNode.Many" + }, + { + "name": "@DefiningType", + "type": "string", + "description": "Returns the fully qualified name of the enclosing type", + "inherited_from": "AbstractApexNode.Many" + } + ] + }, + { + "name": "Method", + "description": "Represents a method declaration", + "category": "Declarations", + "extends": "AbstractApexNode", + "implements": [ + "ApexQualifiableNode" + ], + "attributes": [ + { + "name": "@RealLoc", + "type": "boolean", + "description": "Internal name used by the synthetic trigger method." + }, + { + "name": "@Image", + "type": "string", + "description": "Internal name used by the synthetic trigger method." + }, + { + "name": "@CanonicalName", + "type": "string", + "description": "Internal name used by the synthetic trigger method." + }, + { + "name": "@QualifiedName", + "type": "string", + "description": "Internal name used by the synthetic trigger method." + }, + { + "name": "@isConstructor", + "type": "boolean", + "description": "Internal name used by the synthetic trigger method." + }, + { + "name": "@isStaticInitializer", + "type": "boolean", + "description": "Internal name used by the synthetic trigger method." + }, + { + "name": "@Modifiers", + "type": "node", + "description": "Internal name used by the synthetic trigger method." + }, + { + "name": "@ReturnType", + "type": "string", + "description": "Returns the method return type name. This includes any type arguments. If the type is a primitive, its case will be normalized." + }, + { + "name": "@Arity", + "type": "integer", + "description": "Returns the method return type name. This includes any type arguments. If the type is a primitive, its case will be normalized." + }, + { + "name": "@isTriggerBlock", + "type": "boolean", + "description": "Checks whether this method is the synthetic trigger method." + }, + { + "name": "@DefiningType", + "type": "string", + "description": "Returns the fully qualified name of the enclosing type", + "inherited_from": "AbstractApexNode" + } + ] + }, + { + "name": "MethodBlockStatement", + "description": "Represents method block statement", + "category": "Declarations", + "extends": "AbstractApexNode.Single", + "implements": [], + "attributes": [ + { + "name": "@RealLoc", + "type": "boolean", + "description": "Returns true if this node has a real source location", + "inherited_from": "AbstractApexNode.Single" + }, + { + "name": "@DefiningType", + "type": "string", + "description": "Returns the fully qualified name of the enclosing type", + "inherited_from": "AbstractApexNode.Single" + } + ] + }, + { + "name": "MethodCallExpression", + "description": "Represents a method call expression", + "category": "Declarations", + "extends": "AbstractApexNode.Single", + "implements": [], + "attributes": [ + { + "name": "@MethodName", + "type": "string", + "description": "The {@link Identifier}s that constitute the {@link CallExpression#getReceiver() receiver} of this method call." + }, + { + "name": "@FullMethodName", + "type": "string", + "description": "The {@link Identifier}s that constitute the {@link CallExpression#getReceiver() receiver} of this method call." + }, + { + "name": "@InputParametersSize", + "type": "integer", + "description": "The {@link Identifier}s that constitute the {@link CallExpression#getReceiver() receiver} of this method call." + }, + { + "name": "@RealLoc", + "type": "boolean", + "description": "Returns true if this node has a real source location", + "inherited_from": "AbstractApexNode.Single" + }, + { + "name": "@DefiningType", + "type": "string", + "description": "Returns the fully qualified name of the enclosing type", + "inherited_from": "AbstractApexNode.Single" + } + ] + }, + { + "name": "Modifier", + "description": "Represents modifier", + "category": "Modifiers", + "extends": "AbstractApexNode.Single", + "implements": [], + "attributes": [ + { + "name": "@RealLoc", + "type": "boolean", + "description": "Returns true if this node has a real source location", + "inherited_from": "AbstractApexNode.Single" + }, + { + "name": "@DefiningType", + "type": "string", + "description": "Returns the fully qualified name of the enclosing type", + "inherited_from": "AbstractApexNode.Single" + } + ] + }, + { + "name": "ModifierNode", + "description": "Represents modifier node", + "category": "Modifiers", + "extends": "AbstractApexNode.Many", + "implements": [ + "AccessNode" + ], + "attributes": [ + { + "name": "@Modifiers", + "type": "integer", + "description": "The modifiers of this node" + }, + { + "name": "@isPublic", + "type": "boolean", + "description": "Whether this node isPublic" + }, + { + "name": "@isProtected", + "type": "boolean", + "description": "Whether this node isProtected" + }, + { + "name": "@isPrivate", + "type": "boolean", + "description": "Whether this node isPrivate" + }, + { + "name": "@isAbstract", + "type": "boolean", + "description": "Whether this node isAbstract" + }, + { + "name": "@isStatic", + "type": "boolean", + "description": "Whether this node isStatic" + }, + { + "name": "@isFinal", + "type": "boolean", + "description": "Whether this node isFinal" + }, + { + "name": "@isTransient", + "type": "boolean", + "description": "Whether this node isTransient" + }, + { + "name": "@isTest", + "type": "boolean", + "description": "Returns true if function has `@isTest` annotation or `testmethod` modifier" + }, + { + "name": "@DeprecatedTestMethod", + "type": "boolean", + "description": "Returns true if function has `testmethod` modifier" + }, + { + "name": "@isTestOrTestSetup", + "type": "boolean", + "description": "Returns true if function has `testmethod` modifier" + }, + { + "name": "@isWithSharing", + "type": "boolean", + "description": "Returns true if function has `testmethod` modifier" + }, + { + "name": "@isWithoutSharing", + "type": "boolean", + "description": "Returns true if function has `testmethod` modifier" + }, + { + "name": "@isInheritedSharing", + "type": "boolean", + "description": "Returns true if function has `testmethod` modifier" + }, + { + "name": "@isWebService", + "type": "boolean", + "description": "Returns true if function has `testmethod` modifier" + }, + { + "name": "@isGlobal", + "type": "boolean", + "description": "Returns true if function has `testmethod` modifier" + }, + { + "name": "@isOverride", + "type": "boolean", + "description": "Returns true if function has `testmethod` modifier" + }, + { + "name": "@isVirtual", + "type": "boolean", + "description": "Returns true if function has `testmethod` modifier" + }, + { + "name": "@RealLoc", + "type": "boolean", + "description": "Returns true if this node has a real source location", + "inherited_from": "AbstractApexNode.Many" + }, + { + "name": "@DefiningType", + "type": "string", + "description": "Returns the fully qualified name of the enclosing type", + "inherited_from": "AbstractApexNode.Many" + } + ] + }, + { + "name": "ModifierOrAnnotation", + "description": "Represents modifier or annotation", + "category": "Modifiers", + "extends": "AbstractApexNode.Single", + "implements": [], + "attributes": [ + { + "name": "@RealLoc", + "type": "boolean", + "description": "Returns true if this node has a real source location", + "inherited_from": "AbstractApexNode.Single" + }, + { + "name": "@DefiningType", + "type": "string", + "description": "Returns the fully qualified name of the enclosing type", + "inherited_from": "AbstractApexNode.Single" + } + ] + }, + { + "name": "MultiStatement", + "description": "Represents multi statement", + "category": "Statements", + "extends": "AbstractApexNode.Single", + "implements": [], + "attributes": [ + { + "name": "@RealLoc", + "type": "boolean", + "description": "Returns true if this node has a real source location", + "inherited_from": "AbstractApexNode.Single" + }, + { + "name": "@DefiningType", + "type": "string", + "description": "Returns the fully qualified name of the enclosing type", + "inherited_from": "AbstractApexNode.Single" + } + ] + }, + { + "name": "NestedExpression", + "description": "Represents nested expression", + "category": "Expressions", + "extends": "AbstractApexNode.Single", + "implements": [], + "attributes": [ + { + "name": "@RealLoc", + "type": "boolean", + "description": "Returns true if this node has a real source location", + "inherited_from": "AbstractApexNode.Single" + }, + { + "name": "@DefiningType", + "type": "string", + "description": "Returns the fully qualified name of the enclosing type", + "inherited_from": "AbstractApexNode.Single" + } + ] + }, + { + "name": "NestedStoreExpression", + "description": "Represents nested store expression", + "category": "Expressions", + "extends": "AbstractApexNode.Single", + "implements": [], + "attributes": [ + { + "name": "@RealLoc", + "type": "boolean", + "description": "Returns true if this node has a real source location", + "inherited_from": "AbstractApexNode.Single" + }, + { + "name": "@DefiningType", + "type": "string", + "description": "Returns the fully qualified name of the enclosing type", + "inherited_from": "AbstractApexNode.Single" + } + ] + }, + { + "name": "NewKeyValueObjectExpression", + "description": "Represents new key value object expression", + "category": "Expressions", + "extends": "AbstractApexNode.Single", + "implements": [], + "attributes": [ + { + "name": "@Type", + "type": "string", + "description": "Returns the type name. This includes any type arguments. If the type is a primitive, its case will be normalized." + }, + { + "name": "@ParameterCount", + "type": "integer", + "description": "Returns the type name. This includes any type arguments. If the type is a primitive, its case will be normalized." + }, + { + "name": "@RealLoc", + "type": "boolean", + "description": "Returns true if this node has a real source location", + "inherited_from": "AbstractApexNode.Single" + }, + { + "name": "@DefiningType", + "type": "string", + "description": "Returns the fully qualified name of the enclosing type", + "inherited_from": "AbstractApexNode.Single" + } + ] + }, + { + "name": "NewListInitExpression", + "description": "Represents new list init expression", + "category": "Expressions", + "extends": "AbstractApexNode.Single", + "implements": [], + "attributes": [ + { + "name": "@RealLoc", + "type": "boolean", + "description": "Returns true if this node has a real source location", + "inherited_from": "AbstractApexNode.Single" + }, + { + "name": "@DefiningType", + "type": "string", + "description": "Returns the fully qualified name of the enclosing type", + "inherited_from": "AbstractApexNode.Single" + } + ] + }, + { + "name": "NewListLiteralExpression", + "description": "Represents new list literal expression", + "category": "Expressions", + "extends": "AbstractApexNode.Single", + "implements": [], + "attributes": [ + { + "name": "@RealLoc", + "type": "boolean", + "description": "Returns true if this node has a real source location", + "inherited_from": "AbstractApexNode.Single" + }, + { + "name": "@DefiningType", + "type": "string", + "description": "Returns the fully qualified name of the enclosing type", + "inherited_from": "AbstractApexNode.Single" + } + ] + }, + { + "name": "NewMapInitExpression", + "description": "Represents new map init expression", + "category": "Expressions", + "extends": "AbstractApexNode.Single", + "implements": [], + "attributes": [ + { + "name": "@RealLoc", + "type": "boolean", + "description": "Returns true if this node has a real source location", + "inherited_from": "AbstractApexNode.Single" + }, + { + "name": "@DefiningType", + "type": "string", + "description": "Returns the fully qualified name of the enclosing type", + "inherited_from": "AbstractApexNode.Single" + } + ] + }, + { + "name": "NewMapLiteralExpression", + "description": "Represents new map literal expression", + "category": "Expressions", + "extends": "AbstractApexNode.Single", + "implements": [], + "attributes": [ + { + "name": "@RealLoc", + "type": "boolean", + "description": "Returns true if this node has a real source location", + "inherited_from": "AbstractApexNode.Single" + }, + { + "name": "@DefiningType", + "type": "string", + "description": "Returns the fully qualified name of the enclosing type", + "inherited_from": "AbstractApexNode.Single" + } + ] + }, + { + "name": "NewObjectExpression", + "description": "Represents new object expression", + "category": "Expressions", + "extends": "AbstractApexNode.Single", + "implements": [], + "attributes": [ + { + "name": "@Type", + "type": "string", + "description": "Returns the type name. This includes any type arguments. If the type is a primitive, its case will be normalized." + }, + { + "name": "@RealLoc", + "type": "boolean", + "description": "Returns true if this node has a real source location", + "inherited_from": "AbstractApexNode.Single" + }, + { + "name": "@DefiningType", + "type": "string", + "description": "Returns the fully qualified name of the enclosing type", + "inherited_from": "AbstractApexNode.Single" + } + ] + }, + { + "name": "NewSetInitExpression", + "description": "Represents new set init expression", + "category": "Expressions", + "extends": "AbstractApexNode.Single", + "implements": [], + "attributes": [ + { + "name": "@RealLoc", + "type": "boolean", + "description": "Returns true if this node has a real source location", + "inherited_from": "AbstractApexNode.Single" + }, + { + "name": "@DefiningType", + "type": "string", + "description": "Returns the fully qualified name of the enclosing type", + "inherited_from": "AbstractApexNode.Single" + } + ] + }, + { + "name": "NewSetLiteralExpression", + "description": "Represents new set literal expression", + "category": "Expressions", + "extends": "AbstractApexNode.Single", + "implements": [], + "attributes": [ + { + "name": "@RealLoc", + "type": "boolean", + "description": "Returns true if this node has a real source location", + "inherited_from": "AbstractApexNode.Single" + }, + { + "name": "@DefiningType", + "type": "string", + "description": "Returns the fully qualified name of the enclosing type", + "inherited_from": "AbstractApexNode.Single" + } + ] + }, + { + "name": "PackageVersionExpression", + "description": "Represents package version expression", + "category": "Expressions", + "extends": "AbstractApexNode.Single", + "implements": [], + "attributes": [ + { + "name": "@RealLoc", + "type": "boolean", + "description": "Returns true if this node has a real source location", + "inherited_from": "AbstractApexNode.Single" + }, + { + "name": "@DefiningType", + "type": "string", + "description": "Returns the fully qualified name of the enclosing type", + "inherited_from": "AbstractApexNode.Single" + } + ] + }, + { + "name": "Parameter", + "description": "Represents parameter", + "category": "Declarations", + "extends": "AbstractApexNode.Single", + "implements": [], + "attributes": [ + { + "name": "@Image", + "type": "string", + "description": "The image of this node" + }, + { + "name": "@Modifiers", + "type": "node", + "description": "The modifiers of this node" + }, + { + "name": "@Type", + "type": "string", + "description": "Returns the parameter's type name.

This includes any type arguments. If the type is a primitive, its case will be normalized." + }, + { + "name": "@RealLoc", + "type": "boolean", + "description": "Returns true if this node has a real source location", + "inherited_from": "AbstractApexNode.Single" + }, + { + "name": "@DefiningType", + "type": "string", + "description": "Returns the fully qualified name of the enclosing type", + "inherited_from": "AbstractApexNode.Single" + } + ] + }, + { + "name": "PostfixExpression", + "description": "Represents postfix expression", + "category": "Expressions", + "extends": "AbstractApexNode.Single", + "implements": [], + "attributes": [ + { + "name": "@Op", + "type": "string", + "description": "The op of this node" + }, + { + "name": "@RealLoc", + "type": "boolean", + "description": "Returns true if this node has a real source location", + "inherited_from": "AbstractApexNode.Single" + }, + { + "name": "@DefiningType", + "type": "string", + "description": "Returns the fully qualified name of the enclosing type", + "inherited_from": "AbstractApexNode.Single" + } + ] + }, + { + "name": "PrefixExpression", + "description": "Represents prefix expression", + "category": "Expressions", + "extends": "AbstractApexNode.Single", + "implements": [], + "attributes": [ + { + "name": "@Op", + "type": "string", + "description": "The op of this node" + }, + { + "name": "@RealLoc", + "type": "boolean", + "description": "Returns true if this node has a real source location", + "inherited_from": "AbstractApexNode.Single" + }, + { + "name": "@DefiningType", + "type": "string", + "description": "Returns the fully qualified name of the enclosing type", + "inherited_from": "AbstractApexNode.Single" + } + ] + }, + { + "name": "Property", + "description": "Represents property", + "category": "Declarations", + "extends": "AbstractApexNode.Single", + "implements": [], + "attributes": [ + { + "name": "@Type", + "type": "string", + "description": "Returns the property value's type name. This includes any type arguments. If the type is a primitive, its case will be normalized." + }, + { + "name": "@Modifiers", + "type": "node", + "description": "Returns the property value's type name. This includes any type arguments. If the type is a primitive, its case will be normalized." + }, + { + "name": "@FormatAccessorName", + "type": "string", + "description": "Returns the internal accessor (getter/setter) name of an {@link ASTProperty}. The accessor name is the constant {@link #ACCESSOR_PREFIX} prepended to the name of the property.", + "parameters": [ + "ASTProperty property" + ] + }, + { + "name": "@RealLoc", + "type": "boolean", + "description": "Returns true if this node has a real source location", + "inherited_from": "AbstractApexNode.Single" + }, + { + "name": "@DefiningType", + "type": "string", + "description": "Returns the fully qualified name of the enclosing type", + "inherited_from": "AbstractApexNode.Single" + } + ] + }, + { + "name": "ReferenceExpression", + "description": "Represents reference expression", + "category": "Expressions", + "extends": "AbstractApexNode.Many", + "implements": [], + "attributes": [ + { + "name": "@ReferenceType", + "type": "string", + "description": "The referencetype of this node" + }, + { + "name": "@Image", + "type": "string", + "description": "The image of this node" + }, + { + "name": "@Names", + "type": "array", + "description": "The names of this node" + }, + { + "name": "@isSafeNav", + "type": "boolean", + "description": "Whether this node isSafeNav" + }, + { + "name": "@isSObjectType", + "type": "boolean", + "description": "Whether this node isSObjectType" + }, + { + "name": "@RealLoc", + "type": "boolean", + "description": "Whether this node hasRealLoc" + }, + { + "name": "@DefiningType", + "type": "string", + "description": "Returns the fully qualified name of the enclosing type", + "inherited_from": "AbstractApexNode.Many" + } + ] + }, + { + "name": "ReturnStatement", + "description": "Represents a return statement", + "category": "Statements", + "extends": "AbstractApexNode.Single", + "implements": [], + "attributes": [ + { + "name": "@RealLoc", + "type": "boolean", + "description": "Returns true if this node has a real source location", + "inherited_from": "AbstractApexNode.Single" + }, + { + "name": "@DefiningType", + "type": "string", + "description": "Returns the fully qualified name of the enclosing type", + "inherited_from": "AbstractApexNode.Single" + } + ] + }, + { + "name": "RunAsBlockStatement", + "description": "Represents run as block statement", + "category": "Statements", + "extends": "AbstractApexNode.Single", + "implements": [], + "attributes": [ + { + "name": "@RealLoc", + "type": "boolean", + "description": "Returns true if this node has a real source location", + "inherited_from": "AbstractApexNode.Single" + }, + { + "name": "@DefiningType", + "type": "string", + "description": "Returns the fully qualified name of the enclosing type", + "inherited_from": "AbstractApexNode.Single" + } + ] + }, + { + "name": "SoqlExpression", + "description": "Represents a SOQL query expression", + "category": "Expressions", + "extends": "AbstractApexNode.Single", + "implements": [], + "attributes": [ + { + "name": "@Query", + "type": "string", + "description": "Returns the raw query as it appears in the source code." + }, + { + "name": "@CanonicalQuery", + "type": "string", + "description": "Returns the query with the SOQL keywords normalized as uppercase." + }, + { + "name": "@RealLoc", + "type": "boolean", + "description": "Returns true if this node has a real source location", + "inherited_from": "AbstractApexNode.Single" + }, + { + "name": "@DefiningType", + "type": "string", + "description": "Returns the fully qualified name of the enclosing type", + "inherited_from": "AbstractApexNode.Single" + } + ] + }, + { + "name": "SoslExpression", + "description": "Represents a SOSL search expression", + "category": "Expressions", + "extends": "AbstractApexNode.Single", + "implements": [], + "attributes": [ + { + "name": "@Query", + "type": "string", + "description": "Returns the raw query as it appears in the source code." + }, + { + "name": "@CanonicalQuery", + "type": "string", + "description": "Returns the query with the SOSL keywords normalized as uppercase." + }, + { + "name": "@RealLoc", + "type": "boolean", + "description": "Returns true if this node has a real source location", + "inherited_from": "AbstractApexNode.Single" + }, + { + "name": "@DefiningType", + "type": "string", + "description": "Returns the fully qualified name of the enclosing type", + "inherited_from": "AbstractApexNode.Single" + } + ] + }, + { + "name": "StandardCondition", + "description": "Represents standard condition", + "category": "Other", + "extends": "AbstractApexNode.Single", + "implements": [], + "attributes": [ + { + "name": "@RealLoc", + "type": "boolean", + "description": "Returns true if this node has a real source location", + "inherited_from": "AbstractApexNode.Single" + }, + { + "name": "@DefiningType", + "type": "string", + "description": "Returns the fully qualified name of the enclosing type", + "inherited_from": "AbstractApexNode.Single" + } + ] + }, + { + "name": "Statement", + "description": "Represents statement", + "category": "Statements", + "extends": "AbstractApexNode.Single", + "implements": [], + "attributes": [ + { + "name": "@RealLoc", + "type": "boolean", + "description": "Returns true if this node has a real source location", + "inherited_from": "AbstractApexNode.Single" + }, + { + "name": "@DefiningType", + "type": "string", + "description": "Returns the fully qualified name of the enclosing type", + "inherited_from": "AbstractApexNode.Single" + } + ] + }, + { + "name": "StatementExecuted", + "description": "Represents statement executed", + "category": "Statements", + "extends": "AbstractApexNode.Single", + "implements": [], + "attributes": [ + { + "name": "@RealLoc", + "type": "boolean", + "description": "Returns true if this node has a real source location", + "inherited_from": "AbstractApexNode.Single" + }, + { + "name": "@DefiningType", + "type": "string", + "description": "Returns the fully qualified name of the enclosing type", + "inherited_from": "AbstractApexNode.Single" + } + ] + }, + { + "name": "SuperMethodCallExpression", + "description": "Represents super method call expression", + "category": "Declarations", + "extends": "AbstractApexNode.Single", + "implements": [], + "attributes": [ + { + "name": "@RealLoc", + "type": "boolean", + "description": "Returns true if this node has a real source location", + "inherited_from": "AbstractApexNode.Single" + }, + { + "name": "@DefiningType", + "type": "string", + "description": "Returns the fully qualified name of the enclosing type", + "inherited_from": "AbstractApexNode.Single" + } + ] + }, + { + "name": "SuperVariableExpression", + "description": "Represents super variable expression", + "category": "Declarations", + "extends": "AbstractApexNode.Single", + "implements": [], + "attributes": [ + { + "name": "@RealLoc", + "type": "boolean", + "description": "Returns true if this node has a real source location", + "inherited_from": "AbstractApexNode.Single" + }, + { + "name": "@DefiningType", + "type": "string", + "description": "Returns the fully qualified name of the enclosing type", + "inherited_from": "AbstractApexNode.Single" + } + ] + }, + { + "name": "SwitchStatement", + "description": "Represents switch statement", + "category": "Statements", + "extends": "AbstractApexNode.Single", + "implements": [], + "attributes": [ + { + "name": "@RealLoc", + "type": "boolean", + "description": "Returns true if this node has a real source location", + "inherited_from": "AbstractApexNode.Single" + }, + { + "name": "@DefiningType", + "type": "string", + "description": "Returns the fully qualified name of the enclosing type", + "inherited_from": "AbstractApexNode.Single" + } + ] + }, + { + "name": "TernaryExpression", + "description": "Represents ternary expression", + "category": "Expressions", + "extends": "AbstractApexNode.Single", + "implements": [], + "attributes": [ + { + "name": "@RealLoc", + "type": "boolean", + "description": "Returns true if this node has a real source location", + "inherited_from": "AbstractApexNode.Single" + }, + { + "name": "@DefiningType", + "type": "string", + "description": "Returns the fully qualified name of the enclosing type", + "inherited_from": "AbstractApexNode.Single" + } + ] + }, + { + "name": "ThisMethodCallExpression", + "description": "Represents this method call expression", + "category": "Declarations", + "extends": "AbstractApexNode.Single", + "implements": [], + "attributes": [ + { + "name": "@RealLoc", + "type": "boolean", + "description": "Returns true if this node has a real source location", + "inherited_from": "AbstractApexNode.Single" + }, + { + "name": "@DefiningType", + "type": "string", + "description": "Returns the fully qualified name of the enclosing type", + "inherited_from": "AbstractApexNode.Single" + } + ] + }, + { + "name": "ThisVariableExpression", + "description": "Represents this variable expression", + "category": "Declarations", + "extends": "AbstractApexNode.Single", + "implements": [], + "attributes": [ + { + "name": "@RealLoc", + "type": "boolean", + "description": "Returns true if this node has a real source location", + "inherited_from": "AbstractApexNode.Single" + }, + { + "name": "@DefiningType", + "type": "string", + "description": "Returns the fully qualified name of the enclosing type", + "inherited_from": "AbstractApexNode.Single" + } + ] + }, + { + "name": "ThrowStatement", + "description": "Represents throw statement", + "category": "Statements", + "extends": "AbstractApexNode.Single", + "implements": [], + "attributes": [ + { + "name": "@RealLoc", + "type": "boolean", + "description": "Returns true if this node has a real source location", + "inherited_from": "AbstractApexNode.Single" + }, + { + "name": "@DefiningType", + "type": "string", + "description": "Returns the fully qualified name of the enclosing type", + "inherited_from": "AbstractApexNode.Single" + } + ] + }, + { + "name": "TriggerVariableExpression", + "description": "Represents trigger variable expression", + "category": "Declarations", + "extends": "AbstractApexNode.Single", + "implements": [], + "attributes": [ + { + "name": "@RealLoc", + "type": "boolean", + "description": "Returns true if this node has a real source location", + "inherited_from": "AbstractApexNode.Single" + }, + { + "name": "@DefiningType", + "type": "string", + "description": "Returns the fully qualified name of the enclosing type", + "inherited_from": "AbstractApexNode.Single" + } + ] + }, + { + "name": "TryCatchFinallyBlockStatement", + "description": "Represents try catch finally block statement", + "category": "Statements", + "extends": "AbstractApexNode.Single", + "implements": [], + "attributes": [ + { + "name": "@TryBlock", + "type": "string", + "description": "The tryblock of this node" + }, + { + "name": "@CatchClauses", + "type": "array", + "description": "The catchclauses of this node" + }, + { + "name": "@FinallyBlock", + "type": "string", + "description": "The finallyblock of this node" + }, + { + "name": "@RealLoc", + "type": "boolean", + "description": "Returns true if this node has a real source location", + "inherited_from": "AbstractApexNode.Single" + }, + { + "name": "@DefiningType", + "type": "string", + "description": "Returns the fully qualified name of the enclosing type", + "inherited_from": "AbstractApexNode.Single" + } + ] + }, + { + "name": "TypeWhenBlock", + "description": "Represents type when block", + "category": "Statements", + "extends": "AbstractApexNode.Single", + "implements": [], + "attributes": [ + { + "name": "@Type", + "type": "string", + "description": "Returns the when block's matching type name. This includes any type arguments. If the type is a primitive, its case will be normalized." + }, + { + "name": "@Name", + "type": "string", + "description": "Returns the when block's matching type name. This includes any type arguments. If the type is a primitive, its case will be normalized." + }, + { + "name": "@RealLoc", + "type": "boolean", + "description": "Returns true if this node has a real source location", + "inherited_from": "AbstractApexNode.Single" + }, + { + "name": "@DefiningType", + "type": "string", + "description": "Returns the fully qualified name of the enclosing type", + "inherited_from": "AbstractApexNode.Single" + } + ] + }, + { + "name": "UserClass", + "description": "Represents an Apex class declaration (Note: Use UserClass, not ClassNode)", + "category": "Declarations", + "extends": "BaseApexClass", + "implements": [ + "ASTUserClassOrInterface" + ], + "attributes": [ + { + "name": "@SuperClassName", + "type": "string", + "description": "Returns the name of the superclass of this class, or an empty string if there is none. The type name does NOT include type arguments." + }, + { + "name": "@InterfaceNames", + "type": "array", + "description": "Returns a list of the names of the interfaces implemented by this class. The type names do NOT include type arguments. (This is tested.)" + }, + { + "name": "@Image", + "type": "string", + "description": "Returns the name of this class/interface/enum/trigger", + "inherited_from": "BaseApexClass" + }, + { + "name": "@SimpleName", + "type": "string", + "description": "Returns the simple name of this type declaration", + "inherited_from": "BaseApexClass" + }, + { + "name": "@QualifiedName", + "type": "string", + "description": "Returns the fully qualified name of this type", + "inherited_from": "BaseApexClass" + }, + { + "name": "@RealLoc", + "type": "boolean", + "description": "Returns true if this node has a real source location", + "inherited_from": "AbstractApexNode.Single" + }, + { + "name": "@DefiningType", + "type": "string", + "description": "Returns the fully qualified name of the enclosing type", + "inherited_from": "AbstractApexNode.Single" + } + ] + }, + { + "name": "UserClassMethods", + "description": "Represents user class methods", + "category": "Declarations", + "extends": "AbstractApexNode.Single", + "implements": [], + "attributes": [ + { + "name": "@RealLoc", + "type": "boolean", + "description": "Returns true if this node has a real source location", + "inherited_from": "AbstractApexNode.Single" + }, + { + "name": "@DefiningType", + "type": "string", + "description": "Returns the fully qualified name of the enclosing type", + "inherited_from": "AbstractApexNode.Single" + } + ] + }, + { + "name": "UserEnum", + "description": "Represents an Apex enum declaration", + "category": "Declarations", + "extends": "BaseApexClass", + "implements": [], + "attributes": [ + { + "name": "@QualifiedName", + "type": "string", + "description": "The qualifiedname of this node" + }, + { + "name": "@Image", + "type": "string", + "description": "Returns the name of this class/interface/enum/trigger", + "inherited_from": "BaseApexClass" + }, + { + "name": "@SimpleName", + "type": "string", + "description": "Returns the simple name of this type declaration", + "inherited_from": "BaseApexClass" + }, + { + "name": "@RealLoc", + "type": "boolean", + "description": "Returns true if this node has a real source location", + "inherited_from": "AbstractApexNode.Single" + }, + { + "name": "@DefiningType", + "type": "string", + "description": "Returns the fully qualified name of the enclosing type", + "inherited_from": "AbstractApexNode.Single" + } + ] + }, + { + "name": "UserExceptionMethods", + "description": "Represents user exception methods", + "category": "Declarations", + "extends": "AbstractApexNode.Single", + "implements": [], + "attributes": [ + { + "name": "@RealLoc", + "type": "boolean", + "description": "Returns true if this node has a real source location", + "inherited_from": "AbstractApexNode.Single" + }, + { + "name": "@DefiningType", + "type": "string", + "description": "Returns the fully qualified name of the enclosing type", + "inherited_from": "AbstractApexNode.Single" + } + ] + }, + { + "name": "UserInterface", + "description": "Represents an Apex interface declaration", + "category": "Declarations", + "extends": "BaseApexClass", + "implements": [ + "ASTUserClassOrInterface" + ], + "attributes": [ + { + "name": "@SuperInterfaceName", + "type": "string", + "description": "Returns the name of the superclass of this class, or an empty string if there is none. The type name does NOT include type arguments." + }, + { + "name": "@Image", + "type": "string", + "description": "Returns the name of this class/interface/enum/trigger", + "inherited_from": "BaseApexClass" + }, + { + "name": "@SimpleName", + "type": "string", + "description": "Returns the simple name of this type declaration", + "inherited_from": "BaseApexClass" + }, + { + "name": "@QualifiedName", + "type": "string", + "description": "Returns the fully qualified name of this type", + "inherited_from": "BaseApexClass" + }, + { + "name": "@RealLoc", + "type": "boolean", + "description": "Returns true if this node has a real source location", + "inherited_from": "AbstractApexNode.Single" + }, + { + "name": "@DefiningType", + "type": "string", + "description": "Returns the fully qualified name of the enclosing type", + "inherited_from": "AbstractApexNode.Single" + } + ] + }, + { + "name": "UserTrigger", + "description": "Represents an Apex trigger declaration (Note: Use UserTrigger, not TriggerNode)", + "category": "Declarations", + "extends": "BaseApexClass", + "implements": [], + "attributes": [ + { + "name": "@TargetName", + "type": "string", + "description": "The targetname of this node" + }, + { + "name": "@Usages", + "type": "array", + "description": "The usages of this node" + }, + { + "name": "@Image", + "type": "string", + "description": "Returns the name of this class/interface/enum/trigger", + "inherited_from": "BaseApexClass" + }, + { + "name": "@SimpleName", + "type": "string", + "description": "Returns the simple name of this type declaration", + "inherited_from": "BaseApexClass" + }, + { + "name": "@QualifiedName", + "type": "string", + "description": "Returns the fully qualified name of this type", + "inherited_from": "BaseApexClass" + }, + { + "name": "@RealLoc", + "type": "boolean", + "description": "Returns true if this node has a real source location", + "inherited_from": "AbstractApexNode.Single" + }, + { + "name": "@DefiningType", + "type": "string", + "description": "Returns the fully qualified name of the enclosing type", + "inherited_from": "AbstractApexNode.Single" + } + ] + }, + { + "name": "ValueWhenBlock", + "description": "Represents value when block", + "category": "Statements", + "extends": "AbstractApexNode.Single", + "implements": [], + "attributes": [ + { + "name": "@RealLoc", + "type": "boolean", + "description": "Returns true if this node has a real source location", + "inherited_from": "AbstractApexNode.Single" + }, + { + "name": "@DefiningType", + "type": "string", + "description": "Returns the fully qualified name of the enclosing type", + "inherited_from": "AbstractApexNode.Single" + } + ] + }, + { + "name": "VariableDeclaration", + "description": "Represents a variable declaration statement", + "category": "Declarations", + "extends": "AbstractApexNode.Single", + "implements": [], + "attributes": [ + { + "name": "@Image", + "type": "string", + "description": "The image of this node" + }, + { + "name": "@Type", + "type": "string", + "description": "Returns the variable's type name.

This includes any type arguments. If the type is a primitive, its case will be normalized." + }, + { + "name": "@RealLoc", + "type": "boolean", + "description": "Returns true if this node has a real source location", + "inherited_from": "AbstractApexNode.Single" + }, + { + "name": "@DefiningType", + "type": "string", + "description": "Returns the fully qualified name of the enclosing type", + "inherited_from": "AbstractApexNode.Single" + } + ] + }, + { + "name": "VariableDeclarationStatements", + "description": "Represents variable declaration statements", + "category": "Declarations", + "extends": "AbstractApexNode.Single", + "implements": [], + "attributes": [ + { + "name": "@Modifiers", + "type": "node", + "description": "The modifiers of this node" + }, + { + "name": "@RealLoc", + "type": "boolean", + "description": "Returns true if this node has a real source location", + "inherited_from": "AbstractApexNode.Single" + }, + { + "name": "@DefiningType", + "type": "string", + "description": "Returns the fully qualified name of the enclosing type", + "inherited_from": "AbstractApexNode.Single" + } + ] + }, + { + "name": "VariableExpression", + "description": "Represents a reference to a variable", + "category": "Declarations", + "extends": "AbstractApexNode.Single", + "implements": [], + "attributes": [ + { + "name": "@Image", + "type": "string", + "description": "The image of this node" + }, + { + "name": "@RealLoc", + "type": "boolean", + "description": "Returns true if this node has a real source location", + "inherited_from": "AbstractApexNode.Single" + }, + { + "name": "@DefiningType", + "type": "string", + "description": "Returns the fully qualified name of the enclosing type", + "inherited_from": "AbstractApexNode.Single" + } + ] + }, + { + "name": "WhileLoopStatement", + "description": "Represents a while loop statement", + "category": "Statements", + "extends": "AbstractApexNode.Single", + "implements": [], + "attributes": [ + { + "name": "@RealLoc", + "type": "boolean", + "description": "Returns true if this node has a real source location", + "inherited_from": "AbstractApexNode.Single" + }, + { + "name": "@DefiningType", + "type": "string", + "description": "Returns the fully qualified name of the enclosing type", + "inherited_from": "AbstractApexNode.Single" + } + ] + } + ] + } \ No newline at end of file diff --git a/packages/mcp-provider-code-analyzer/src/tools/generate_xpath_prompt.ts b/packages/mcp-provider-code-analyzer/src/tools/generate_xpath_prompt.ts index 754a95e3..aab75683 100644 --- a/packages/mcp-provider-code-analyzer/src/tools/generate_xpath_prompt.ts +++ b/packages/mcp-provider-code-analyzer/src/tools/generate_xpath_prompt.ts @@ -93,9 +93,12 @@ export class GenerateXpathPromptMcpTool extends McpTool [node.name.toLowerCase(), node]) + ); + const nodeSummaries = input.astNodes.map((node) => { + const metadata = metadataByName.get(node.nodeName.toLowerCase()); + return { + nodeName: node.nodeName, + parent: node.parent ?? null, + ancestors: node.ancestors, + attributes: node.attributes, + metadata: metadata ?? null + }; + }); + + return [ + "You are generating a PMD XPath query.", + "Goal: Generate an XPath expression that matches the violation described by the sample code.", + "", + "Context:", + `- Engine: ${input.engine}`, + `- Language: ${input.language}`, + "", + "Sample code (violates the rule):", + input.sampleCode, + "", + "AST nodes (from ast-dump) with extracted metadata:", + JSON.stringify(nodeSummaries, null, 2), + "", + "Task:", + "- Use the AST nodes and metadata above to write a precise XPath for the violation.", + "- Prefer minimal, stable XPath that avoids overfitting.", + "- Return only the XPath expression." + ].join("\n"); +} + function validateInput(input: z.infer): CallToolResult | undefined { const language = input.language?.trim(); if (!language) { From 05293102ad16445ee3709b6d2daf4da2b99fdbcd Mon Sep 17 00:00:00 2001 From: aruntyagiTutu Date: Tue, 10 Feb 2026 18:43:41 +0530 Subject: [PATCH 09/25] @W-21102094 - implemented create custom rule tool (#380) * implemented create custom rule tool * test update --- .../src/actions/create-custom-rule.ts | 94 +++++++++++ .../src/provider.ts | 5 +- .../src/tools/create_custom_rule.ts | 149 ++++++++++++++++++ .../src/tools/generate_xpath_prompt.ts | 5 +- .../test/provider.test.ts | 4 +- .../test/e2e/tool-registration.test.ts | 3 +- 6 files changed, 256 insertions(+), 4 deletions(-) create mode 100644 packages/mcp-provider-code-analyzer/src/actions/create-custom-rule.ts create mode 100644 packages/mcp-provider-code-analyzer/src/tools/create_custom_rule.ts diff --git a/packages/mcp-provider-code-analyzer/src/actions/create-custom-rule.ts b/packages/mcp-provider-code-analyzer/src/actions/create-custom-rule.ts new file mode 100644 index 00000000..221e8d92 --- /dev/null +++ b/packages/mcp-provider-code-analyzer/src/actions/create-custom-rule.ts @@ -0,0 +1,94 @@ +import path from "node:path"; +import fs from "node:fs/promises"; + +// TODO: Work in progress. This action is a placeholder to wire the tool end-to-end. + +export type CreateCustomRuleInput = { + xpath: string; + ruleName?: string; + description?: string; + language?: string; + engine?: string; + priority?: number; + workingDirectory?: string; +}; + +export type CreateCustomRuleOutput = { + status: string; + ruleXml?: string; + rulesetPath?: string; + configPath?: string; +}; + +export interface CreateCustomRuleAction { + exec(input: CreateCustomRuleInput): Promise; +} + +export class CreateCustomRuleActionImpl implements CreateCustomRuleAction { + public async exec(input: CreateCustomRuleInput): Promise { + const xpath = (input.xpath ?? "").trim(); + if (!xpath) { + return { status: "xpath is required" }; + } + + const engine = (input.engine ?? "pmd").toLowerCase(); + if (engine !== "pmd") { + return { status: `engine '${engine}' is not supported yet` }; + } + + const ruleName = input.ruleName?.trim() || "CustomXPathRule"; + const description = input.description?.trim() || "Generated rule from XPath"; + const language = (input.language ?? "apex").toLowerCase(); + const priority = Number.isFinite(input.priority) ? input.priority : 3; + + const ruleXml = [ + ``, + ``, + ` ${escapeXml(description)}`, + ` `, + ` ${priority}`, + ` `, + ` `, + ` `, + ` `, + ` `, + ` `, + `` + ].join("\n"); + + const workingDirectory = input.workingDirectory?.trim(); + if (!workingDirectory) { + return { status: "workingDirectory is required" }; + } + + const rulesetPath = path.join(workingDirectory, "custom-pmd-rules.xml"); + const configPath = path.join(workingDirectory, "code-analyzer.yml"); + + await fs.mkdir(workingDirectory, { recursive: true }); + await fs.writeFile(rulesetPath, ruleXml, "utf8"); + await fs.writeFile( + configPath, + [ + "engines:", + " pmd:", + " rulesets:", + ` - ${rulesetPath}` + ].join("\n"), + "utf8" + ); + + return { status: "success", ruleXml, rulesetPath, configPath }; + } +} + +function escapeXml(value: string): string { + return value + .replaceAll("&", "&") + .replaceAll("<", "<") + .replaceAll(">", ">") + .replaceAll("\"", """) + .replaceAll("'", "'"); +} diff --git a/packages/mcp-provider-code-analyzer/src/provider.ts b/packages/mcp-provider-code-analyzer/src/provider.ts index 33615501..ff2ec38f 100644 --- a/packages/mcp-provider-code-analyzer/src/provider.ts +++ b/packages/mcp-provider-code-analyzer/src/provider.ts @@ -10,7 +10,9 @@ import {RunAnalyzerActionImpl} from "./actions/run-analyzer.js"; 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 { GetAstNodesActionImpl } from "./actions/get-ast-nodes.js"; +import { CreateCustomRuleActionImpl } from "./actions/create-custom-rule.js"; export class CodeAnalyzerMcpProvider extends McpProvider { public getName(): string { @@ -37,7 +39,8 @@ export class CodeAnalyzerMcpProvider extends McpProvider { telemetryService: services.getTelemetryService() })), new CodeAnalyzerQueryResultsMcpTool(new QueryResultsActionImpl(), services.getTelemetryService()), - new GenerateXpathPromptMcpTool(new GetAstNodesActionImpl(), services.getTelemetryService()) + new GenerateXpathPromptMcpTool(new GetAstNodesActionImpl(), services.getTelemetryService()), + new CreateCustomRuleMcpTool(new CreateCustomRuleActionImpl(), services.getTelemetryService()) ]); } } \ No newline at end of file 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 new file mode 100644 index 00000000..fa9d6823 --- /dev/null +++ b/packages/mcp-provider-code-analyzer/src/tools/create_custom_rule.ts @@ -0,0 +1,149 @@ +import { z } from "zod"; +import { CallToolResult } from "@modelcontextprotocol/sdk/types.js"; +import { McpTool, McpToolConfig, ReleaseState, Toolset, TelemetryService } from "@salesforce/mcp-provider-api"; +import { + CreateCustomRuleAction, + CreateCustomRuleActionImpl, + CreateCustomRuleInput, + CreateCustomRuleOutput +} from "../actions/create-custom-rule.js"; + +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. + +If xpath is not provided and engine is "pmd": +- First call the tool "get_ast_nodes_to_generate_xpath" 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.`; + +export const inputSchema = z.object({ + xpath: z.string().describe("XPath expression that should match the violation."), + 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).") +}); +type InputArgsShape = typeof inputSchema.shape; + +const outputSchema = z.object({ + 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.") +}); +type OutputArgsShape = typeof outputSchema.shape; + +export class CreateCustomRuleMcpTool extends McpTool { + public static readonly NAME: string = "create_custom_rule"; + private readonly action: CreateCustomRuleAction; + private readonly telemetryService?: TelemetryService; + + public constructor( + action: CreateCustomRuleAction = new CreateCustomRuleActionImpl(), + 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 CreateCustomRuleMcpTool.NAME; + } + + public getConfig(): McpToolConfig { + return { + title: "Create Custom Rule", + description: DESCRIPTION, + inputSchema: inputSchema.shape, + outputSchema: outputSchema.shape, + annotations: { + readOnlyHint: true + } + }; + } + + public async exec(input: z.infer): Promise { + const validationError = validateInput(input); + if (validationError) { + return validationError; + } + const output: CreateCustomRuleOutput = await this.action.exec(input as CreateCustomRuleInput); + const message = output.rulesetPath && output.configPath + ? `Custom rule created. Ruleset: ${output.rulesetPath}. Code Analyzer config: ${output.configPath}.` + : output.status; + return { + content: [{ type: "text", text: message }], + structuredContent: output + }; + } +} + +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 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'."); + } + + 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) { + return buildError("xpath is required for engine 'pmd'. Provide a valid XPath expression."); + } + + if (input.priority === undefined || input.priority === null) { + return buildError("priority is required. Provide a value between 1 and 5."); + } + + const workingDirectory = input.workingDirectory?.trim(); + if (!workingDirectory) { + return buildError("workingDirectory is required. Provide a directory where files can be written."); + } + + return undefined; +} + +function buildError(status: string): CallToolResult { + const output = { status }; + return { + content: [{ type: "text", text: JSON.stringify(output) }], + structuredContent: output + }; +} diff --git a/packages/mcp-provider-code-analyzer/src/tools/generate_xpath_prompt.ts b/packages/mcp-provider-code-analyzer/src/tools/generate_xpath_prompt.ts index aab75683..3172d096 100644 --- a/packages/mcp-provider-code-analyzer/src/tools/generate_xpath_prompt.ts +++ b/packages/mcp-provider-code-analyzer/src/tools/generate_xpath_prompt.ts @@ -148,7 +148,10 @@ function buildXpathPrompt(input: BuildPromptInput): string { "Task:", "- Use the AST nodes and metadata above to write a precise XPath for the violation.", "- Prefer minimal, stable XPath that avoids overfitting.", - "- Return only the XPath expression." + "- Return only the XPath expression.", + "", + "Next step:", + "- Call the tool 'create_custom_rule' with the generated XPath to create the custom rule." ].join("\n"); } diff --git a/packages/mcp-provider-code-analyzer/test/provider.test.ts b/packages/mcp-provider-code-analyzer/test/provider.test.ts index c03cffcc..2a79b493 100644 --- a/packages/mcp-provider-code-analyzer/test/provider.test.ts +++ b/packages/mcp-provider-code-analyzer/test/provider.test.ts @@ -6,6 +6,7 @@ import { CodeAnalyzerDescribeRuleMcpTool } from "../src/tools/describe_code_anal import { CodeAnalyzerListRulesMcpTool } from "../src/tools/list_code_analyzer_rules.js"; 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"; describe("Tests for CodeAnalyzerMcpProvider", () => { let services: Services; @@ -22,11 +23,12 @@ 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(5); + 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); }); }) \ No newline at end of file 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 d4bf42b2..85f82800 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 @@ -90,7 +90,7 @@ describe('specific tool registration', () => { try { const initialTools = (await client.listTools()).tools.map((t) => t.name).sort(); - expect(initialTools.length).to.equal(8); + expect(initialTools.length).to.equal(9); expect(initialTools).to.deep.equal( [ 'run_soql_query', @@ -101,6 +101,7 @@ describe('specific tool registration', () => { 'list_code_analyzer_rules', 'query_code_analyzer_results', 'get_ast_nodes_to_generate_xpath', + 'create_custom_rule', ].sort(), ); } catch (err) { From c986c775d81e3e6c00a1d82197b4147067e26b1b Mon Sep 17 00:00:00 2001 From: aruntyagiTutu Date: Wed, 11 Feb 2026 12:18:09 +0530 Subject: [PATCH 10/25] create custom rule implementation (#383) --- .../mcp-provider-code-analyzer/package.json | 2 +- .../src/actions/create-custom-rule.ts | 172 ++++++++++++------ .../src/templates/code-analyzer.yml | 4 + .../src/templates/pmd-ruleset.xml | 23 +++ .../src/tools/generate_xpath_prompt.ts | 16 ++ .../mcp-provider-code-analyzer/src/utils.ts | 12 ++ 6 files changed, 172 insertions(+), 57 deletions(-) create mode 100644 packages/mcp-provider-code-analyzer/src/templates/code-analyzer.yml create mode 100644 packages/mcp-provider-code-analyzer/src/templates/pmd-ruleset.xml diff --git a/packages/mcp-provider-code-analyzer/package.json b/packages/mcp-provider-code-analyzer/package.json index 36b62203..b5c3cff1 100644 --- a/packages/mcp-provider-code-analyzer/package.json +++ b/packages/mcp-provider-code-analyzer/package.json @@ -41,7 +41,7 @@ "package.json" ], "scripts": { - "build": "tsc --build tsconfig.build.json --verbose && cp -R src/data dist/data", + "build": "tsc --build tsconfig.build.json --verbose && cp -R src/data dist/data && cp -R src/templates dist/templates", "clean": "tsc --build tsconfig.build.json --clean", "clean-all": "yarn clean && rimraf node_modules", "lint": "eslint **/*.ts", diff --git a/packages/mcp-provider-code-analyzer/src/actions/create-custom-rule.ts b/packages/mcp-provider-code-analyzer/src/actions/create-custom-rule.ts index 221e8d92..a2cf0ce4 100644 --- a/packages/mcp-provider-code-analyzer/src/actions/create-custom-rule.ts +++ b/packages/mcp-provider-code-analyzer/src/actions/create-custom-rule.ts @@ -1,5 +1,6 @@ import path from "node:path"; import fs from "node:fs/promises"; +import { escapeXml } from "../utils.js"; // TODO: Work in progress. This action is a placeholder to wire the tool end-to-end. @@ -26,69 +27,128 @@ export interface CreateCustomRuleAction { export class CreateCustomRuleActionImpl implements CreateCustomRuleAction { public async exec(input: CreateCustomRuleInput): Promise { - const xpath = (input.xpath ?? "").trim(); - if (!xpath) { - return { status: "xpath is required" }; + const normalized = normalizeInput(input); + if ("error" in normalized) { + return { status: normalized.error }; } - const engine = (input.engine ?? "pmd").toLowerCase(); - if (engine !== "pmd") { - return { status: `engine '${engine}' is not supported yet` }; - } - - const ruleName = input.ruleName?.trim() || "CustomXPathRule"; - const description = input.description?.trim() || "Generated rule from XPath"; - const language = (input.language ?? "apex").toLowerCase(); - const priority = Number.isFinite(input.priority) ? input.priority : 3; - - const ruleXml = [ - ``, - ``, - ` ${escapeXml(description)}`, - ` `, - ` ${priority}`, - ` `, - ` `, - ` `, - ` `, - ` `, - ` `, - `` - ].join("\n"); - - const workingDirectory = input.workingDirectory?.trim(); - if (!workingDirectory) { - return { status: "workingDirectory is required" }; - } - - const rulesetPath = path.join(workingDirectory, "custom-pmd-rules.xml"); - const configPath = path.join(workingDirectory, "code-analyzer.yml"); + const ruleXml = await buildRuleXml(normalized); + const { customRulesDir, rulesetPath, configPath } = buildPaths(normalized); - await fs.mkdir(workingDirectory, { recursive: true }); + await fs.mkdir(customRulesDir, { recursive: true }); await fs.writeFile(rulesetPath, ruleXml, "utf8"); - await fs.writeFile( - configPath, - [ - "engines:", - " pmd:", - " rulesets:", - ` - ${rulesetPath}` - ].join("\n"), - "utf8" - ); + await upsertCodeAnalyzerConfig(configPath, rulesetPath); return { status: "success", ruleXml, rulesetPath, configPath }; } } -function escapeXml(value: string): string { - return value - .replaceAll("&", "&") - .replaceAll("<", "<") - .replaceAll(">", ">") - .replaceAll("\"", """) - .replaceAll("'", "'"); +type NormalizedInput = { + xpath: string; + engine: string; + ruleName: string; + description: string; + language: string; + priority: number; + workingDirectory: string; +}; + +const DEFAULT_RULE_NAME = "CustomXPathRule"; +const DEFAULT_DESCRIPTION = "Generated rule from XPath"; +const DEFAULT_LANGUAGE = "apex"; +const DEFAULT_PRIORITY = 3; +const CUSTOM_RULES_DIR_NAME = "custom-rules"; + +function normalizeInput(input: CreateCustomRuleInput): NormalizedInput | { error: string } { + const xpath = (input.xpath ?? "").trim(); + if (!xpath) { + return { error: "xpath is required" }; + } + + const engine = (input.engine ?? "pmd").toLowerCase(); + if (engine !== "pmd") { + return { error: `engine '${engine}' is not supported yet` }; + } + + const workingDirectory = input.workingDirectory?.trim(); + if (!workingDirectory) { + return { error: "workingDirectory is required" }; + } + + return { + xpath, + engine, + ruleName: input.ruleName?.trim() || DEFAULT_RULE_NAME, + description: input.description?.trim() || DEFAULT_DESCRIPTION, + language: (input.language ?? DEFAULT_LANGUAGE).toLowerCase(), + priority: Number.isFinite(input.priority) ? (input.priority as number) : DEFAULT_PRIORITY, + workingDirectory + }; +} + +async function buildRuleXml(input: NormalizedInput): Promise { + const templatePath = new URL("../templates/pmd-ruleset.xml", import.meta.url); + const template = await fs.readFile(templatePath, "utf8"); + return applyTemplate(template, { + rulesetName: escapeXml(input.ruleName), + rulesetDescription: escapeXml(input.description), + ruleName: escapeXml(input.ruleName), + ruleMessage: escapeXml(input.description), + ruleDescription: escapeXml(input.description), + documentationUrl: "", + priority: String(input.priority), + xpathExpression: input.xpath, + exampleCode: "" + }); +} + +function buildPaths(input: NormalizedInput): { customRulesDir: string; rulesetPath: string; configPath: string } { + const customRulesDir = path.join(input.workingDirectory, CUSTOM_RULES_DIR_NAME); + return { + customRulesDir, + rulesetPath: path.join(customRulesDir, `${input.ruleName}-pmd-rules.xml`), + configPath: path.join(input.workingDirectory, "code-analyzer.yml") + }; +} + +function applyTemplate(template: string, values: Record): string { + return template.replace(/\{\{(\w+)\}\}/g, (_, key: string) => values[key] ?? ""); +} + +async function upsertCodeAnalyzerConfig(configPath: string, rulesetPath: string): Promise { + try { + const existing = await fs.readFile(configPath, "utf8"); + if (existing.includes(rulesetPath)) { + return; + } + const updated = addRulesetPath(existing, rulesetPath); + await fs.writeFile(configPath, updated, "utf8"); + } catch (error) { + const code = (error as NodeJS.ErrnoException).code; + if (code !== "ENOENT") { + throw error; + } + const templatePath = new URL("../templates/code-analyzer.yml", import.meta.url); + const template = await fs.readFile(templatePath, "utf8"); + const content = applyTemplate(template, { rulesetPath }); + await fs.writeFile(configPath, content, "utf8"); + } +} + +function addRulesetPath(configContent: string, rulesetPath: string): string { + const lines = configContent.split(/\r?\n/); + for (let i = 0; i < lines.length; i++) { + if (lines[i].trim() === "rulesets:") { + lines.splice(i + 1, 0, ` - "${rulesetPath}"`); + return lines.join("\n"); + } + } + return [ + configContent.trimEnd(), + "", + "engines:", + " pmd:", + " rulesets:", + ` - "${rulesetPath}"` + ].join("\n"); } diff --git a/packages/mcp-provider-code-analyzer/src/templates/code-analyzer.yml b/packages/mcp-provider-code-analyzer/src/templates/code-analyzer.yml new file mode 100644 index 00000000..a7d3ecf3 --- /dev/null +++ b/packages/mcp-provider-code-analyzer/src/templates/code-analyzer.yml @@ -0,0 +1,4 @@ +engines: + pmd: + rulesets: + - "{{rulesetPath}}" diff --git a/packages/mcp-provider-code-analyzer/src/templates/pmd-ruleset.xml b/packages/mcp-provider-code-analyzer/src/templates/pmd-ruleset.xml new file mode 100644 index 00000000..916e7d14 --- /dev/null +++ b/packages/mcp-provider-code-analyzer/src/templates/pmd-ruleset.xml @@ -0,0 +1,23 @@ + + + {{rulesetDescription}} + + + {{ruleDescription}} + + {{priority}} + + + + + + + + diff --git a/packages/mcp-provider-code-analyzer/src/tools/generate_xpath_prompt.ts b/packages/mcp-provider-code-analyzer/src/tools/generate_xpath_prompt.ts index 3172d096..997d106d 100644 --- a/packages/mcp-provider-code-analyzer/src/tools/generate_xpath_prompt.ts +++ b/packages/mcp-provider-code-analyzer/src/tools/generate_xpath_prompt.ts @@ -18,6 +18,22 @@ const DESCRIPTION: string = Output: - prompt: A concise, high-signal prompt that guides an LLM to extract the AST context needed for XPath authoring from the sampleCode. + Use this tool when the user asks for rules like: + - Ban all System.debug statements in production code. + - Enforce that all Apex classes must end with Service, Controller, Handler, or Helper suffix. + - Detect hardcoded Salesforce IDs in Apex classes. + - Require that all test methods include assertions and cannot be empty. + - Prevent usage of @future methods without proper error handling. + - Enforce that all public methods must have proper documentation comments. + - Prevent nested if statements deeper than 3 levels. + - Require that all DML operations are wrapped in try-catch blocks. + - Ensure all SOQL queries use bind variables instead of string concatenation. + - Classes implementing Batchable must have proper error handling in execute(). + - All methods with @TestVisible must be in test classes only. + - Enforce that all custom exceptions extend Exception class properly. + - Require that all Database.query calls use escapeSingleQuotes for user input. + - Ban the use of Test.isRunningTest() in production code. + Note: This tool only prepares the prompt. A subsequent tool will use these details to generate the final XPath-based custom rule.`; export const inputSchema = z.object({ diff --git a/packages/mcp-provider-code-analyzer/src/utils.ts b/packages/mcp-provider-code-analyzer/src/utils.ts index ae7929fb..806ee47f 100644 --- a/packages/mcp-provider-code-analyzer/src/utils.ts +++ b/packages/mcp-provider-code-analyzer/src/utils.ts @@ -5,3 +5,15 @@ export function getErrorMessage(error: unknown): string { return error instanceof Error ? error.message : /* istanbul ignore next */ String(error); } + +/** + * Escape XML special characters for safe attribute/text usage. + */ +export function escapeXml(value: string): string { + return value + .replaceAll("&", "&") + .replaceAll("<", "<") + .replaceAll(">", ">") + .replaceAll("\"", """) + .replaceAll("'", "'"); +} From 16a02e71ab7d5083ea3a8cff21a5dcf91a2ff256 Mon Sep 17 00:00:00 2001 From: aruntyagiTutu Date: Fri, 13 Feb 2026 09:20:56 +0530 Subject: [PATCH 11/25] @W-21102094 - Refactor create rule tool implementation (#384) * add validation that this implementation supports pmd only for now * update prompt - optimize for output size * refactor create tool * file name santization * updated action name * update prompt * update prompt optimize for simple xpath * fix tests * fix test with mock --- ...om-rule.ts => create-xpath-custom-rule.ts} | 63 ++++++++---- .../src/actions/get-ast-nodes.ts | 74 ++------------ .../src/ast/extract-ast-nodes.ts | 3 +- .../src/ast/generate-ast-xml.ts | 12 ++- .../src/constants.ts | 13 ++- .../src/provider.ts | 4 +- .../src/templates/code-analyzer.yml | 2 +- .../src/tools/create_custom_rule.ts | 30 ++++-- .../src/tools/generate_xpath_prompt.ts | 96 ++++++++++++++----- .../mcp-provider-code-analyzer/src/utils.ts | 15 +++ .../test/actions/get-ast-nodes.test.ts | 48 +++++++++- 11 files changed, 231 insertions(+), 129 deletions(-) rename packages/mcp-provider-code-analyzer/src/actions/{create-custom-rule.ts => create-xpath-custom-rule.ts} (68%) diff --git a/packages/mcp-provider-code-analyzer/src/actions/create-custom-rule.ts b/packages/mcp-provider-code-analyzer/src/actions/create-xpath-custom-rule.ts similarity index 68% rename from packages/mcp-provider-code-analyzer/src/actions/create-custom-rule.ts rename to packages/mcp-provider-code-analyzer/src/actions/create-xpath-custom-rule.ts index a2cf0ce4..fb4edcae 100644 --- a/packages/mcp-provider-code-analyzer/src/actions/create-custom-rule.ts +++ b/packages/mcp-provider-code-analyzer/src/actions/create-xpath-custom-rule.ts @@ -1,10 +1,10 @@ import path from "node:path"; import fs from "node:fs/promises"; -import { escapeXml } from "../utils.js"; +import { escapeXml, toSafeFilenameSlug } from "../utils.js"; // TODO: Work in progress. This action is a placeholder to wire the tool end-to-end. -export type CreateCustomRuleInput = { +export type CreateXpathCustomRuleInput = { xpath: string; ruleName?: string; description?: string; @@ -14,19 +14,19 @@ export type CreateCustomRuleInput = { workingDirectory?: string; }; -export type CreateCustomRuleOutput = { +export type CreateXpathCustomRuleOutput = { status: string; ruleXml?: string; rulesetPath?: string; configPath?: string; }; -export interface CreateCustomRuleAction { - exec(input: CreateCustomRuleInput): Promise; +export interface CreateXpathCustomRuleAction { + exec(input: CreateXpathCustomRuleInput): Promise; } -export class CreateCustomRuleActionImpl implements CreateCustomRuleAction { - public async exec(input: CreateCustomRuleInput): Promise { +export class CreateXpathCustomRuleActionImpl implements CreateXpathCustomRuleAction { + public async exec(input: CreateXpathCustomRuleInput): Promise { const normalized = normalizeInput(input); if ("error" in normalized) { return { status: normalized.error }; @@ -37,7 +37,7 @@ export class CreateCustomRuleActionImpl implements CreateCustomRuleAction { await fs.mkdir(customRulesDir, { recursive: true }); await fs.writeFile(rulesetPath, ruleXml, "utf8"); - await upsertCodeAnalyzerConfig(configPath, rulesetPath); + await upsertCodeAnalyzerConfig(configPath, rulesetPath, normalized.engine); return { status: "success", ruleXml, rulesetPath, configPath }; } @@ -59,7 +59,7 @@ const DEFAULT_LANGUAGE = "apex"; const DEFAULT_PRIORITY = 3; const CUSTOM_RULES_DIR_NAME = "custom-rules"; -function normalizeInput(input: CreateCustomRuleInput): NormalizedInput | { error: string } { +function normalizeInput(input: CreateXpathCustomRuleInput): NormalizedInput | { error: string } { const xpath = (input.xpath ?? "").trim(); if (!xpath) { return { error: "xpath is required" }; @@ -104,9 +104,10 @@ async function buildRuleXml(input: NormalizedInput): Promise { function buildPaths(input: NormalizedInput): { customRulesDir: string; rulesetPath: string; configPath: string } { const customRulesDir = path.join(input.workingDirectory, CUSTOM_RULES_DIR_NAME); + const safeRuleName = toSafeFilenameSlug(input.ruleName); return { customRulesDir, - rulesetPath: path.join(customRulesDir, `${input.ruleName}-pmd-rules.xml`), + rulesetPath: path.join(customRulesDir, `${safeRuleName}-${input.engine}-rules.xml`), configPath: path.join(input.workingDirectory, "code-analyzer.yml") }; } @@ -115,13 +116,13 @@ function applyTemplate(template: string, values: Record): string return template.replace(/\{\{(\w+)\}\}/g, (_, key: string) => values[key] ?? ""); } -async function upsertCodeAnalyzerConfig(configPath: string, rulesetPath: string): Promise { +async function upsertCodeAnalyzerConfig(configPath: string, rulesetPath: string, engine: string): Promise { try { const existing = await fs.readFile(configPath, "utf8"); if (existing.includes(rulesetPath)) { return; } - const updated = addRulesetPath(existing, rulesetPath); + const updated = addRulesetPath(existing, rulesetPath, engine); await fs.writeFile(configPath, updated, "utf8"); } catch (error) { const code = (error as NodeJS.ErrnoException).code; @@ -130,25 +131,49 @@ async function upsertCodeAnalyzerConfig(configPath: string, rulesetPath: string) } const templatePath = new URL("../templates/code-analyzer.yml", import.meta.url); const template = await fs.readFile(templatePath, "utf8"); - const content = applyTemplate(template, { rulesetPath }); + const content = applyTemplate(template, { rulesetPath, engine }); await fs.writeFile(configPath, content, "utf8"); } } -function addRulesetPath(configContent: string, rulesetPath: string): string { +function addRulesetPath(configContent: string, rulesetPath: string, engine: string): string { const lines = configContent.split(/\r?\n/); + let enginesLineIndex = -1; + let engineLineIndex = -1; + let customRulesetsLineIndex = -1; + for (let i = 0; i < lines.length; i++) { - if (lines[i].trim() === "rulesets:") { - lines.splice(i + 1, 0, ` - "${rulesetPath}"`); - return lines.join("\n"); + const trimmed = lines[i].trim(); + if (trimmed === "engines:") { + enginesLineIndex = i; + continue; + } + if (trimmed === `${engine}:` && enginesLineIndex !== -1) { + engineLineIndex = i; + continue; } + if (trimmed === "custom_rulesets:" && engineLineIndex !== -1) { + customRulesetsLineIndex = i; + break; + } + } + + if (customRulesetsLineIndex !== -1) { + lines.splice(customRulesetsLineIndex + 1, 0, ` - "${rulesetPath}"`); + return lines.join("\n"); + } + + if (engineLineIndex !== -1) { + lines.splice(engineLineIndex + 1, 0, " custom_rulesets:", ` - "${rulesetPath}"`); + return lines.join("\n"); } + return [ configContent.trimEnd(), "", "engines:", - " pmd:", - " rulesets:", + ` ${engine}:`, + " custom_rulesets:", ` - "${rulesetPath}"` ].join("\n"); } diff --git a/packages/mcp-provider-code-analyzer/src/actions/get-ast-nodes.ts b/packages/mcp-provider-code-analyzer/src/actions/get-ast-nodes.ts index 2eff930f..5cba1228 100644 --- a/packages/mcp-provider-code-analyzer/src/actions/get-ast-nodes.ts +++ b/packages/mcp-provider-code-analyzer/src/actions/get-ast-nodes.ts @@ -1,6 +1,7 @@ import { generateAstXmlFromSource } from "../ast/generate-ast-xml.js"; import { type AstNode, extractAstNodesFromXml } from "../ast/extract-ast-nodes.js"; import { getApexAstNodeMetadataByNames, type ApexAstNodeMetadata } from "../ast/metadata/apex-ast-reference.js"; +import { LANGUAGE_NAMES } from "../constants.js"; export type GetAstNodesInput = { code: string; @@ -20,17 +21,16 @@ export interface GetAstNodesAction { export class GetAstNodesActionImpl implements GetAstNodesAction { public async exec(input: GetAstNodesInput): Promise { try { - const pmdBinPath = process.env.PMD_BIN_PATH ?? "/Users/arun.tyagi/Downloads/pmd-bin-7.21.0/bin"; - if (!pmdBinPath) { - throw new Error("Missing PMD bin path. Provide pmdBinPath or set PMD_BIN_PATH."); - } - + // Steps: + // 1) Generate AST XML from source code + // 2) Parse XML into AST nodes + // 3) Resolve cached metadata for unique node names (per language) // TODO: Spike note: // - Currently shelling out to the PMD CLI (`./pmd ast-dump`) to generate AST XML. // - This is a temporary approach for early prototyping and should not be considered final. // - Replace this with a direct PMD Java API integration or a Code Analyzer core API call. // - When replacing, remove dependency on local PMD bin path and avoid spawning external processes. - const astXml = await generateAstXmlFromSource(input.code, input.language, pmdBinPath); + const astXml = await generateAstXmlFromSource(input.code, input.language); const nodes = extractAstNodesFromXml(astXml); const language = input.language?.toLowerCase().trim(); const nodeNames = Array.from(new Set(nodes.map((node) => node.nodeName))); @@ -42,70 +42,14 @@ export class GetAstNodesActionImpl implements GetAstNodesAction { } } - -/** - * Generates AST XML for the given source code using PMD CLI. - * This is a utility-style export so it can be wired into the action later - * without altering the existing flow in this file. - */ -export async function generateAstXml( - code: string, - language: string, - pmdBinPath: string -): Promise { - const { generateAstXmlFromSource } = await import("../ast/generate-ast-xml.js"); - return generateAstXmlFromSource(code, language, pmdBinPath); -} - -/** - * Returns a list of AST node identifiers for the given source code. - * Minimal implementation with zero external dependencies: - * - For 'xml' or 'html' languages, returns tag names encountered in document order (unique, case-preserving). - * - For other languages, returns an empty list (placeholder). - * - * This function is intentionally lightweight to avoid runtime dependencies. - * - * @param code - The source code as a string - * @param language - The language of the source code (e.g., "xml", "html", "typescript", "javascript", "apex") - * @returns An array of strings representing AST nodes - */ -export function getAstNodes(code: string, language: string): string[] { - const lang = (language ?? '').toLowerCase().trim(); - // 1. Read user utterance and normalize rule intent (engine, language, rule type) - - // 2. Generate minimal Apex sample code representing the rule violation - - // 3. Run PMD ast-dump on generated Apex code to produce AST XML - - // 4. Parse AST XML and extract all AST nodes with hierarchy information - - // 5. Identify and filter relevant AST nodes required for the rule logic - - // 6. Enrich AST nodes using cached AST metadata (descriptions, attributes) - - // 7. Prepare structured prompt input using rule intent + relevant AST nodes - - // 8. Call LLM to generate XPath expression based on AST structure - - // 9. Validate generated XPath against extracted AST nodes - - // 10. Generate custom PMD rule XML using rule template and XPath - - // 11. Create or update custom PMD rules XML file - - // 12. Create or update code-analyzer configuration to reference custom rules - - // 13. (Optional) Run PMD with sample code to validate rule behavior - - return []; -} - async function getCachedMetadataByLanguage( language: string, nodeNames: string[] ): Promise { - if (language === "apex") { + const normalized = (language ?? "").toLowerCase().trim(); + if (normalized === LANGUAGE_NAMES.Apex) { return getApexAstNodeMetadataByNames(nodeNames); } return []; } + diff --git a/packages/mcp-provider-code-analyzer/src/ast/extract-ast-nodes.ts b/packages/mcp-provider-code-analyzer/src/ast/extract-ast-nodes.ts index eb4f3398..90822399 100644 --- a/packages/mcp-provider-code-analyzer/src/ast/extract-ast-nodes.ts +++ b/packages/mcp-provider-code-analyzer/src/ast/extract-ast-nodes.ts @@ -11,6 +11,7 @@ export interface AstNode { const parser = new XMLParser({ ignoreAttributes: false, attributeNamePrefix: "@_", + ignoreDeclaration: true, }); /** @@ -62,7 +63,7 @@ function traverse( function parseAstXml(xml: string): AstNode[] { const parsed = parser.parse(xml); - const rootName = Object.keys(parsed)[0]; + const rootName = Object.keys(parsed).find((key) => key !== "?xml") ?? Object.keys(parsed)[0]; const rootNode = parsed[rootName]; return traverse(rootNode, rootName, []); diff --git a/packages/mcp-provider-code-analyzer/src/ast/generate-ast-xml.ts b/packages/mcp-provider-code-analyzer/src/ast/generate-ast-xml.ts index ecbe670c..238241bb 100644 --- a/packages/mcp-provider-code-analyzer/src/ast/generate-ast-xml.ts +++ b/packages/mcp-provider-code-analyzer/src/ast/generate-ast-xml.ts @@ -18,8 +18,7 @@ function sanitizeExtension(language: string): string { */ export async function generateAstXmlFromSource( code: string, - language: string, - pmdBinPath: string + language: string ): Promise { const tempDir = await fs.mkdtemp(path.join(os.tmpdir(), "pmd-ast-")); const sourceFile = path.join(tempDir, `source.${sanitizeExtension(language)}`); @@ -27,16 +26,19 @@ export async function generateAstXmlFromSource( try { await fs.writeFile(sourceFile, code, "utf8"); const { stdout } = await execFileAsync( - "./pmd", + "pmd", ["ast-dump", "--language", language, "--format", "xml", "--file", sourceFile], { - cwd: pmdBinPath, maxBuffer: 10 * 1024 * 1024 } ); return stdout.trim(); } catch (error) { - throw new Error(`Failed to generate AST XML via PMD: ${getErrorMessage(error)}`); + const message = getErrorMessage(error); + if (message.toLowerCase().includes("enoent")) { + throw new Error("PMD CLI not found on PATH. Install PMD and ensure `pmd` is available in your PATH."); + } + throw new Error(`Failed to generate AST XML via PMD: ${message}`); } finally { await fs.rm(tempDir, { recursive: true, force: true }); } diff --git a/packages/mcp-provider-code-analyzer/src/constants.ts b/packages/mcp-provider-code-analyzer/src/constants.ts index 1e427791..80d2930f 100644 --- a/packages/mcp-provider-code-analyzer/src/constants.ts +++ b/packages/mcp-provider-code-analyzer/src/constants.ts @@ -4,7 +4,8 @@ export const TelemetrySource = "MCP" export const McpTelemetryEvents = { ENGINE_SELECTION: 'engine_selection', ENGINE_EXECUTION: 'engine_execution', - RESULTS_QUERY: 'results_query' + RESULTS_QUERY: 'results_query', + CUSTOM_RULE_CREATED: 'custom_rule_created' } export const ENGINE_NAMES = [ @@ -91,6 +92,16 @@ export type Language = typeof LANGUAGES[number]; export const LANGUAGE_SET: ReadonlySet = new Set(LANGUAGES); +export const LANGUAGE_NAMES = { + Apex: 'apex', + CSS: 'css', + HTML: 'html', + JavaScript: 'javascript', + TypeScript: 'typescript', + Visualforce: 'visualforce', + XML: 'xml' +} as const; + export const ENGINE_SPECIFIC_TAGS = [ 'DevPreview', 'LWC' diff --git a/packages/mcp-provider-code-analyzer/src/provider.ts b/packages/mcp-provider-code-analyzer/src/provider.ts index ff2ec38f..59b0a59a 100644 --- a/packages/mcp-provider-code-analyzer/src/provider.ts +++ b/packages/mcp-provider-code-analyzer/src/provider.ts @@ -12,7 +12,7 @@ import { ListRulesActionImpl } from "./actions/list-rules.js"; import { GenerateXpathPromptMcpTool } from "./tools/generate_xpath_prompt.js"; import { CreateCustomRuleMcpTool } from "./tools/create_custom_rule.js"; import { GetAstNodesActionImpl } from "./actions/get-ast-nodes.js"; -import { CreateCustomRuleActionImpl } from "./actions/create-custom-rule.js"; +import { CreateXpathCustomRuleActionImpl } from "./actions/create-xpath-custom-rule.js"; export class CodeAnalyzerMcpProvider extends McpProvider { public getName(): string { @@ -40,7 +40,7 @@ export class CodeAnalyzerMcpProvider extends McpProvider { })), new CodeAnalyzerQueryResultsMcpTool(new QueryResultsActionImpl(), services.getTelemetryService()), new GenerateXpathPromptMcpTool(new GetAstNodesActionImpl(), services.getTelemetryService()), - new CreateCustomRuleMcpTool(new CreateCustomRuleActionImpl(), services.getTelemetryService()) + new CreateCustomRuleMcpTool(new CreateXpathCustomRuleActionImpl(), services.getTelemetryService()) ]); } } \ No newline at end of file diff --git a/packages/mcp-provider-code-analyzer/src/templates/code-analyzer.yml b/packages/mcp-provider-code-analyzer/src/templates/code-analyzer.yml index a7d3ecf3..22d3009e 100644 --- a/packages/mcp-provider-code-analyzer/src/templates/code-analyzer.yml +++ b/packages/mcp-provider-code-analyzer/src/templates/code-analyzer.yml @@ -1,4 +1,4 @@ engines: pmd: - rulesets: + custom_rulesets: - "{{rulesetPath}}" 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 fa9d6823..4927ceb5 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 @@ -1,12 +1,13 @@ 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 { - CreateCustomRuleAction, - CreateCustomRuleActionImpl, - CreateCustomRuleInput, - CreateCustomRuleOutput -} from "../actions/create-custom-rule.js"; + CreateXpathCustomRuleAction, + CreateXpathCustomRuleActionImpl, + CreateXpathCustomRuleInput, + CreateXpathCustomRuleOutput +} from "../actions/create-xpath-custom-rule.js"; const DESCRIPTION: string = `Purpose: Create a custom rule using a provided XPath expression. @@ -50,11 +51,11 @@ type OutputArgsShape = typeof outputSchema.shape; export class CreateCustomRuleMcpTool extends McpTool { public static readonly NAME: string = "create_custom_rule"; - private readonly action: CreateCustomRuleAction; + private readonly action: CreateXpathCustomRuleAction; private readonly telemetryService?: TelemetryService; public constructor( - action: CreateCustomRuleAction = new CreateCustomRuleActionImpl(), + action: CreateXpathCustomRuleAction = new CreateXpathCustomRuleActionImpl(), telemetryService?: TelemetryService ) { super(); @@ -91,10 +92,21 @@ export class CreateCustomRuleMcpTool extends McpTool): CallToolResult | und const xpath = input.xpath?.trim(); if (engine.toLowerCase() === "pmd" && !xpath) { - return buildError("xpath is required for engine 'pmd'. Provide a valid XPath expression."); + return buildError("xpath is required for engine 'pmd'. Provide a valid XPath expression, use tool 'get_ast_nodes_to_generate_xpath' to generate the XPath."); } if (input.priority === undefined || input.priority === null) { diff --git a/packages/mcp-provider-code-analyzer/src/tools/generate_xpath_prompt.ts b/packages/mcp-provider-code-analyzer/src/tools/generate_xpath_prompt.ts index 997d106d..fdb0392d 100644 --- a/packages/mcp-provider-code-analyzer/src/tools/generate_xpath_prompt.ts +++ b/packages/mcp-provider-code-analyzer/src/tools/generate_xpath_prompt.ts @@ -1,6 +1,7 @@ import { z } from "zod"; import { CallToolResult } from "@modelcontextprotocol/sdk/types.js"; import { McpTool, McpToolConfig, ReleaseState, TelemetryService, Toolset } from "@salesforce/mcp-provider-api"; +import * as Constants from "../constants.js"; import { GetAstNodesActionImpl, type GetAstNodesAction, type GetAstNodesInput, type GetAstNodesOutput } from "../actions/get-ast-nodes.js"; const DESCRIPTION: string = @@ -117,6 +118,14 @@ export class GenerateXpathPromptMcpTool extends McpTool): CallToolResult | undefined { @@ -184,6 +214,28 @@ function validateInput(input: z.infer): CallToolResult | und }; } + const engine = input.engine?.trim().toLowerCase(); + if (!engine) { + const output = { + status: "engine is required", + prompt: "" + }; + return { + content: [{ type: "text", text: JSON.stringify(output) }], + structuredContent: output + }; + } + if (engine !== "pmd") { + const output = { + status: `engine '${engine}' is not supported yet`, + prompt: "" + }; + return { + content: [{ type: "text", text: JSON.stringify(output) }], + structuredContent: output + }; + } + const sampleCode = input.sampleCode?.trim(); if (!sampleCode) { const output = { diff --git a/packages/mcp-provider-code-analyzer/src/utils.ts b/packages/mcp-provider-code-analyzer/src/utils.ts index 806ee47f..17c645d1 100644 --- a/packages/mcp-provider-code-analyzer/src/utils.ts +++ b/packages/mcp-provider-code-analyzer/src/utils.ts @@ -17,3 +17,18 @@ export function escapeXml(value: string): string { .replaceAll("\"", """) .replaceAll("'", "'"); } + +/** + * Convert a user-provided name into a safe, filesystem-friendly slug. + * - Removes path separators and invalid filename characters + * - Normalizes whitespace and repeated dashes + */ +export function toSafeFilenameSlug(value: string): string { + return value + .trim() + .replace(/[\\/:"*?<>|]+/g, "-") + .replace(/\s+/g, "-") + .replace(/-+/g, "-") + .replace(/^-+|-+$/g, "") + .toLowerCase() || "custom-rule"; +} diff --git a/packages/mcp-provider-code-analyzer/test/actions/get-ast-nodes.test.ts b/packages/mcp-provider-code-analyzer/test/actions/get-ast-nodes.test.ts index 2f5a263f..25b00c84 100644 --- a/packages/mcp-provider-code-analyzer/test/actions/get-ast-nodes.test.ts +++ b/packages/mcp-provider-code-analyzer/test/actions/get-ast-nodes.test.ts @@ -1,6 +1,6 @@ import { describe, expect, it, vi } from "vitest"; -const sampleXml = ` +const nestedIfXml = ` @@ -18,7 +18,30 @@ const sampleXml = ` `.trim(); -const generateAstXmlFromSourceMock = vi.fn().mockResolvedValue(sampleXml); +const hardcodedIdXml = ` + + + + + + + + + + + + + + + + + + +`.trim(); + +const generateAstXmlFromSourceMock = vi.fn() + .mockResolvedValueOnce(nestedIfXml) + .mockResolvedValueOnce(hardcodedIdXml); vi.mock("../../src/ast/generate-ast-xml.js", () => ({ generateAstXmlFromSource: generateAstXmlFromSourceMock @@ -42,8 +65,25 @@ describe("GetAstNodesActionImpl", () => { expect(generateAstXmlFromSourceMock).toHaveBeenCalledTimes(1); expect(result.status).toBe("success"); - console.log(result.nodes); expect(result.nodes.length).toBeGreaterThan(0); - expect(result.nodes[0]?.nodeName).toBe("CompilationUnit"); + }); + + it("generates AST nodes for hardcoded Id example", async () => { + const { GetAstNodesActionImpl } = await import("../../src/actions/get-ast-nodes.js"); + const action = new GetAstNodesActionImpl(); + + const input = { + sampleCode: "public class HardcodedIdLengthExample {\n public void doWork() {\n // Violations to target: starts with 003 and length > 15 (18-char Id)\n String contactId = '0035g00000ABCDEFXYZ'; // 18 chars, starts with 003\n Id cId = '0039A00000ZZZZZQAA'; // 18 chars, starts with 003\n // Additional representative contexts\n if ('0032K00001ABCDEFGH'.startsWith('003')) { /* ... */ }\n String soql = 'SELECT Id FROM Contact WHERE Id = \\'0038X00001ABCDEFGH\\''; // embedded in string\n }\n}\n", + language: "apex", + engine: "pmd" + }; + + const result = await action.exec({ + code: input.sampleCode, + language: input.language + }); + + expect(result.status).toBe("success"); + expect(result.nodes.length).toBeGreaterThan(0); }); }); From 6ffbd3e5fb861bbdc24fd0883a917aa62eacb5ea Mon Sep 17 00:00:00 2001 From: aruntyagiTutu Date: Fri, 13 Feb 2026 12:40:01 +0530 Subject: [PATCH 12/25] @W-21102094 - follow clean code for create rule tool and remove chunky methods (#386) * langugae in ruleset template * update telemetry on success * xpath is optional * clean chuncky methods --- .../src/actions/create-xpath-custom-rule.ts | 75 ++++++++---- .../src/actions/get-ast-nodes.ts | 1 + .../src/ast/extract-ast-nodes.ts | 38 ++++-- .../src/ast/generate-ast-xml.ts | 54 +++++---- .../src/ast/metadata/apex-ast-reference.ts | 1 + .../src/templates/pmd-ruleset.xml | 2 +- .../src/tools/create_custom_rule.ts | 5 +- .../src/tools/generate_xpath_prompt.ts | 113 ++++++++---------- 8 files changed, 165 insertions(+), 124 deletions(-) diff --git a/packages/mcp-provider-code-analyzer/src/actions/create-xpath-custom-rule.ts b/packages/mcp-provider-code-analyzer/src/actions/create-xpath-custom-rule.ts index fb4edcae..26701e15 100644 --- a/packages/mcp-provider-code-analyzer/src/actions/create-xpath-custom-rule.ts +++ b/packages/mcp-provider-code-analyzer/src/actions/create-xpath-custom-rule.ts @@ -2,7 +2,7 @@ import path from "node:path"; import fs from "node:fs/promises"; import { escapeXml, toSafeFilenameSlug } from "../utils.js"; -// TODO: Work in progress. This action is a placeholder to wire the tool end-to-end. +// Creates PMD XPath ruleset XML and updates code-analyzer.yml. export type CreateXpathCustomRuleInput = { xpath: string; @@ -93,6 +93,7 @@ async function buildRuleXml(input: NormalizedInput): Promise { rulesetName: escapeXml(input.ruleName), rulesetDescription: escapeXml(input.description), ruleName: escapeXml(input.ruleName), + language: escapeXml(input.language), ruleMessage: escapeXml(input.description), ruleDescription: escapeXml(input.description), documentationUrl: "", @@ -117,27 +118,59 @@ function applyTemplate(template: string, values: Record): string } async function upsertCodeAnalyzerConfig(configPath: string, rulesetPath: string, engine: string): Promise { + const existing = await readConfigIfExists(configPath); + if (!existing) { + await writeNewCodeAnalyzerConfig(configPath, rulesetPath, engine); + return; + } + if (existing.includes(rulesetPath)) { + return; + } + const updated = addRulesetPath(existing, rulesetPath, engine); + await fs.writeFile(configPath, updated, "utf8"); +} + +function addRulesetPath(configContent: string, rulesetPath: string, engine: string): string { + const lines = configContent.split(/\r?\n/); + const indices = findRulesetBlockIndices(lines, engine); + if (indices.customRulesetsLineIndex !== -1) { + lines.splice(indices.customRulesetsLineIndex + 1, 0, ` - "${rulesetPath}"`); + return lines.join("\n"); + } + if (indices.engineLineIndex !== -1) { + lines.splice(indices.engineLineIndex + 1, 0, " custom_rulesets:", ` - "${rulesetPath}"`); + return lines.join("\n"); + } + return appendEngineRulesetBlock(configContent, rulesetPath, engine); +} + +async function readConfigIfExists(configPath: string): Promise { try { - const existing = await fs.readFile(configPath, "utf8"); - if (existing.includes(rulesetPath)) { - return; - } - const updated = addRulesetPath(existing, rulesetPath, engine); - await fs.writeFile(configPath, updated, "utf8"); + return await fs.readFile(configPath, "utf8"); } catch (error) { const code = (error as NodeJS.ErrnoException).code; - if (code !== "ENOENT") { - throw error; + if (code === "ENOENT") { + return null; } - const templatePath = new URL("../templates/code-analyzer.yml", import.meta.url); - const template = await fs.readFile(templatePath, "utf8"); - const content = applyTemplate(template, { rulesetPath, engine }); - await fs.writeFile(configPath, content, "utf8"); + throw error; } } -function addRulesetPath(configContent: string, rulesetPath: string, engine: string): string { - const lines = configContent.split(/\r?\n/); +async function writeNewCodeAnalyzerConfig( + configPath: string, + rulesetPath: string, + engine: string +): Promise { + const templatePath = new URL("../templates/code-analyzer.yml", import.meta.url); + const template = await fs.readFile(templatePath, "utf8"); + const content = applyTemplate(template, { rulesetPath, engine }); + await fs.writeFile(configPath, content, "utf8"); +} + +function findRulesetBlockIndices( + lines: string[], + engine: string +): { enginesLineIndex: number; engineLineIndex: number; customRulesetsLineIndex: number } { let enginesLineIndex = -1; let engineLineIndex = -1; let customRulesetsLineIndex = -1; @@ -158,16 +191,10 @@ function addRulesetPath(configContent: string, rulesetPath: string, engine: stri } } - if (customRulesetsLineIndex !== -1) { - lines.splice(customRulesetsLineIndex + 1, 0, ` - "${rulesetPath}"`); - return lines.join("\n"); - } - - if (engineLineIndex !== -1) { - lines.splice(engineLineIndex + 1, 0, " custom_rulesets:", ` - "${rulesetPath}"`); - return lines.join("\n"); - } + return { enginesLineIndex, engineLineIndex, customRulesetsLineIndex }; +} +function appendEngineRulesetBlock(configContent: string, rulesetPath: string, engine: string): string { return [ configContent.trimEnd(), "", diff --git a/packages/mcp-provider-code-analyzer/src/actions/get-ast-nodes.ts b/packages/mcp-provider-code-analyzer/src/actions/get-ast-nodes.ts index 5cba1228..29b37149 100644 --- a/packages/mcp-provider-code-analyzer/src/actions/get-ast-nodes.ts +++ b/packages/mcp-provider-code-analyzer/src/actions/get-ast-nodes.ts @@ -3,6 +3,7 @@ import { type AstNode, extractAstNodesFromXml } from "../ast/extract-ast-nodes.j import { getApexAstNodeMetadataByNames, type ApexAstNodeMetadata } from "../ast/metadata/apex-ast-reference.js"; import { LANGUAGE_NAMES } from "../constants.js"; +// Action that returns AST nodes plus cached metadata. export type GetAstNodesInput = { code: string; language: string; diff --git a/packages/mcp-provider-code-analyzer/src/ast/extract-ast-nodes.ts b/packages/mcp-provider-code-analyzer/src/ast/extract-ast-nodes.ts index 90822399..32bdbfe7 100644 --- a/packages/mcp-provider-code-analyzer/src/ast/extract-ast-nodes.ts +++ b/packages/mcp-provider-code-analyzer/src/ast/extract-ast-nodes.ts @@ -1,6 +1,7 @@ import { XMLParser } from "fast-xml-parser"; import * as fs from "node:fs"; +// Parses PMD AST XML into a flat node list with ancestry info. export interface AstNode { nodeName: string; attributes: Record; @@ -26,13 +27,7 @@ function traverse( ) { if (typeof node !== "object" || node === null) return result; - // Extract attributes - const attributes: Record = {}; - for (const key of Object.keys(node)) { - if (key.startsWith("@_")) { - attributes[key.substring(2)] = String(node[key]); - } - } + const attributes = collectAttributes(node); // Store current node result.push({ @@ -42,22 +37,41 @@ function traverse( ancestors, }); - // Traverse children + traverseChildren(node, nodeName, ancestors, result); + + return result; +} + +function collectAttributes(node: Record): Record { + const attributes: Record = {}; + for (const key of Object.keys(node)) { + if (key.startsWith("@_")) { + attributes[key.substring(2)] = String(node[key]); + } + } + return attributes; +} + +function traverseChildren( + node: Record, + nodeName: string, + ancestors: string[], + result: AstNode[] +): void { for (const key of Object.keys(node)) { if (key.startsWith("@_") || key === "#text") continue; const child = node[key]; + const nextAncestors = [...ancestors, nodeName]; if (Array.isArray(child)) { for (const c of child) { - traverse(c, key, [...ancestors, nodeName], nodeName, result); + traverse(c, key, nextAncestors, nodeName, result); } } else { - traverse(child, key, [...ancestors, nodeName], nodeName, result); + traverse(child, key, nextAncestors, nodeName, result); } } - - return result; } function parseAstXml(xml: string): AstNode[] { diff --git a/packages/mcp-provider-code-analyzer/src/ast/generate-ast-xml.ts b/packages/mcp-provider-code-analyzer/src/ast/generate-ast-xml.ts index 238241bb..8d0c27c8 100644 --- a/packages/mcp-provider-code-analyzer/src/ast/generate-ast-xml.ts +++ b/packages/mcp-provider-code-analyzer/src/ast/generate-ast-xml.ts @@ -5,11 +5,12 @@ import { execFile } from "node:child_process"; import { promisify } from "node:util"; import { getErrorMessage } from "../utils.js"; +// Executes PMD to produce AST XML from source code. const execFileAsync = promisify(execFile); function sanitizeExtension(language: string): string { - const cleaned = (language ?? "").toLowerCase().replace(/[^a-z0-9]/g, ""); - return cleaned.length > 0 ? cleaned : "txt"; + const cleaned = (language ?? "").toLowerCase().replace(/[^a-z0-9]/g, ""); + return cleaned.length > 0 ? cleaned : "txt"; } /** @@ -17,29 +18,34 @@ function sanitizeExtension(language: string): string { * Assumes the PMD bin folder path is provided and will be used as the cwd. */ export async function generateAstXmlFromSource( - code: string, - language: string + code: string, + language: string ): Promise { - const tempDir = await fs.mkdtemp(path.join(os.tmpdir(), "pmd-ast-")); - const sourceFile = path.join(tempDir, `source.${sanitizeExtension(language)}`); + const tempDir = await fs.mkdtemp(path.join(os.tmpdir(), "pmd-ast-")); + const sourceFile = path.join(tempDir, `source.${sanitizeExtension(language)}`); - try { - await fs.writeFile(sourceFile, code, "utf8"); - const { stdout } = await execFileAsync( - "pmd", - ["ast-dump", "--language", language, "--format", "xml", "--file", sourceFile], - { - maxBuffer: 10 * 1024 * 1024 - } - ); - return stdout.trim(); - } catch (error) { - const message = getErrorMessage(error); - if (message.toLowerCase().includes("enoent")) { - throw new Error("PMD CLI not found on PATH. Install PMD and ensure `pmd` is available in your PATH."); - } - throw new Error(`Failed to generate AST XML via PMD: ${message}`); - } finally { - await fs.rm(tempDir, { recursive: true, force: true }); + try { + await fs.writeFile(sourceFile, code, "utf8"); + const stdout = await runPmdAstDump(language, sourceFile); + return stdout.trim(); + } catch (error) { + const message = getErrorMessage(error); + if (message.toLowerCase().includes("enoent")) { + throw new Error("PMD CLI not found on PATH. Install PMD and ensure `pmd` is available in your PATH."); } + throw new Error(`Failed to generate AST XML via PMD: ${message}`); + } finally { + await fs.rm(tempDir, { recursive: true, force: true }); + } +} + +async function runPmdAstDump(language: string, sourceFile: string): Promise { + const { stdout } = await execFileAsync( + "pmd", + ["ast-dump", "--language", language, "--format", "xml", "--file", sourceFile], + { + maxBuffer: 10 * 1024 * 1024 + } + ); + return stdout; } diff --git a/packages/mcp-provider-code-analyzer/src/ast/metadata/apex-ast-reference.ts b/packages/mcp-provider-code-analyzer/src/ast/metadata/apex-ast-reference.ts index 2e5e5ed9..e016e178 100644 --- a/packages/mcp-provider-code-analyzer/src/ast/metadata/apex-ast-reference.ts +++ b/packages/mcp-provider-code-analyzer/src/ast/metadata/apex-ast-reference.ts @@ -1,5 +1,6 @@ import fs from "node:fs/promises"; +// Loads cached Apex AST node metadata from bundled JSON. export type ApexAstAttribute = { name: string; type: string; diff --git a/packages/mcp-provider-code-analyzer/src/templates/pmd-ruleset.xml b/packages/mcp-provider-code-analyzer/src/templates/pmd-ruleset.xml index 916e7d14..8e6286a4 100644 --- a/packages/mcp-provider-code-analyzer/src/templates/pmd-ruleset.xml +++ b/packages/mcp-provider-code-analyzer/src/templates/pmd-ruleset.xml @@ -5,7 +5,7 @@ xsi:schemaLocation="http://pmd.sourceforge.net/ruleset/2.0.0 https://pmd.sourceforge.io/ruleset_2_0_0.xsd"> {{rulesetDescription}} 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 4927ceb5..ec817e38 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 @@ -9,6 +9,7 @@ import { CreateXpathCustomRuleOutput } from "../actions/create-xpath-custom-rule.js"; +// MCP tool wrapper that validates input and delegates rule creation. 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. @@ -31,7 +32,7 @@ Output: - configPath: Path to the updated code-analyzer.yml that references the custom ruleset.`; export const inputSchema = z.object({ - xpath: z.string().describe("XPath expression that should match the violation."), + xpath: z.string().optional().describe("XPath expression that should match the violation (required for PMD)."), 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')."), @@ -96,7 +97,7 @@ export class CreateCustomRuleMcpTool extends McpTool [node.name.toLowerCase(), node]) - ); - const nodeSummaries = input.astNodes.map((node) => { - const metadata = metadataByName.get(node.nodeName.toLowerCase()); - return { - nodeName: node.nodeName, - parent: node.parent ?? null, - ancestors: node.ancestors, - attributes: node.attributes, - metadata: metadata ?? null - }; - }); + const nodeSummaries = buildNodeSummaries(input.astNodes, input.astMetadata); return `You are generating a PMD XPath query. Goal: Generate an XPath expression that matches the violation described earlier. @@ -201,53 +177,68 @@ Next step: Call the tool 'create_custom_rule' with the generated XPath to create the custom rule.`; } +function buildNodeSummaries( + nodes: GetAstNodesOutput["nodes"], + metadata: GetAstNodesOutput["metadata"] +): Array<{ + nodeName: string; + parent: string | null; + ancestors: string[]; + attributes: Record; + metadata: GetAstNodesOutput["metadata"][number] | null; +}> { + const metadataByName = new Map( + metadata.map((node) => [node.name.toLowerCase(), node]) + ); + return nodes.map((node) => { + const nodeMetadata = metadataByName.get(node.nodeName.toLowerCase()); + return { + nodeName: node.nodeName, + parent: node.parent ?? null, + ancestors: node.ancestors, + attributes: node.attributes, + metadata: nodeMetadata ?? null + }; + }); +} + function validateInput(input: z.infer): CallToolResult | undefined { const language = input.language?.trim(); if (!language) { - const output = { - status: "language is required", - prompt: "" - }; - return { - content: [{ type: "text", text: JSON.stringify(output) }], - structuredContent: output - }; + return buildErrorResult("language is required"); } const engine = input.engine?.trim().toLowerCase(); if (!engine) { - const output = { - status: "engine is required", - prompt: "" - }; - return { - content: [{ type: "text", text: JSON.stringify(output) }], - structuredContent: output - }; + return buildErrorResult("engine is required"); } if (engine !== "pmd") { - const output = { - status: `engine '${engine}' is not supported yet`, - prompt: "" - }; - return { - content: [{ type: "text", text: JSON.stringify(output) }], - structuredContent: output - }; + return buildErrorResult(`engine '${engine}' is not supported yet`); } const sampleCode = input.sampleCode?.trim(); if (!sampleCode) { - const output = { - status: `code in ${language} is required`, - prompt: "" - }; - return { - content: [{ type: "text", text: JSON.stringify(output) }], - structuredContent: output - }; + return buildErrorResult(`code in ${language} is required`); } return undefined; } +function buildAstInput(input: z.infer): GetAstNodesInput { + return { + code: input.sampleCode, + language: input.language + }; +} + +function buildErrorResult(status: string): CallToolResult { + return buildToolResult({ status, prompt: "" }); +} + +function buildToolResult(output: { status: string; prompt: string }): CallToolResult { + return { + content: [{ type: "text", text: JSON.stringify(output) }], + structuredContent: output + }; +} + From 8830fc01797a7edbf5c31298a84506bbb9ba1b71 Mon Sep 17 00:00:00 2001 From: aruntyagiTutu Date: Fri, 13 Feb 2026 16:30:56 +0530 Subject: [PATCH 13/25] implement patterns whereever possible (#387) --- .../src/actions/get-ast-nodes.ts | 34 +---- .../src/ast/ast-node-pipeline.ts | 52 +++++++ .../src/ast/generate-ast-xml.ts | 44 +----- .../src/ast/pmd-cli-adapter.ts | 50 +++++++ .../src/engines/engine-strategies.ts | 137 ++++++++++++++++++ .../src/tools/generate_xpath_prompt.ts | 86 +---------- 6 files changed, 250 insertions(+), 153 deletions(-) create mode 100644 packages/mcp-provider-code-analyzer/src/ast/ast-node-pipeline.ts create mode 100644 packages/mcp-provider-code-analyzer/src/ast/pmd-cli-adapter.ts create mode 100644 packages/mcp-provider-code-analyzer/src/engines/engine-strategies.ts diff --git a/packages/mcp-provider-code-analyzer/src/actions/get-ast-nodes.ts b/packages/mcp-provider-code-analyzer/src/actions/get-ast-nodes.ts index 29b37149..7ec3c485 100644 --- a/packages/mcp-provider-code-analyzer/src/actions/get-ast-nodes.ts +++ b/packages/mcp-provider-code-analyzer/src/actions/get-ast-nodes.ts @@ -1,7 +1,6 @@ -import { generateAstXmlFromSource } from "../ast/generate-ast-xml.js"; -import { type AstNode, extractAstNodesFromXml } from "../ast/extract-ast-nodes.js"; -import { getApexAstNodeMetadataByNames, type ApexAstNodeMetadata } from "../ast/metadata/apex-ast-reference.js"; -import { LANGUAGE_NAMES } from "../constants.js"; +import { type AstNode } from "../ast/extract-ast-nodes.js"; +import { type ApexAstNodeMetadata } from "../ast/metadata/apex-ast-reference.js"; +import { PmdAstNodePipeline } from "../ast/ast-node-pipeline.js"; // Action that returns AST nodes plus cached metadata. export type GetAstNodesInput = { @@ -22,20 +21,8 @@ export interface GetAstNodesAction { export class GetAstNodesActionImpl implements GetAstNodesAction { public async exec(input: GetAstNodesInput): Promise { try { - // Steps: - // 1) Generate AST XML from source code - // 2) Parse XML into AST nodes - // 3) Resolve cached metadata for unique node names (per language) - // TODO: Spike note: - // - Currently shelling out to the PMD CLI (`./pmd ast-dump`) to generate AST XML. - // - This is a temporary approach for early prototyping and should not be considered final. - // - Replace this with a direct PMD Java API integration or a Code Analyzer core API call. - // - When replacing, remove dependency on local PMD bin path and avoid spawning external processes. - const astXml = await generateAstXmlFromSource(input.code, input.language); - const nodes = extractAstNodesFromXml(astXml); - const language = input.language?.toLowerCase().trim(); - const nodeNames = Array.from(new Set(nodes.map((node) => node.nodeName))); - const metadata = await getCachedMetadataByLanguage(language, nodeNames); + const pipeline = new PmdAstNodePipeline(); + const { nodes, metadata } = await pipeline.run(input); return { status: "success", nodes, metadata }; } catch (e) { return { status: (e as Error)?.message ?? String(e), nodes: [], metadata: [] }; @@ -43,14 +30,3 @@ export class GetAstNodesActionImpl implements GetAstNodesAction { } } -async function getCachedMetadataByLanguage( - language: string, - nodeNames: string[] -): Promise { - const normalized = (language ?? "").toLowerCase().trim(); - if (normalized === LANGUAGE_NAMES.Apex) { - return getApexAstNodeMetadataByNames(nodeNames); - } - return []; -} - diff --git a/packages/mcp-provider-code-analyzer/src/ast/ast-node-pipeline.ts b/packages/mcp-provider-code-analyzer/src/ast/ast-node-pipeline.ts new file mode 100644 index 00000000..e4051486 --- /dev/null +++ b/packages/mcp-provider-code-analyzer/src/ast/ast-node-pipeline.ts @@ -0,0 +1,52 @@ +import { extractAstNodesFromXml, type AstNode } from "./extract-ast-nodes.js"; +import { type ApexAstNodeMetadata } from "./metadata/apex-ast-reference.js"; +import { getEngineStrategy } from "../engines/engine-strategies.js"; + +// Template Method pipeline for AST XML -> nodes -> metadata. +export type AstPipelineInput = { + code: string; + language: string; +}; + +export type AstPipelineOutput = { + nodes: AstNode[]; + metadata: ApexAstNodeMetadata[]; +}; + +export abstract class AstNodePipeline { + public async run(input: AstPipelineInput): Promise { + const astXml = await this.generateAstXml(input); + const nodes = this.extractNodes(astXml); + const metadata = await this.enrichMetadata(input, nodes); + return { nodes, metadata }; + } + + protected abstract generateAstXml(input: AstPipelineInput): Promise; + + protected extractNodes(astXml: string): AstNode[] { + return extractAstNodesFromXml(astXml); + } + + protected async enrichMetadata( + _input: AstPipelineInput, + _nodes: AstNode[] + ): Promise { + return []; + } +} + +export class PmdAstNodePipeline extends AstNodePipeline { + private readonly strategy = getEngineStrategy("pmd"); + + protected async generateAstXml(input: AstPipelineInput): Promise { + return this.strategy.astGenerator.generateAstXml(input.code, input.language); + } + + protected async enrichMetadata( + input: AstPipelineInput, + nodes: AstNode[] + ): Promise { + const nodeNames = Array.from(new Set(nodes.map((node) => node.nodeName))); + return this.strategy.metadataProvider.getMetadata(input.language, nodeNames); + } +} diff --git a/packages/mcp-provider-code-analyzer/src/ast/generate-ast-xml.ts b/packages/mcp-provider-code-analyzer/src/ast/generate-ast-xml.ts index 8d0c27c8..252ebb70 100644 --- a/packages/mcp-provider-code-analyzer/src/ast/generate-ast-xml.ts +++ b/packages/mcp-provider-code-analyzer/src/ast/generate-ast-xml.ts @@ -1,17 +1,4 @@ -import os from "node:os"; -import path from "node:path"; -import fs from "node:fs/promises"; -import { execFile } from "node:child_process"; -import { promisify } from "node:util"; -import { getErrorMessage } from "../utils.js"; - -// Executes PMD to produce AST XML from source code. -const execFileAsync = promisify(execFile); - -function sanitizeExtension(language: string): string { - const cleaned = (language ?? "").toLowerCase().replace(/[^a-z0-9]/g, ""); - return cleaned.length > 0 ? cleaned : "txt"; -} +import { PmdCliAstXmlAdapter } from "./pmd-cli-adapter.js"; /** * Generates AST XML for the given source code using the PMD CLI. @@ -21,31 +8,6 @@ export async function generateAstXmlFromSource( code: string, language: string ): Promise { - const tempDir = await fs.mkdtemp(path.join(os.tmpdir(), "pmd-ast-")); - const sourceFile = path.join(tempDir, `source.${sanitizeExtension(language)}`); - - try { - await fs.writeFile(sourceFile, code, "utf8"); - const stdout = await runPmdAstDump(language, sourceFile); - return stdout.trim(); - } catch (error) { - const message = getErrorMessage(error); - if (message.toLowerCase().includes("enoent")) { - throw new Error("PMD CLI not found on PATH. Install PMD and ensure `pmd` is available in your PATH."); - } - throw new Error(`Failed to generate AST XML via PMD: ${message}`); - } finally { - await fs.rm(tempDir, { recursive: true, force: true }); - } -} - -async function runPmdAstDump(language: string, sourceFile: string): Promise { - const { stdout } = await execFileAsync( - "pmd", - ["ast-dump", "--language", language, "--format", "xml", "--file", sourceFile], - { - maxBuffer: 10 * 1024 * 1024 - } - ); - return stdout; + const adapter = new PmdCliAstXmlAdapter(); + return adapter.generateAstXml(code, language); } diff --git a/packages/mcp-provider-code-analyzer/src/ast/pmd-cli-adapter.ts b/packages/mcp-provider-code-analyzer/src/ast/pmd-cli-adapter.ts new file mode 100644 index 00000000..e07f29fb --- /dev/null +++ b/packages/mcp-provider-code-analyzer/src/ast/pmd-cli-adapter.ts @@ -0,0 +1,50 @@ +import os from "node:os"; +import path from "node:path"; +import fs from "node:fs/promises"; +import { execFile } from "node:child_process"; +import { promisify } from "node:util"; +import { getErrorMessage } from "../utils.js"; + +// Adapter that wraps the PMD CLI as an AST XML provider. +const execFileAsync = promisify(execFile); + +export interface AstXmlAdapter { + generateAstXml(code: string, language: string): Promise; +} + +export class PmdCliAstXmlAdapter implements AstXmlAdapter { + public async generateAstXml(code: string, language: string): Promise { + const tempDir = await fs.mkdtemp(path.join(os.tmpdir(), "pmd-ast-")); + const sourceFile = path.join(tempDir, `source.${sanitizeExtension(language)}`); + + try { + await fs.writeFile(sourceFile, code, "utf8"); + const stdout = await runPmdAstDump(language, sourceFile); + return stdout.trim(); + } catch (error) { + const message = getErrorMessage(error); + if (message.toLowerCase().includes("enoent")) { + throw new Error("PMD CLI not found on PATH. Install PMD and ensure `pmd` is available in your PATH."); + } + throw new Error(`Failed to generate AST XML via PMD: ${message}`); + } finally { + await fs.rm(tempDir, { recursive: true, force: true }); + } + } +} + +function sanitizeExtension(language: string): string { + const cleaned = (language ?? "").toLowerCase().replace(/[^a-z0-9]/g, ""); + return cleaned.length > 0 ? cleaned : "txt"; +} + +async function runPmdAstDump(language: string, sourceFile: string): Promise { + const { stdout } = await execFileAsync( + "pmd", + ["ast-dump", "--language", language, "--format", "xml", "--file", sourceFile], + { + maxBuffer: 10 * 1024 * 1024 + } + ); + return stdout; +} diff --git a/packages/mcp-provider-code-analyzer/src/engines/engine-strategies.ts b/packages/mcp-provider-code-analyzer/src/engines/engine-strategies.ts new file mode 100644 index 00000000..8824b313 --- /dev/null +++ b/packages/mcp-provider-code-analyzer/src/engines/engine-strategies.ts @@ -0,0 +1,137 @@ +import { generateAstXmlFromSource } from "../ast/generate-ast-xml.js"; +import { type AstNode } from "../ast/extract-ast-nodes.js"; +import { getApexAstNodeMetadataByNames, type ApexAstNodeMetadata } from "../ast/metadata/apex-ast-reference.js"; +import { LANGUAGE_NAMES } from "../constants.js"; + +export type EngineName = "pmd"; + +export type PromptInput = { + language: string; + engine: string; + astNodes: AstNode[]; + astMetadata: ApexAstNodeMetadata[]; +}; + +export interface AstGenerator { + generateAstXml(code: string, language: string): Promise; +} + +export interface AstMetadataProvider { + getMetadata(language: string, nodeNames: string[]): Promise; +} + +export interface PromptBuilder { + buildPrompt(input: PromptInput): string; +} + +export type EngineStrategy = { + engine: EngineName; + astGenerator: AstGenerator; + metadataProvider: AstMetadataProvider; + promptBuilder: PromptBuilder; +}; + +class PmdAstGenerator implements AstGenerator { + public async generateAstXml(code: string, language: string): Promise { + return generateAstXmlFromSource(code, language); + } +} + +class PmdAstMetadataProvider implements AstMetadataProvider { + public async getMetadata(language: string, nodeNames: string[]): Promise { + const normalized = (language ?? "").toLowerCase().trim(); + if (normalized === LANGUAGE_NAMES.Apex) { + return getApexAstNodeMetadataByNames(nodeNames); + } + return []; + } +} + +class PmdPromptBuilder implements PromptBuilder { + public buildPrompt(input: PromptInput): string { + const nodeSummaries = buildNodeSummaries(input.astNodes, input.astMetadata); + return `You are generating a PMD XPath query. +Goal: Generate an XPath expression that matches the violation described earlier. + +Context: + +Engine: ${input.engine} + +Language: ${input.language} + +AST nodes (from ast-dump) with extracted metadata: +${JSON.stringify(nodeSummaries, null, 2)} + +Task: + +Use the AST nodes and metadata above to write a precise XPath for the violation. + +Create the XPath for the scenario described by the user request. + +Prefer minimal, stable XPath that avoids overfitting. + +Return only the XPath expression. + +Requirements: + +Review availableNodes (${nodeSummaries.length} nodes) to identify needed nodes. + +Use ONLY node names from availableNodes. + +Use only attributes present in the AST metadata. + +Treat attribute values exactly as shown in metadata (e.g., if @Image includes quotes, do not strip them). + +Do not invent attributes or assume normalization. + +Prefer structural matching over string manipulation. + +Avoid complex XPath functions unless clearly required. + +Ensure compatibility with PMD ${input.engine} XPath support. + +Next step: + +Call the tool 'create_custom_rule' with the generated XPath to create the custom rule.`; + } +} + +const PMD_STRATEGY: EngineStrategy = { + engine: "pmd", + astGenerator: new PmdAstGenerator(), + metadataProvider: new PmdAstMetadataProvider(), + promptBuilder: new PmdPromptBuilder() +}; + +export function getEngineStrategy(engine: string): EngineStrategy { + const normalized = (engine ?? "").toLowerCase().trim(); + if (normalized === "pmd") { + return PMD_STRATEGY; + } + throw new Error(`engine '${engine}' is not supported yet`); +} + +function buildNodeSummaries( + nodes: AstNode[], + metadata: ApexAstNodeMetadata[] +): Array<{ + nodeName: string; + parent: string | null; + ancestors: string[]; + attributes: Record; + metadata: ApexAstNodeMetadata | null; +}> { + const metadataByName = new Map( + metadata.map((node) => [node.name.toLowerCase(), node]) + ); + return nodes.map((node) => { + const nodeMetadata = metadataByName.get(node.nodeName.toLowerCase()); + return { + nodeName: node.nodeName, + parent: node.parent ?? null, + ancestors: node.ancestors, + attributes: node.attributes, + metadata: nodeMetadata ?? null + }; + }); +} diff --git a/packages/mcp-provider-code-analyzer/src/tools/generate_xpath_prompt.ts b/packages/mcp-provider-code-analyzer/src/tools/generate_xpath_prompt.ts index f8371b3d..951626c8 100644 --- a/packages/mcp-provider-code-analyzer/src/tools/generate_xpath_prompt.ts +++ b/packages/mcp-provider-code-analyzer/src/tools/generate_xpath_prompt.ts @@ -3,6 +3,7 @@ import { CallToolResult } from "@modelcontextprotocol/sdk/types.js"; import { McpTool, McpToolConfig, ReleaseState, TelemetryService, Toolset } from "@salesforce/mcp-provider-api"; import * as Constants from "../constants.js"; import { GetAstNodesActionImpl, type GetAstNodesAction, type GetAstNodesInput, type GetAstNodesOutput } from "../actions/get-ast-nodes.js"; +import { getEngineStrategy } from "../engines/engine-strategies.js"; // Builds the prompt that guides XPath authoring from AST context. const DESCRIPTION: string = @@ -99,10 +100,10 @@ export class GenerateXpathPromptMcpTool extends McpTool; - metadata: GetAstNodesOutput["metadata"][number] | null; -}> { - const metadataByName = new Map( - metadata.map((node) => [node.name.toLowerCase(), node]) - ); - return nodes.map((node) => { - const nodeMetadata = metadataByName.get(node.nodeName.toLowerCase()); - return { - nodeName: node.nodeName, - parent: node.parent ?? null, - ancestors: node.ancestors, - attributes: node.attributes, - metadata: nodeMetadata ?? null - }; - }); -} - function validateInput(input: z.infer): CallToolResult | undefined { const language = input.language?.trim(); if (!language) { From 2b0809412514946b45d3eaabc46387f6bf64bb86 Mon Sep 17 00:00:00 2001 From: aruntyagiTutu Date: Fri, 13 Feb 2026 17:12:11 +0530 Subject: [PATCH 14/25] path updation in code analyzer yml to be relative (#388) --- .../src/actions/create-xpath-custom-rule.ts | 8 +++++++- 1 file changed, 7 insertions(+), 1 deletion(-) diff --git a/packages/mcp-provider-code-analyzer/src/actions/create-xpath-custom-rule.ts b/packages/mcp-provider-code-analyzer/src/actions/create-xpath-custom-rule.ts index 26701e15..87d7e0bf 100644 --- a/packages/mcp-provider-code-analyzer/src/actions/create-xpath-custom-rule.ts +++ b/packages/mcp-provider-code-analyzer/src/actions/create-xpath-custom-rule.ts @@ -34,10 +34,11 @@ export class CreateXpathCustomRuleActionImpl implements CreateXpathCustomRuleAct const ruleXml = await buildRuleXml(normalized); const { customRulesDir, rulesetPath, configPath } = buildPaths(normalized); + const rulesetPathForConfig = toRelativeRulesetPath(normalized.workingDirectory, rulesetPath); await fs.mkdir(customRulesDir, { recursive: true }); await fs.writeFile(rulesetPath, ruleXml, "utf8"); - await upsertCodeAnalyzerConfig(configPath, rulesetPath, normalized.engine); + await upsertCodeAnalyzerConfig(configPath, rulesetPathForConfig, normalized.engine); return { status: "success", ruleXml, rulesetPath, configPath }; } @@ -113,6 +114,11 @@ function buildPaths(input: NormalizedInput): { customRulesDir: string; rulesetPa }; } +function toRelativeRulesetPath(workingDirectory: string, rulesetPath: string): string { + const relativePath = path.relative(workingDirectory, rulesetPath); + return relativePath.split(path.sep).join("/"); +} + function applyTemplate(template: string, values: Record): string { return template.replace(/\{\{(\w+)\}\}/g, (_, key: string) => values[key] ?? ""); } From bbc6c5e2aad54ecc72cafbe6a40f41a057b35f46 Mon Sep 17 00:00:00 2001 From: Arun Tyagi Date: Mon, 16 Feb 2026 11:46:18 +0530 Subject: [PATCH 15/25] add comprehensive guidline in the prompt --- .../src/engines/engine-strategies.ts | 53 +++++++++++++++++++ 1 file changed, 53 insertions(+) diff --git a/packages/mcp-provider-code-analyzer/src/engines/engine-strategies.ts b/packages/mcp-provider-code-analyzer/src/engines/engine-strategies.ts index 8824b313..c0631353 100644 --- a/packages/mcp-provider-code-analyzer/src/engines/engine-strategies.ts +++ b/packages/mcp-provider-code-analyzer/src/engines/engine-strategies.ts @@ -72,6 +72,59 @@ Prefer minimal, stable XPath that avoids overfitting. Return only the XPath expression. +Guidelines (PMD/Apex XPath): + +- Target the smallest stable ancestor that owns the behavior (e.g., MethodCallExpression). +- Avoid cross-node joins (no current(), no sibling/parent chains, no @Image equality to correlate identifiers). +- Prefer structural signals over string matching (e.g., BinaryExpression[@Op='+'], MethodCallExpression[@FullMethodName='X.Y']). +- Use pragmatic string checks only when needed (LiteralExpression guards for edge cases). +- Use only node names/attributes present in the AST dump and metadata. + +Prompt boilerplate: +- Use only node names and attributes seen in the following PMD Apex AST dump. Do not invent attributes. Treat attribute values exactly as they appear. +- Select the top-most behavior node (e.g., MethodCallExpression for Database.query/.countQuery) and match evidence of violation anywhere in its subtree using descendant axes. +- Avoid using current() and identifier equality comparisons (@Image) to correlate nodes. Do not rely on sibling or parent chains that may vary. +- Prefer simple, robust patterns over deep, brittle paths. Allow inline and variable-initialized forms. +- Return only the XPath expression, PMD-compatible, no CDATA or extra text. + +Verification checklist: +- Ensure the XPath matches: + - Inline violation: Database.query('...' + var) + - Variable-based violation: String q = '...' + var; Database.query(q) + - Multi-part concatenation chains: 'a' + b + 'c' + d + - String literal containing '+' that contributes to the query + - Both Database.query and Database.countQuery +- Ensure the XPath does not depend on variable names or declaration order. +- Prefer descendant:: over absolute paths; avoid hard-coding depths. +- If matching an API, gate on @FullMethodName='Namespace.method' not @Image. + +Pattern templates (adapt as needed): +- Method call with subtree evidence: + //MethodCallExpression[ @FullMethodName='Database.query' or @FullMethodName='Database.countQuery' ][ .//BinaryExpression[@Op='+'] or .//LiteralExpression[@LiteralType='STRING' and contains(@Image, '+')] ] +- Ban System.debug in non-test code: + //MethodCallExpression[ @FullMethodName='System.debug' ] +- Detect DML in loops: + //ForStatement | //WhileStatement | //ForEachStatement [.//MethodCallExpression[ @FullMethodName='Database.insert' or @FullMethodName='Database.update' or @FullMethodName='Database.delete' ]] +- Detect hardcoded IDs: + //LiteralExpression[ @LiteralType='STRING' and matches(@Image, '^[a-zA-Z0-9]{15,18}$') ] + +AST-first workflow: +1. Generate a minimal, compiling violating Apex snippet for the scenario. +2. Dump the PMD AST for that snippet. +3. Identify the smallest stable ancestor node to select. +4. Write XPath that: + - Filters by the ancestor’s discriminant attributes (e.g., @FullMethodName) + - Uses .// to search for evidence nodes under it (BinaryExpression, LiteralExpression, etc.) + - Avoids current() and identifier joins +5. Validate the XPath against multiple variants of the snippet. + +Acceptance test (must pass): +- Database.query('SELECT Id FROM A WHERE Name = ' + name) +- String q = 'SELECT Id FROM A WHERE Name = ' + name; Database.query(q); +- Database.countQuery('SELECT COUNT() FROM A ' + 'WHERE Type = ' + t); +- String q = 'SELECT ' + 'Id' + ' FROM A'; Database.query(q); +- Must not rely on variable names, current(), or fixed ancestor depths. + Requirements: Review availableNodes (${nodeSummaries.length} nodes) to identify needed nodes. From 8eaf915bfdbaed101b455027ebbe89fcbf2319f8 Mon Sep 17 00:00:00 2001 From: Arun Tyagi Date: Mon, 16 Feb 2026 11:48:50 +0530 Subject: [PATCH 16/25] custom code analyzer yaml template --- .../mcp-provider-code-analyzer/src/templates/code-analyzer.yml | 1 + 1 file changed, 1 insertion(+) diff --git a/packages/mcp-provider-code-analyzer/src/templates/code-analyzer.yml b/packages/mcp-provider-code-analyzer/src/templates/code-analyzer.yml index 22d3009e..66a253a9 100644 --- a/packages/mcp-provider-code-analyzer/src/templates/code-analyzer.yml +++ b/packages/mcp-provider-code-analyzer/src/templates/code-analyzer.yml @@ -1,4 +1,5 @@ engines: pmd: + # Custom ruleset paths for PMD. custom_rulesets: - "{{rulesetPath}}" From 1669e64c021c5da39b0d5686b496da96036ab7c3 Mon Sep 17 00:00:00 2001 From: aruntyagiTutu Date: Tue, 17 Feb 2026 11:02:41 +0530 Subject: [PATCH 17/25] @W-21102094 - create rule unit tests for ast and actions functions (#392) * tests for ast and actions functions * path platform agnostic --- .../actions/create-xpath-custom-rule.test.ts | 217 ++++++++++++++++++ .../test/actions/get-ast-nodes.test.ts | 46 ++++ .../test/ast/generate-ast-xml.test.ts | 31 +++ .../test/ast/pmd-cli-adapter.test.ts | 102 ++++++++ .../test/utils.test.ts | 25 ++ 5 files changed, 421 insertions(+) create mode 100644 packages/mcp-provider-code-analyzer/test/actions/create-xpath-custom-rule.test.ts create mode 100644 packages/mcp-provider-code-analyzer/test/ast/generate-ast-xml.test.ts create mode 100644 packages/mcp-provider-code-analyzer/test/ast/pmd-cli-adapter.test.ts create mode 100644 packages/mcp-provider-code-analyzer/test/utils.test.ts diff --git a/packages/mcp-provider-code-analyzer/test/actions/create-xpath-custom-rule.test.ts b/packages/mcp-provider-code-analyzer/test/actions/create-xpath-custom-rule.test.ts new file mode 100644 index 00000000..0b051871 --- /dev/null +++ b/packages/mcp-provider-code-analyzer/test/actions/create-xpath-custom-rule.test.ts @@ -0,0 +1,217 @@ +import { describe, expect, it, afterEach } from "vitest"; +import fs from "node:fs/promises"; +import os from "node:os"; +import path from "node:path"; + +async function createTempDir(): Promise { + return fs.mkdtemp(path.join(os.tmpdir(), "mcp-custom-rule-")); +} + +async function cleanupTempDir(dir: string | undefined): Promise { + if (!dir) { + return; + } + await fs.rm(dir, { recursive: true, force: true }); +} + +function countOccurrences(haystack: string, needle: string): number { + return haystack.split(needle).length - 1; +} + +describe("CreateXpathCustomRuleActionImpl", () => { + let tempDir: string | undefined; + + afterEach(async () => { + await cleanupTempDir(tempDir); + tempDir = undefined; + }); + + it("returns an error when xpath is missing", async () => { + const { CreateXpathCustomRuleActionImpl } = await import("../../src/actions/create-xpath-custom-rule.js"); + const action = new CreateXpathCustomRuleActionImpl(); + const result = await action.exec({ + xpath: "", + engine: "pmd", + workingDirectory: "/tmp" + }); + expect(result.status).toBe("xpath is required"); + }); + + it("returns an error for unsupported engines", async () => { + const { CreateXpathCustomRuleActionImpl } = await import("../../src/actions/create-xpath-custom-rule.js"); + const action = new CreateXpathCustomRuleActionImpl(); + const result = await action.exec({ + xpath: "//MethodCallExpression", + engine: "eslint", + workingDirectory: "/tmp" + }); + expect(result.status).toBe("engine 'eslint' is not supported yet"); + }); + + it("returns an error when workingDirectory is missing", async () => { + const { CreateXpathCustomRuleActionImpl } = await import("../../src/actions/create-xpath-custom-rule.js"); + const action = new CreateXpathCustomRuleActionImpl(); + const result = await action.exec({ + xpath: "//MethodCallExpression", + engine: "pmd", + workingDirectory: " " + }); + expect(result.status).toBe("workingDirectory is required"); + }); + + it("writes ruleset XML and config with a relative ruleset path", async () => { + const { CreateXpathCustomRuleActionImpl } = await import("../../src/actions/create-xpath-custom-rule.js"); + const action = new CreateXpathCustomRuleActionImpl(); + tempDir = await createTempDir(); + + const result = await action.exec({ + xpath: "//MethodCallExpression[@FullMethodName='System.debug']", + ruleName: "My Rule", + description: "No System.debug", + language: "apex", + engine: "pmd", + priority: 2, + workingDirectory: tempDir + }); + + expect(result.status).toBe("success"); + expect(result.rulesetPath).toBeTruthy(); + expect(result.configPath).toBeTruthy(); + + const configContent = await fs.readFile(result.configPath as string, "utf8"); + expect(configContent).toContain('custom_rulesets:'); + expect(configContent).toContain('custom-rules/my-rule-pmd-rules.xml'); + expect(configContent).not.toContain(tempDir); + }); + + it("does not rewrite config when ruleset path already exists", async () => { + const { CreateXpathCustomRuleActionImpl } = await import("../../src/actions/create-xpath-custom-rule.js"); + const action = new CreateXpathCustomRuleActionImpl(); + tempDir = await createTempDir(); + + const configPath = path.join(tempDir, "code-analyzer.yml"); + await fs.writeFile( + configPath, + [ + "engines:", + " pmd:", + " custom_rulesets:", + ' - "custom-rules/dup-rule-pmd-rules.xml"' + ].join("\n"), + "utf8" + ); + + const result = await action.exec({ + xpath: "//MethodCallExpression[@FullMethodName='System.debug']", + ruleName: "Dup Rule", + engine: "pmd", + workingDirectory: tempDir + }); + expect(result.status).toBe("success"); + + const updated = await fs.readFile(configPath, "utf8"); + expect(countOccurrences(updated, 'custom-rules/dup-rule-pmd-rules.xml')).toBe(1); + }); + + it("adds a ruleset path to an existing config and avoids duplicates", async () => { + const { CreateXpathCustomRuleActionImpl } = await import("../../src/actions/create-xpath-custom-rule.js"); + const action = new CreateXpathCustomRuleActionImpl(); + tempDir = await createTempDir(); + + const configPath = path.join(tempDir, "code-analyzer.yml"); + await fs.writeFile( + configPath, + [ + "engines:", + " pmd:", + " custom_rulesets:", + ' - "custom-rules/existing.xml"' + ].join("\n"), + "utf8" + ); + + const result = await action.exec({ + xpath: "//MethodCallExpression[@FullMethodName='System.debug']", + ruleName: "Extra Rule", + engine: "pmd", + workingDirectory: tempDir + }); + expect(result.status).toBe("success"); + + const updated = await fs.readFile(configPath, "utf8"); + expect(updated).toContain('custom-rules/existing.xml'); + expect(updated).toContain('custom-rules/extra-rule-pmd-rules.xml'); + + const count = countOccurrences(updated, 'custom-rules/extra-rule-pmd-rules.xml'); + expect(count).toBe(1); + }); + + it("appends an engine block when missing", async () => { + const { CreateXpathCustomRuleActionImpl } = await import("../../src/actions/create-xpath-custom-rule.js"); + const action = new CreateXpathCustomRuleActionImpl(); + tempDir = await createTempDir(); + + const configPath = path.join(tempDir, "code-analyzer.yml"); + await fs.writeFile(configPath, "version: 1\n", "utf8"); + + const result = await action.exec({ + xpath: "//MethodCallExpression[@FullMethodName='System.debug']", + ruleName: "Rule", + engine: "pmd", + workingDirectory: tempDir + }); + expect(result.status).toBe("success"); + + const updated = await fs.readFile(configPath, "utf8"); + expect(updated).toContain("engines:"); + expect(updated).toContain(" pmd:"); + expect(updated).toContain('custom_rulesets:'); + expect(updated).toContain('custom-rules/rule-pmd-rules.xml'); + }); + + it("adds custom_rulesets under existing engine when missing", async () => { + const { CreateXpathCustomRuleActionImpl } = await import("../../src/actions/create-xpath-custom-rule.js"); + const action = new CreateXpathCustomRuleActionImpl(); + tempDir = await createTempDir(); + + const configPath = path.join(tempDir, "code-analyzer.yml"); + await fs.writeFile( + configPath, + [ + "engines:", + " pmd:", + " java_command: java" + ].join("\n"), + "utf8" + ); + + const result = await action.exec({ + xpath: "//MethodCallExpression[@FullMethodName='System.debug']", + ruleName: "Inserted", + engine: "pmd", + workingDirectory: tempDir + }); + expect(result.status).toBe("success"); + + const updated = await fs.readFile(configPath, "utf8"); + expect(updated).toContain(" pmd:"); + expect(updated).toContain(" custom_rulesets:"); + expect(updated).toContain('custom-rules/inserted-pmd-rules.xml'); + }); + + it("throws when config path is not readable", async () => { + const { CreateXpathCustomRuleActionImpl } = await import("../../src/actions/create-xpath-custom-rule.js"); + const action = new CreateXpathCustomRuleActionImpl(); + tempDir = await createTempDir(); + + const configPath = path.join(tempDir, "code-analyzer.yml"); + await fs.mkdir(configPath, { recursive: true }); + + await expect(action.exec({ + xpath: "//MethodCallExpression[@FullMethodName='System.debug']", + ruleName: "Unreadable", + engine: "pmd", + workingDirectory: tempDir + })).rejects.toThrow(); + }); +}); diff --git a/packages/mcp-provider-code-analyzer/test/actions/get-ast-nodes.test.ts b/packages/mcp-provider-code-analyzer/test/actions/get-ast-nodes.test.ts index 25b00c84..18e76d18 100644 --- a/packages/mcp-provider-code-analyzer/test/actions/get-ast-nodes.test.ts +++ b/packages/mcp-provider-code-analyzer/test/actions/get-ast-nodes.test.ts @@ -86,4 +86,50 @@ describe("GetAstNodesActionImpl", () => { expect(result.status).toBe("success"); expect(result.nodes.length).toBeGreaterThan(0); }); + + it("returns an error status when the pipeline throws", async () => { + vi.resetModules(); + vi.doMock("../../src/ast/ast-node-pipeline.js", () => ({ + PmdAstNodePipeline: class { + public async run(): Promise { + throw new Error("boom"); + } + } + })); + + const { GetAstNodesActionImpl } = await import("../../src/actions/get-ast-nodes.js"); + const action = new GetAstNodesActionImpl(); + + const result = await action.exec({ + code: "class X {}", + language: "apex" + }); + + expect(result.status).toBe("boom"); + expect(result.nodes).toEqual([]); + expect(result.metadata).toEqual([]); + }); + + it("falls back to string error when non-Error is thrown", async () => { + vi.resetModules(); + vi.doMock("../../src/ast/ast-node-pipeline.js", () => ({ + PmdAstNodePipeline: class { + public async run(): Promise { + throw "boom-string"; + } + } + })); + + const { GetAstNodesActionImpl } = await import("../../src/actions/get-ast-nodes.js"); + const action = new GetAstNodesActionImpl(); + + const result = await action.exec({ + code: "class X {}", + language: "apex" + }); + + expect(result.status).toBe("boom-string"); + expect(result.nodes).toEqual([]); + expect(result.metadata).toEqual([]); + }); }); diff --git a/packages/mcp-provider-code-analyzer/test/ast/generate-ast-xml.test.ts b/packages/mcp-provider-code-analyzer/test/ast/generate-ast-xml.test.ts new file mode 100644 index 00000000..9bd43fa3 --- /dev/null +++ b/packages/mcp-provider-code-analyzer/test/ast/generate-ast-xml.test.ts @@ -0,0 +1,31 @@ +import { describe, expect, it, vi } from "vitest"; + +const generateAstXmlMock = vi.fn(); + +vi.mock("../../src/ast/pmd-cli-adapter.js", () => ({ + PmdCliAstXmlAdapter: class { + public generateAstXml(code: string, language: string): Promise { + return generateAstXmlMock(code, language); + } + } +})); + +describe("generateAstXmlFromSource", () => { + it("delegates to the PMD CLI adapter", async () => { + generateAstXmlMock.mockResolvedValueOnce(""); + const { generateAstXmlFromSource } = await import("../../src/ast/generate-ast-xml.js"); + + const result = await generateAstXmlFromSource("class X {}", "apex"); + + expect(generateAstXmlMock).toHaveBeenCalledTimes(1); + expect(generateAstXmlMock).toHaveBeenCalledWith("class X {}", "apex"); + expect(result).toBe(""); + }); + + it("propagates adapter errors", async () => { + generateAstXmlMock.mockRejectedValueOnce(new Error("boom")); + const { generateAstXmlFromSource } = await import("../../src/ast/generate-ast-xml.js"); + + await expect(generateAstXmlFromSource("class X {}", "apex")).rejects.toThrow("boom"); + }); +}); diff --git a/packages/mcp-provider-code-analyzer/test/ast/pmd-cli-adapter.test.ts b/packages/mcp-provider-code-analyzer/test/ast/pmd-cli-adapter.test.ts new file mode 100644 index 00000000..ace56d36 --- /dev/null +++ b/packages/mcp-provider-code-analyzer/test/ast/pmd-cli-adapter.test.ts @@ -0,0 +1,102 @@ +import { describe, expect, it, vi, afterEach } from "vitest"; +import path from "node:path"; +import { PmdCliAstXmlAdapter } from "../../src/ast/pmd-cli-adapter.js"; + +const mkdtempMock = vi.fn(); +const writeFileMock = vi.fn(); +const rmMock = vi.fn(); +const execFileMock = vi.fn(); + +vi.mock("node:fs/promises", () => ({ + default: { + mkdtemp: (path: string) => mkdtempMock(path), + writeFile: (path: string, content: string, encoding: string) => writeFileMock(path, content, encoding), + rm: (path: string, options: { recursive: boolean; force: boolean }) => rmMock(path, options) + } +})); + +vi.mock("node:child_process", () => { + const execFile = ( + cmd: string, + args: string[], + options: { maxBuffer: number }, + cb: (err: Error | null, stdout: string, stderr: string) => void + ) => execFileMock(cmd, args, options, cb); + + (execFile as unknown as Record)[Symbol.for("nodejs.util.promisify.custom")] = ( + cmd: string, + args: string[], + options: { maxBuffer: number } + ) => + new Promise((resolve, reject) => { + execFileMock(cmd, args, options, (err: Error | null, stdout: string, stderr: string) => { + if (err) { + reject(err); + return; + } + resolve({ stdout, stderr }); + }); + }); + + return { execFile }; +}); + + +describe("PmdCliAstXmlAdapter", () => { + afterEach(() => { + vi.clearAllMocks(); + }); + + it("generates AST XML via the PMD CLI and cleans up temp files", async () => { + mkdtempMock.mockResolvedValueOnce("/tmp/pmd-ast-123"); + writeFileMock.mockResolvedValueOnce(undefined); + rmMock.mockResolvedValueOnce(undefined); + execFileMock.mockImplementationOnce((_cmd, _args, _options, cb) => cb(null, "", "")); + + const adapter = new PmdCliAstXmlAdapter(); + + const result = await adapter.generateAstXml("class X {}", "apex"); + + expect(result).toBe(""); + expect(mkdtempMock).toHaveBeenCalledTimes(1); + expect(writeFileMock).toHaveBeenCalledTimes(1); + expect(execFileMock).toHaveBeenCalledTimes(1); + expect(rmMock).toHaveBeenCalledTimes(1); + + const execArgs = execFileMock.mock.calls[0]; + expect(execArgs[0]).toBe("pmd"); + expect(execArgs[1]).toEqual([ + "ast-dump", + "--language", + "apex", + "--format", + "xml", + "--file", + path.join("/tmp/pmd-ast-123", "source.apex") + ]); + }); + + it("throws a helpful error when PMD CLI is missing", async () => { + mkdtempMock.mockResolvedValueOnce("/tmp/pmd-ast-123"); + writeFileMock.mockResolvedValueOnce(undefined); + rmMock.mockResolvedValueOnce(undefined); + execFileMock.mockImplementationOnce((_cmd, _args, _options, cb) => cb(new Error("ENOENT: pmd not found"), "", "")); + + const adapter = new PmdCliAstXmlAdapter(); + + await expect(adapter.generateAstXml("class X {}", "apex")) + .rejects.toThrow("PMD CLI not found on PATH"); + }); + + it("throws a generic error when PMD CLI fails", async () => { + mkdtempMock.mockResolvedValueOnce("/tmp/pmd-ast-123"); + writeFileMock.mockResolvedValueOnce(undefined); + rmMock.mockResolvedValueOnce(undefined); + execFileMock.mockImplementationOnce((_cmd, _args, _options, cb) => cb(new Error("bad stuff"), "", "")); + + const adapter = new PmdCliAstXmlAdapter(); + + await expect(adapter.generateAstXml("class X {}", "apex")) + .rejects.toThrow("Failed to generate AST XML via PMD: bad stuff"); + }); +}); diff --git a/packages/mcp-provider-code-analyzer/test/utils.test.ts b/packages/mcp-provider-code-analyzer/test/utils.test.ts new file mode 100644 index 00000000..ef36c91b --- /dev/null +++ b/packages/mcp-provider-code-analyzer/test/utils.test.ts @@ -0,0 +1,25 @@ +import { describe, expect, it } from "vitest"; +import { escapeXml, toSafeFilenameSlug } from "../src/utils.js"; + +describe("utils", () => { + describe("escapeXml", () => { + it("escapes XML special characters", () => { + expect(escapeXml(`Tom & "Jerry" 's`)) + .toBe("Tom & "Jerry" <tag>'s</tag>"); + }); + }); + + describe("toSafeFilenameSlug", () => { + it("normalizes spaces and invalid characters", () => { + expect(toSafeFilenameSlug(" My Rule:Name ")).toBe("my-rule-name"); + }); + + it("removes path separators and collapses dashes", () => { + expect(toSafeFilenameSlug("foo/bar\\baz---qux")).toBe("foo-bar-baz-qux"); + }); + + it("falls back to a default when empty", () => { + expect(toSafeFilenameSlug(" ")).toBe("custom-rule"); + }); + }); +}); From dc9b8d06038129a5a9a56fdd05fe4544764c508a Mon Sep 17 00:00:00 2001 From: aruntyagiTutu Date: Tue, 17 Feb 2026 15:20:31 +0530 Subject: [PATCH 18/25] test cases for create rule tools and services code (#393) --- .../test/ast/extract-ast-nodes.test.ts | 44 ++++ .../test/ast/pmd-cli-adapter.test.ts | 21 ++ .../test/engines/engine-strategies.test.ts | 106 +++++++++ .../test/tools/create_custom_rule.test.ts | 205 ++++++++++++++++++ .../test/tools/generate_xpath_prompt.test.ts | 147 +++++++++++++ 5 files changed, 523 insertions(+) create mode 100644 packages/mcp-provider-code-analyzer/test/ast/extract-ast-nodes.test.ts create mode 100644 packages/mcp-provider-code-analyzer/test/engines/engine-strategies.test.ts create mode 100644 packages/mcp-provider-code-analyzer/test/tools/create_custom_rule.test.ts create mode 100644 packages/mcp-provider-code-analyzer/test/tools/generate_xpath_prompt.test.ts diff --git a/packages/mcp-provider-code-analyzer/test/ast/extract-ast-nodes.test.ts b/packages/mcp-provider-code-analyzer/test/ast/extract-ast-nodes.test.ts new file mode 100644 index 00000000..dd4a81bd --- /dev/null +++ b/packages/mcp-provider-code-analyzer/test/ast/extract-ast-nodes.test.ts @@ -0,0 +1,44 @@ +import { describe, expect, it, afterEach } from "vitest"; +import fs from "node:fs/promises"; +import os from "node:os"; +import path from "node:path"; +import { extractAstNodes } from "../../src/ast/extract-ast-nodes.js"; + +let tempDir: string | undefined; + +async function createTempDir(): Promise { + return fs.mkdtemp(path.join(os.tmpdir(), "mcp-ast-")); +} + +async function cleanupTempDir(): Promise { + if (!tempDir) { + return; + } + await fs.rm(tempDir, { recursive: true, force: true }); + tempDir = undefined; +} + +describe("extractAstNodes", () => { + afterEach(async () => { + await cleanupTempDir(); + }); + + it("reads XML from a file path and parses nodes", async () => { + tempDir = await createTempDir(); + const xmlPath = path.join(tempDir, "ast.xml"); + const xml = [ + "", + " ", + " ", + " ", + "" + ].join("\n"); + + await fs.writeFile(xmlPath, xml, "utf8"); + + const nodes = extractAstNodes(xmlPath); + + expect(nodes.length).toBeGreaterThan(0); + expect(nodes[0].nodeName).toBe("CompilationUnit"); + }); +}); diff --git a/packages/mcp-provider-code-analyzer/test/ast/pmd-cli-adapter.test.ts b/packages/mcp-provider-code-analyzer/test/ast/pmd-cli-adapter.test.ts index ace56d36..137fd1b3 100644 --- a/packages/mcp-provider-code-analyzer/test/ast/pmd-cli-adapter.test.ts +++ b/packages/mcp-provider-code-analyzer/test/ast/pmd-cli-adapter.test.ts @@ -99,4 +99,25 @@ describe("PmdCliAstXmlAdapter", () => { await expect(adapter.generateAstXml("class X {}", "apex")) .rejects.toThrow("Failed to generate AST XML via PMD: bad stuff"); }); + + it("falls back to .txt when language is empty", async () => { + mkdtempMock.mockResolvedValueOnce("/tmp/pmd-ast-123"); + writeFileMock.mockResolvedValueOnce(undefined); + rmMock.mockResolvedValueOnce(undefined); + execFileMock.mockImplementationOnce((_cmd, _args, _options, cb) => cb(null, "", "")); + + const adapter = new PmdCliAstXmlAdapter(); + await adapter.generateAstXml("class X {}", ""); + + const execArgs = execFileMock.mock.calls[0]; + expect(execArgs[1]).toEqual([ + "ast-dump", + "--language", + "", + "--format", + "xml", + "--file", + path.join("/tmp/pmd-ast-123", "source.txt") + ]); + }); }); diff --git a/packages/mcp-provider-code-analyzer/test/engines/engine-strategies.test.ts b/packages/mcp-provider-code-analyzer/test/engines/engine-strategies.test.ts new file mode 100644 index 00000000..be4656cc --- /dev/null +++ b/packages/mcp-provider-code-analyzer/test/engines/engine-strategies.test.ts @@ -0,0 +1,106 @@ +import { describe, expect, it, vi } from "vitest"; + +const generateAstXmlFromSourceMock = vi.fn(); +const getApexAstNodeMetadataByNamesMock = vi.fn(); + +vi.mock("../../src/ast/generate-ast-xml.js", () => ({ + generateAstXmlFromSource: generateAstXmlFromSourceMock +})); + +vi.mock("../../src/ast/metadata/apex-ast-reference.js", () => ({ + getApexAstNodeMetadataByNames: getApexAstNodeMetadataByNamesMock +})); + +describe("engine strategies", () => { + it("returns PMD strategy and generates AST XML via generator", async () => { + generateAstXmlFromSourceMock.mockResolvedValueOnce(""); + const { getEngineStrategy } = await import("../../src/engines/engine-strategies.js"); + const strategy = getEngineStrategy("pmd"); + + const result = await strategy.astGenerator.generateAstXml("class X {}", "apex"); + + expect(result).toBe(""); + expect(generateAstXmlFromSourceMock).toHaveBeenCalledWith("class X {}", "apex"); + }); + + it("returns metadata only for Apex language", async () => { + getApexAstNodeMetadataByNamesMock.mockResolvedValueOnce([{ name: "UserClass" }]); + const { getEngineStrategy } = await import("../../src/engines/engine-strategies.js"); + const strategy = getEngineStrategy("pmd"); + + const apexMeta = await strategy.metadataProvider.getMetadata("apex", ["UserClass"]); + const jsMeta = await strategy.metadataProvider.getMetadata("javascript", ["Foo"]); + + expect(apexMeta).toEqual([{ name: "UserClass" }]); + expect(jsMeta).toEqual([]); + }); + + it("treats missing language as non-Apex for metadata", async () => { + const { getEngineStrategy } = await import("../../src/engines/engine-strategies.js"); + const strategy = getEngineStrategy("pmd"); + + const meta = await strategy.metadataProvider.getMetadata(undefined as unknown as string, ["UserClass"]); + + expect(meta).toEqual([]); + }); + + it("builds a PMD prompt with AST node summaries", async () => { + const { getEngineStrategy } = await import("../../src/engines/engine-strategies.js"); + const strategy = getEngineStrategy("pmd"); + + const prompt = strategy.promptBuilder.buildPrompt({ + language: "apex", + engine: "pmd", + astNodes: [ + { + nodeName: "MethodCallExpression", + attributes: { FullMethodName: "System.debug" }, + parent: "BlockStatement", + ancestors: ["CompilationUnit", "UserClass"] + } + ], + astMetadata: [ + { + name: "MethodCallExpression", + description: "Represents a method call" + } + ] + }); + + expect(prompt).toContain("You are generating a PMD XPath query."); + expect(prompt).toContain("MethodCallExpression"); + expect(prompt).toContain("System.debug"); + expect(prompt).toContain("Create the XPath"); + }); + + it("includes null parent when AST node has no parent", async () => { + const { getEngineStrategy } = await import("../../src/engines/engine-strategies.js"); + const strategy = getEngineStrategy("pmd"); + + const prompt = strategy.promptBuilder.buildPrompt({ + language: "apex", + engine: "pmd", + astNodes: [ + { + nodeName: "CompilationUnit", + attributes: {}, + parent: undefined, + ancestors: [] + } + ], + astMetadata: [] + }); + + expect(prompt).toContain('"parent": null'); + }); + + it("throws for unsupported engines", async () => { + const { getEngineStrategy } = await import("../../src/engines/engine-strategies.js"); + expect(() => getEngineStrategy("eslint")).toThrow("engine 'eslint' is not supported yet"); + }); + + it("throws for empty engine value", async () => { + const { getEngineStrategy } = await import("../../src/engines/engine-strategies.js"); + expect(() => getEngineStrategy("")).toThrow("engine '' is not supported yet"); + }); +}); 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 new file mode 100644 index 00000000..6a1332fa --- /dev/null +++ b/packages/mcp-provider-code-analyzer/test/tools/create_custom_rule.test.ts @@ -0,0 +1,205 @@ +import { describe, expect, it, vi } from "vitest"; +import { CreateCustomRuleMcpTool } from "../../src/tools/create_custom_rule.js"; + +const actionExecMock = vi.fn(); +const telemetrySendMock = vi.fn(); + +function buildTool() { + return new CreateCustomRuleMcpTool( + { + exec: actionExecMock + }, + { + sendEvent: telemetrySendMock + } + ); +} + +describe("CreateCustomRuleMcpTool", () => { + it("exposes the expected name and config", () => { + const tool = buildTool(); + const config = tool.getConfig(); + + expect(tool.getName()).toBe("create_custom_rule"); + expect(tool.getReleaseState()).toBe("non-ga"); + expect(tool.getToolsets()).toEqual(["code-analysis"]); + expect(config.title).toBe("Create Custom Rule"); + expect(config.description).toContain("Purpose: Create a custom rule"); + }); + + it("returns validation error when ruleName is missing", async () => { + const tool = buildTool(); + const result = await tool.exec({ + xpath: "//MethodCallExpression", + ruleName: "", + description: "desc", + language: "apex", + engine: "pmd", + priority: 3, + workingDirectory: "/tmp" + }); + + expect(result.structuredContent?.status).toContain("ruleName is required"); + expect(actionExecMock).not.toHaveBeenCalled(); + }); + + it("returns validation error when description is missing", async () => { + const tool = buildTool(); + const result = await tool.exec({ + xpath: "//MethodCallExpression", + ruleName: "Rule", + description: " ", + language: "apex", + engine: "pmd", + priority: 3, + workingDirectory: "/tmp" + }); + + expect(result.structuredContent?.status).toContain("description is required"); + expect(actionExecMock).not.toHaveBeenCalled(); + }); + + it("returns validation error when language is missing", async () => { + const tool = buildTool(); + const result = await tool.exec({ + xpath: "//MethodCallExpression", + ruleName: "Rule", + description: "desc", + language: " ", + engine: "pmd", + priority: 3, + workingDirectory: "/tmp" + }); + + expect(result.structuredContent?.status).toContain("language is required"); + expect(actionExecMock).not.toHaveBeenCalled(); + }); + + it("returns validation error when engine is missing", async () => { + const tool = buildTool(); + const result = await tool.exec({ + xpath: "//MethodCallExpression", + ruleName: "Rule", + description: "desc", + language: "apex", + engine: " ", + priority: 3, + workingDirectory: "/tmp" + }); + + expect(result.structuredContent?.status).toContain("engine is required"); + expect(actionExecMock).not.toHaveBeenCalled(); + }); + + it("returns validation error when xpath is missing for PMD", async () => { + const tool = buildTool(); + const result = await tool.exec({ + xpath: " ", + ruleName: "Rule", + description: "desc", + language: "apex", + engine: "pmd", + priority: 3, + workingDirectory: "/tmp" + }); + + expect(result.structuredContent?.status).toContain("xpath is required for engine 'pmd'"); + expect(actionExecMock).not.toHaveBeenCalled(); + }); + + it("allows missing xpath for non-PMD engines", async () => { + actionExecMock.mockResolvedValueOnce({ + status: "success", + rulesetPath: "/tmp/custom.xml", + configPath: "/tmp/code-analyzer.yml" + }); + + const tool = buildTool(); + await tool.exec({ + xpath: " ", + ruleName: "Rule", + description: "desc", + language: "apex", + engine: "eslint", + priority: 3, + workingDirectory: "/tmp" + }); + + expect(actionExecMock).toHaveBeenCalledTimes(1); + }); + + it("returns validation error when priority is missing", async () => { + const tool = buildTool(); + const result = await tool.exec({ + xpath: "//MethodCallExpression", + ruleName: "Rule", + description: "desc", + language: "apex", + engine: "pmd", + priority: undefined, + workingDirectory: "/tmp" + } as unknown as Parameters[0]); + + expect(result.structuredContent?.status).toContain("priority is required"); + expect(actionExecMock).not.toHaveBeenCalled(); + }); + + it("returns validation error when workingDirectory is missing", async () => { + const tool = buildTool(); + const result = await tool.exec({ + xpath: "//MethodCallExpression", + ruleName: "Rule", + description: "desc", + language: "apex", + engine: "pmd", + priority: 3, + workingDirectory: " " + }); + + expect(result.structuredContent?.status).toContain("workingDirectory is required"); + expect(actionExecMock).not.toHaveBeenCalled(); + }); + + it("delegates to action and emits telemetry on success", async () => { + actionExecMock.mockResolvedValueOnce({ + status: "success", + rulesetPath: "/tmp/custom.xml", + configPath: "/tmp/code-analyzer.yml" + }); + + const tool = buildTool(); + const result = await tool.exec({ + xpath: "//MethodCallExpression", + ruleName: "Rule", + description: "desc", + language: "apex", + engine: "pmd", + priority: 3, + workingDirectory: "/tmp" + }); + + expect(actionExecMock).toHaveBeenCalledTimes(1); + expect(result.content?.[0]?.text).toContain("Custom rule created"); + expect(telemetrySendMock).toHaveBeenCalledTimes(1); + }); + + it("does not emit telemetry on failure", async () => { + actionExecMock.mockResolvedValueOnce({ + status: "something failed" + }); + + const tool = buildTool(); + const result = await tool.exec({ + xpath: "//MethodCallExpression", + ruleName: "Rule", + description: "desc", + language: "apex", + engine: "pmd", + priority: 3, + workingDirectory: "/tmp" + }); + + expect(result.content?.[0]?.text).toBe("something failed"); + expect(telemetrySendMock).not.toHaveBeenCalled(); + }); +}); diff --git a/packages/mcp-provider-code-analyzer/test/tools/generate_xpath_prompt.test.ts b/packages/mcp-provider-code-analyzer/test/tools/generate_xpath_prompt.test.ts new file mode 100644 index 00000000..47ab39dd --- /dev/null +++ b/packages/mcp-provider-code-analyzer/test/tools/generate_xpath_prompt.test.ts @@ -0,0 +1,147 @@ +import { describe, expect, it, vi, beforeEach } from "vitest"; + +const actionExecMock = vi.fn(); +const telemetrySendMock = vi.fn(); +const buildPromptMock = vi.fn(); + +vi.mock("../../src/engines/engine-strategies.js", () => ({ + getEngineStrategy: () => ({ + promptBuilder: { + buildPrompt: buildPromptMock + } + }) +})); + +beforeEach(() => { + vi.clearAllMocks(); + vi.resetModules(); +}); + +async function buildTool(withTelemetry: boolean = true) { + const { GenerateXpathPromptMcpTool } = await import("../../src/tools/generate_xpath_prompt.js"); + return new GenerateXpathPromptMcpTool( + { + exec: actionExecMock + }, + withTelemetry + ? { sendEvent: telemetrySendMock } + : undefined + ); +} + +describe("GenerateXpathPromptMcpTool", () => { + it("returns validation error when language is missing", async () => { + const tool = await buildTool(); + const result = await tool.exec({ + sampleCode: "class X {}", + language: " ", + engine: "pmd" + }); + + expect(result.structuredContent?.status).toBe("language is required"); + expect(actionExecMock).not.toHaveBeenCalled(); + }); + + it("returns validation error when engine is missing", async () => { + const tool = await buildTool(); + const result = await tool.exec({ + sampleCode: "class X {}", + language: "apex", + engine: " " + }); + + expect(result.structuredContent?.status).toBe("engine is required"); + expect(actionExecMock).not.toHaveBeenCalled(); + }); + + it("returns validation error for unsupported engine", async () => { + const tool = await buildTool(); + const result = await tool.exec({ + sampleCode: "class X {}", + language: "apex", + engine: "eslint" + }); + + expect(result.structuredContent?.status).toBe("engine 'eslint' is not supported yet"); + expect(actionExecMock).not.toHaveBeenCalled(); + }); + + it("returns validation error when sample code is missing", async () => { + const tool = await buildTool(); + const result = await tool.exec({ + sampleCode: " ", + language: "apex", + engine: "pmd" + }); + + expect(result.structuredContent?.status).toBe("code in apex is required"); + expect(actionExecMock).not.toHaveBeenCalled(); + }); + + it("returns error when AST action fails", async () => { + actionExecMock.mockResolvedValueOnce({ + status: "failure", + nodes: [], + metadata: [] + }); + + const tool = await buildTool(); + const result = await tool.exec({ + sampleCode: "class X {}", + language: "apex", + engine: "pmd" + }); + + expect(result.structuredContent?.status).toBe("failure"); + expect(result.structuredContent?.prompt).toBe(""); + }); + + it("builds a prompt and emits telemetry on success", async () => { + actionExecMock.mockResolvedValueOnce({ + status: "success", + nodes: [{ nodeName: "CompilationUnit", attributes: {}, ancestors: [] }], + metadata: [] + }); + buildPromptMock.mockReturnValueOnce("PROMPT"); + + const tool = await buildTool(); + const result = await tool.exec({ + sampleCode: "class X {}", + language: "apex", + engine: "pmd" + }); + + expect(buildPromptMock).toHaveBeenCalledTimes(1); + expect(result.structuredContent?.prompt).toBe("PROMPT"); + expect(telemetrySendMock).toHaveBeenCalledTimes(1); + }); + + it("does not emit telemetry when telemetry service is not provided", async () => { + actionExecMock.mockResolvedValueOnce({ + status: "success", + nodes: [], + metadata: [] + }); + buildPromptMock.mockReturnValueOnce("PROMPT"); + + const tool = await buildTool(false); + await tool.exec({ + sampleCode: "class X {}", + language: "apex", + engine: "pmd" + }); + + expect(telemetrySendMock).not.toHaveBeenCalled(); + }); + + it("exposes name, toolsets, release state, and config", async () => { + const tool = await buildTool(); + const config = tool.getConfig(); + + expect(tool.getName()).toBe("get_ast_nodes_to_generate_xpath"); + expect(tool.getReleaseState()).toBe("non-ga"); + expect(tool.getToolsets()).toEqual(["code-analysis"]); + expect(config.title).toBe("Generate XPath Prompt"); + expect(config.description).toContain("First step for creating a PMD XPath-based custom rule"); + }); +}); From 29d63e62e853acb4a998f2097f1020b5ecf31d82 Mon Sep 17 00:00:00 2001 From: aruntyagiTutu Date: Tue, 17 Feb 2026 16:19:03 +0530 Subject: [PATCH 19/25] file ops validations (#394) --- .../src/actions/create-xpath-custom-rule.ts | 3 +++ .../src/ast/pmd-cli-adapter.ts | 10 ++++++++++ packages/mcp-provider-code-analyzer/src/utils.ts | 1 + .../test/ast/pmd-cli-adapter.test.ts | 8 ++++++++ packages/mcp-provider-code-analyzer/test/utils.test.ts | 5 +++++ 5 files changed, 27 insertions(+) diff --git a/packages/mcp-provider-code-analyzer/src/actions/create-xpath-custom-rule.ts b/packages/mcp-provider-code-analyzer/src/actions/create-xpath-custom-rule.ts index 87d7e0bf..b99b0b79 100644 --- a/packages/mcp-provider-code-analyzer/src/actions/create-xpath-custom-rule.ts +++ b/packages/mcp-provider-code-analyzer/src/actions/create-xpath-custom-rule.ts @@ -116,6 +116,9 @@ function buildPaths(input: NormalizedInput): { customRulesDir: string; rulesetPa function toRelativeRulesetPath(workingDirectory: string, rulesetPath: string): string { const relativePath = path.relative(workingDirectory, rulesetPath); + if (path.isAbsolute(relativePath) || relativePath.startsWith("..")) { + throw new Error("Ruleset path must remain within the workingDirectory."); + } return relativePath.split(path.sep).join("/"); } diff --git a/packages/mcp-provider-code-analyzer/src/ast/pmd-cli-adapter.ts b/packages/mcp-provider-code-analyzer/src/ast/pmd-cli-adapter.ts index e07f29fb..5f083995 100644 --- a/packages/mcp-provider-code-analyzer/src/ast/pmd-cli-adapter.ts +++ b/packages/mcp-provider-code-analyzer/src/ast/pmd-cli-adapter.ts @@ -14,6 +14,7 @@ export interface AstXmlAdapter { export class PmdCliAstXmlAdapter implements AstXmlAdapter { public async generateAstXml(code: string, language: string): Promise { + enforceMaxSourceSize(code); const tempDir = await fs.mkdtemp(path.join(os.tmpdir(), "pmd-ast-")); const sourceFile = path.join(tempDir, `source.${sanitizeExtension(language)}`); @@ -33,6 +34,15 @@ export class PmdCliAstXmlAdapter implements AstXmlAdapter { } } +const MAX_SOURCE_BYTES = 1_000_000; + +function enforceMaxSourceSize(code: string): void { + const size = Buffer.byteLength(code ?? "", "utf8"); + if (size > MAX_SOURCE_BYTES) { + throw new Error(`Source exceeds ${MAX_SOURCE_BYTES} bytes. Provide a smaller snippet.`); + } +} + function sanitizeExtension(language: string): string { const cleaned = (language ?? "").toLowerCase().replace(/[^a-z0-9]/g, ""); return cleaned.length > 0 ? cleaned : "txt"; diff --git a/packages/mcp-provider-code-analyzer/src/utils.ts b/packages/mcp-provider-code-analyzer/src/utils.ts index 17c645d1..86047717 100644 --- a/packages/mcp-provider-code-analyzer/src/utils.ts +++ b/packages/mcp-provider-code-analyzer/src/utils.ts @@ -27,6 +27,7 @@ export function toSafeFilenameSlug(value: string): string { return value .trim() .replace(/[\\/:"*?<>|]+/g, "-") + .replace(/\.+/g, "-") .replace(/\s+/g, "-") .replace(/-+/g, "-") .replace(/^-+|-+$/g, "") diff --git a/packages/mcp-provider-code-analyzer/test/ast/pmd-cli-adapter.test.ts b/packages/mcp-provider-code-analyzer/test/ast/pmd-cli-adapter.test.ts index 137fd1b3..db85825d 100644 --- a/packages/mcp-provider-code-analyzer/test/ast/pmd-cli-adapter.test.ts +++ b/packages/mcp-provider-code-analyzer/test/ast/pmd-cli-adapter.test.ts @@ -120,4 +120,12 @@ describe("PmdCliAstXmlAdapter", () => { path.join("/tmp/pmd-ast-123", "source.txt") ]); }); + + it("rejects source larger than the limit", async () => { + const adapter = new PmdCliAstXmlAdapter(); + const largeSource = "a".repeat(1_000_001); + + await expect(adapter.generateAstXml(largeSource, "apex")) + .rejects.toThrow("Source exceeds 1000000 bytes"); + }); }); diff --git a/packages/mcp-provider-code-analyzer/test/utils.test.ts b/packages/mcp-provider-code-analyzer/test/utils.test.ts index ef36c91b..05457796 100644 --- a/packages/mcp-provider-code-analyzer/test/utils.test.ts +++ b/packages/mcp-provider-code-analyzer/test/utils.test.ts @@ -18,6 +18,11 @@ describe("utils", () => { expect(toSafeFilenameSlug("foo/bar\\baz---qux")).toBe("foo-bar-baz-qux"); }); + it("strips dots to prevent path traversal", () => { + expect(toSafeFilenameSlug("../etc/passwd")).toBe("etc-passwd"); + expect(toSafeFilenameSlug("..")).toBe("custom-rule"); + }); + it("falls back to a default when empty", () => { expect(toSafeFilenameSlug(" ")).toBe("custom-rule"); }); From 2934af6fcf7705128e45658e9de5e5ddd9dae8ac Mon Sep 17 00:00:00 2001 From: Arun Tyagi Date: Wed, 18 Feb 2026 11:01:50 +0530 Subject: [PATCH 20/25] add clean-all scripts for missing packages --- packages/mcp-provider-dx-core/package.json | 1 + packages/mcp/package.json | 1 + 2 files changed, 2 insertions(+) diff --git a/packages/mcp-provider-dx-core/package.json b/packages/mcp-provider-dx-core/package.json index 9b4563bb..5e596146 100644 --- a/packages/mcp-provider-dx-core/package.json +++ b/packages/mcp-provider-dx-core/package.json @@ -9,6 +9,7 @@ "build": "wireit", "build:watch": "yarn build --watch", "clean": "tsc --build --clean", + "clean-all": "yarn clean && rimraf node_modules", "format": "wireit", "lint": "wireit", "test": "wireit", diff --git a/packages/mcp/package.json b/packages/mcp/package.json index 945275cc..528da80b 100644 --- a/packages/mcp/package.json +++ b/packages/mcp/package.json @@ -12,6 +12,7 @@ "build": "wireit", "build:watch": "yarn build --watch", "clean": "tsc --build --clean", + "clean-all": "yarn clean && rimraf node_modules", "fix-license": "eslint src test --fix --rule \"header/header: [2]\"", "format": "wireit", "link-check": "wireit", From 1ee9b0062c8076732efb8ecb3b345d974d2e1194 Mon Sep 17 00:00:00 2001 From: Arun Tyagi Date: Wed, 25 Feb 2026 09:15:18 +0530 Subject: [PATCH 21/25] remove unused field --- packages/mcp-provider-code-analyzer/Untitled | 1 + .../src/tools/create_custom_rule.ts | 5 +---- 2 files changed, 2 insertions(+), 4 deletions(-) create mode 100644 packages/mcp-provider-code-analyzer/Untitled diff --git a/packages/mcp-provider-code-analyzer/Untitled b/packages/mcp-provider-code-analyzer/Untitled new file mode 100644 index 00000000..25ac577c --- /dev/null +++ b/packages/mcp-provider-code-analyzer/Untitled @@ -0,0 +1 @@ +mcp-provider-code-analyzer \ No newline at end of file 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 ec817e38..f8d570f4 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 @@ -81,10 +81,7 @@ export class CreateCustomRuleMcpTool extends McpTool Date: Thu, 5 Mar 2026 11:09:22 +0530 Subject: [PATCH 22/25] remove empty file --- packages/mcp-provider-code-analyzer/Untitled | 1 - 1 file changed, 1 deletion(-) delete mode 100644 packages/mcp-provider-code-analyzer/Untitled diff --git a/packages/mcp-provider-code-analyzer/Untitled b/packages/mcp-provider-code-analyzer/Untitled deleted file mode 100644 index 25ac577c..00000000 --- a/packages/mcp-provider-code-analyzer/Untitled +++ /dev/null @@ -1 +0,0 @@ -mcp-provider-code-analyzer \ No newline at end of file From 739a663553f0b6d21b218763974f633be9b3e045 Mon Sep 17 00:00:00 2001 From: aruntyagiTutu Date: Thu, 12 Mar 2026 14:48:18 +0530 Subject: [PATCH 23/25] @W-21364768 - PMS ast dump via code analyzer core api (#407) * replace pmd dump cli coomand with engine api * update code analyzer packages versions * fix tests * e2e update * fix tests --- .../mcp-provider-code-analyzer/package.json | 12 +- .../src/ast/generate-ast-xml.ts | 37 ++++- .../src/ast/pmd-cli-adapter.ts | 60 -------- .../src/ast/pmd-engine-adapter.ts | 98 +++++++++++++ .../test/actions/run-analyzer.test.ts | 6 +- .../test/ast/generate-ast-xml.test.ts | 13 +- .../test/ast/pmd-cli-adapter.test.ts | 131 ------------------ .../test/e2e/run_code_analyzer-e2e.test.ts | 2 +- ...iolations-in-ApexTarget2-cls.goldfile.json | 26 +++- .../test/e2e/dynamic-tools.test.ts | 16 ++- .../test/e2e/tool-registration.test.ts | 13 +- 11 files changed, 194 insertions(+), 220 deletions(-) delete mode 100644 packages/mcp-provider-code-analyzer/src/ast/pmd-cli-adapter.ts create mode 100644 packages/mcp-provider-code-analyzer/src/ast/pmd-engine-adapter.ts delete mode 100644 packages/mcp-provider-code-analyzer/test/ast/pmd-cli-adapter.test.ts diff --git a/packages/mcp-provider-code-analyzer/package.json b/packages/mcp-provider-code-analyzer/package.json index e51e84a2..fb343aea 100644 --- a/packages/mcp-provider-code-analyzer/package.json +++ b/packages/mcp-provider-code-analyzer/package.json @@ -14,12 +14,12 @@ "types": "dist/index.d.ts", "dependencies": { "@modelcontextprotocol/sdk": "^1.18.0", - "@salesforce/code-analyzer-core": "^0.40.0", - "@salesforce/code-analyzer-engine-api": "^0.32.0", - "@salesforce/code-analyzer-eslint-engine": "^0.37.0", - "@salesforce/code-analyzer-pmd-engine": "^0.33.0", - "@salesforce/code-analyzer-regex-engine": "^0.30.0", - "@salesforce/code-analyzer-retirejs-engine": "^0.29.0", + "@salesforce/code-analyzer-core": "^0.43.0", + "@salesforce/code-analyzer-engine-api": "^0.35.0", + "@salesforce/code-analyzer-eslint-engine": "^0.40.2", + "@salesforce/code-analyzer-pmd-engine": "^0.37.0", + "@salesforce/code-analyzer-regex-engine": "^0.33.0", + "@salesforce/code-analyzer-retirejs-engine": "^0.32.0", "@salesforce/mcp-provider-api": "^0.4.1", "zod": "^3.25.76" }, diff --git a/packages/mcp-provider-code-analyzer/src/ast/generate-ast-xml.ts b/packages/mcp-provider-code-analyzer/src/ast/generate-ast-xml.ts index 252ebb70..2a1a50b6 100644 --- a/packages/mcp-provider-code-analyzer/src/ast/generate-ast-xml.ts +++ b/packages/mcp-provider-code-analyzer/src/ast/generate-ast-xml.ts @@ -1,13 +1,40 @@ -import { PmdCliAstXmlAdapter } from "./pmd-cli-adapter.js"; +import { PmdEngineAstXmlAdapter } from "./pmd-engine-adapter.js"; +import { PmdEngine } from "@salesforce/code-analyzer-pmd-engine"; + +// Cache the engine instance to avoid recreating it for each request (better performance) +let cachedPmdEngine: PmdEngine | null = null; +let cachedAdapter: PmdEngineAstXmlAdapter | null = null; /** - * Generates AST XML for the given source code using the PMD CLI. - * Assumes the PMD bin folder path is provided and will be used as the cwd. + * Generates AST XML for the given source code using PMD Engine API. + * The engine instance is cached for better performance on subsequent calls. + * No longer requires PMD CLI to be installed. */ export async function generateAstXmlFromSource( code: string, language: string ): Promise { - const adapter = new PmdCliAstXmlAdapter(); - return adapter.generateAstXml(code, language); + if (!cachedPmdEngine) { + // Create PMD engine with minimal configuration needed for AST generation + // We only need java_command and empty arrays for the rest since AST generation + // doesn't require rules or custom configuration + const config = { + java_command: "java", + java_classpath_entries: [], + custom_rulesets: [], + rule_languages: ["apex", "visualforce", "xml", "html", "javascript"], + file_extensions: { + apex: [".cls", ".trigger"], + visualforce: [".page", ".component"], + xml: [".xml"], + html: [".html"], + javascript: [".js"] + } + }; + + cachedPmdEngine = new PmdEngine(config as any); + cachedAdapter = new PmdEngineAstXmlAdapter(cachedPmdEngine); + } + + return cachedAdapter!.generateAstXml(code, language); } diff --git a/packages/mcp-provider-code-analyzer/src/ast/pmd-cli-adapter.ts b/packages/mcp-provider-code-analyzer/src/ast/pmd-cli-adapter.ts deleted file mode 100644 index 5f083995..00000000 --- a/packages/mcp-provider-code-analyzer/src/ast/pmd-cli-adapter.ts +++ /dev/null @@ -1,60 +0,0 @@ -import os from "node:os"; -import path from "node:path"; -import fs from "node:fs/promises"; -import { execFile } from "node:child_process"; -import { promisify } from "node:util"; -import { getErrorMessage } from "../utils.js"; - -// Adapter that wraps the PMD CLI as an AST XML provider. -const execFileAsync = promisify(execFile); - -export interface AstXmlAdapter { - generateAstXml(code: string, language: string): Promise; -} - -export class PmdCliAstXmlAdapter implements AstXmlAdapter { - public async generateAstXml(code: string, language: string): Promise { - enforceMaxSourceSize(code); - const tempDir = await fs.mkdtemp(path.join(os.tmpdir(), "pmd-ast-")); - const sourceFile = path.join(tempDir, `source.${sanitizeExtension(language)}`); - - try { - await fs.writeFile(sourceFile, code, "utf8"); - const stdout = await runPmdAstDump(language, sourceFile); - return stdout.trim(); - } catch (error) { - const message = getErrorMessage(error); - if (message.toLowerCase().includes("enoent")) { - throw new Error("PMD CLI not found on PATH. Install PMD and ensure `pmd` is available in your PATH."); - } - throw new Error(`Failed to generate AST XML via PMD: ${message}`); - } finally { - await fs.rm(tempDir, { recursive: true, force: true }); - } - } -} - -const MAX_SOURCE_BYTES = 1_000_000; - -function enforceMaxSourceSize(code: string): void { - const size = Buffer.byteLength(code ?? "", "utf8"); - if (size > MAX_SOURCE_BYTES) { - throw new Error(`Source exceeds ${MAX_SOURCE_BYTES} bytes. Provide a smaller snippet.`); - } -} - -function sanitizeExtension(language: string): string { - const cleaned = (language ?? "").toLowerCase().replace(/[^a-z0-9]/g, ""); - return cleaned.length > 0 ? cleaned : "txt"; -} - -async function runPmdAstDump(language: string, sourceFile: string): Promise { - const { stdout } = await execFileAsync( - "pmd", - ["ast-dump", "--language", language, "--format", "xml", "--file", sourceFile], - { - maxBuffer: 10 * 1024 * 1024 - } - ); - return stdout; -} diff --git a/packages/mcp-provider-code-analyzer/src/ast/pmd-engine-adapter.ts b/packages/mcp-provider-code-analyzer/src/ast/pmd-engine-adapter.ts new file mode 100644 index 00000000..318890c0 --- /dev/null +++ b/packages/mcp-provider-code-analyzer/src/ast/pmd-engine-adapter.ts @@ -0,0 +1,98 @@ +import os from "node:os"; +import path from "node:path"; +import fs from "node:fs/promises"; +import { PmdEngine, PmdAstDumpResults } from "@salesforce/code-analyzer-pmd-engine"; +import { getErrorMessage } from "../utils.js"; + +/** + * Interface for AST XML adapters + */ +export interface AstXmlAdapter { + generateAstXml(code: string, language: string): Promise; +} + +/** + * Adapter that uses PMD Engine's generateAst API instead of PMD CLI + */ +export class PmdEngineAstXmlAdapter implements AstXmlAdapter { + private readonly pmdEngine: PmdEngine; + + constructor(pmdEngine: PmdEngine) { + this.pmdEngine = pmdEngine; + } + + public async generateAstXml(code: string, language: string): Promise { + enforceMaxSourceSize(code); + const tempDir = await fs.mkdtemp(path.join(os.tmpdir(), "pmd-ast-")); + const sourceFile = path.join(tempDir, `source.${sanitizeExtension(language)}`); + + try { + // Write source code to temp file + await fs.writeFile(sourceFile, code, "utf8"); + + // Use PMD Engine's generateAst method + const result: PmdAstDumpResults = await this.pmdEngine.generateAst( + normalizePmdLanguage(language), + sourceFile, + { + encoding: "UTF-8", + workingFolder: tempDir + } + ); + + // Handle error case + if (result.error) { + throw new Error(`PMD Engine error: ${result.error.message}`); + } + + // Return the AST XML + if (!result.ast) { + throw new Error("PMD Engine returned no AST and no error"); + } + + return result.ast.trim(); + } catch (error) { + const message = getErrorMessage(error); + throw new Error(`Failed to generate AST XML via PMD Engine: ${message}`); + } finally { + // Clean up temp directory + await fs.rm(tempDir, { recursive: true, force: true }); + } + } +} + +const MAX_SOURCE_BYTES = 1_000_000; + +function enforceMaxSourceSize(code: string): void { + const size = Buffer.byteLength(code ?? "", "utf8"); + if (size > MAX_SOURCE_BYTES) { + throw new Error(`Source exceeds ${MAX_SOURCE_BYTES} bytes. Provide a smaller snippet.`); + } +} + +function sanitizeExtension(language: string): string { + const cleaned = (language ?? "").toLowerCase().replace(/[^a-z0-9]/g, ""); + return cleaned.length > 0 ? cleaned : "txt"; +} + +/** + * Normalize language names to match PMD's expected identifiers + * PMD uses specific language IDs like "apex", "visualforce", "xml", etc. + */ +function normalizePmdLanguage(language: string): string { + const normalized = (language ?? "").toLowerCase().trim(); + + // Map common variations to PMD language IDs + const languageMap: Record = { + "apex": "apex", + "visualforce": "visualforce", + "vf": "visualforce", + "xml": "xml", + "html": "html", + "javascript": "javascript", + "js": "javascript", + "ecmascript": "javascript" + }; + + return languageMap[normalized] || normalized; +} diff --git a/packages/mcp-provider-code-analyzer/test/actions/run-analyzer.test.ts b/packages/mcp-provider-code-analyzer/test/actions/run-analyzer.test.ts index 76c1271a..74730e5d 100644 --- a/packages/mcp-provider-code-analyzer/test/actions/run-analyzer.test.ts +++ b/packages/mcp-provider-code-analyzer/test/actions/run-analyzer.test.ts @@ -27,7 +27,7 @@ const PATH_TO_SAMPLE_TARGETS: string = path.resolve(__dirname, '..', 'fixtures', const PATH_TO_COMPARISON_FILES: string = path.resolve(__dirname, '..', 'fixtures', 'comparison-files'); // TODO: FIGURE OUT A WAY TO MAKE THESE GOLD FILE TESTS MORE ROBUST AGAINST VERSION CHANGES. FOR NOW USING CONSTANT: -const PMD_VERSION: string = '7.18.0'; +const PMD_VERSION: string = '7.21.0'; describe('RunAnalyzerActionImpl', () => { it.each([ @@ -67,12 +67,12 @@ describe('RunAnalyzerActionImpl', () => { 'success' ], expectedSummary: { - total: 6, + total: 7, sev1: 0, sev2: 0, sev3: 3, sev4: 3, - sev5: 0 + sev5: 1 } }, { diff --git a/packages/mcp-provider-code-analyzer/test/ast/generate-ast-xml.test.ts b/packages/mcp-provider-code-analyzer/test/ast/generate-ast-xml.test.ts index 9bd43fa3..780ad32e 100644 --- a/packages/mcp-provider-code-analyzer/test/ast/generate-ast-xml.test.ts +++ b/packages/mcp-provider-code-analyzer/test/ast/generate-ast-xml.test.ts @@ -2,16 +2,23 @@ import { describe, expect, it, vi } from "vitest"; const generateAstXmlMock = vi.fn(); -vi.mock("../../src/ast/pmd-cli-adapter.js", () => ({ - PmdCliAstXmlAdapter: class { +vi.mock("../../src/ast/pmd-engine-adapter.js", () => ({ + PmdEngineAstXmlAdapter: class { + constructor(_pmdEngine: any) {} public generateAstXml(code: string, language: string): Promise { return generateAstXmlMock(code, language); } } })); +vi.mock("@salesforce/code-analyzer-pmd-engine", () => ({ + PmdEngine: class { + constructor(_config: any) {} + } +})); + describe("generateAstXmlFromSource", () => { - it("delegates to the PMD CLI adapter", async () => { + it("delegates to the PMD Engine adapter", async () => { generateAstXmlMock.mockResolvedValueOnce(""); const { generateAstXmlFromSource } = await import("../../src/ast/generate-ast-xml.js"); diff --git a/packages/mcp-provider-code-analyzer/test/ast/pmd-cli-adapter.test.ts b/packages/mcp-provider-code-analyzer/test/ast/pmd-cli-adapter.test.ts deleted file mode 100644 index db85825d..00000000 --- a/packages/mcp-provider-code-analyzer/test/ast/pmd-cli-adapter.test.ts +++ /dev/null @@ -1,131 +0,0 @@ -import { describe, expect, it, vi, afterEach } from "vitest"; -import path from "node:path"; -import { PmdCliAstXmlAdapter } from "../../src/ast/pmd-cli-adapter.js"; - -const mkdtempMock = vi.fn(); -const writeFileMock = vi.fn(); -const rmMock = vi.fn(); -const execFileMock = vi.fn(); - -vi.mock("node:fs/promises", () => ({ - default: { - mkdtemp: (path: string) => mkdtempMock(path), - writeFile: (path: string, content: string, encoding: string) => writeFileMock(path, content, encoding), - rm: (path: string, options: { recursive: boolean; force: boolean }) => rmMock(path, options) - } -})); - -vi.mock("node:child_process", () => { - const execFile = ( - cmd: string, - args: string[], - options: { maxBuffer: number }, - cb: (err: Error | null, stdout: string, stderr: string) => void - ) => execFileMock(cmd, args, options, cb); - - (execFile as unknown as Record)[Symbol.for("nodejs.util.promisify.custom")] = ( - cmd: string, - args: string[], - options: { maxBuffer: number } - ) => - new Promise((resolve, reject) => { - execFileMock(cmd, args, options, (err: Error | null, stdout: string, stderr: string) => { - if (err) { - reject(err); - return; - } - resolve({ stdout, stderr }); - }); - }); - - return { execFile }; -}); - - -describe("PmdCliAstXmlAdapter", () => { - afterEach(() => { - vi.clearAllMocks(); - }); - - it("generates AST XML via the PMD CLI and cleans up temp files", async () => { - mkdtempMock.mockResolvedValueOnce("/tmp/pmd-ast-123"); - writeFileMock.mockResolvedValueOnce(undefined); - rmMock.mockResolvedValueOnce(undefined); - execFileMock.mockImplementationOnce((_cmd, _args, _options, cb) => cb(null, "", "")); - - const adapter = new PmdCliAstXmlAdapter(); - - const result = await adapter.generateAstXml("class X {}", "apex"); - - expect(result).toBe(""); - expect(mkdtempMock).toHaveBeenCalledTimes(1); - expect(writeFileMock).toHaveBeenCalledTimes(1); - expect(execFileMock).toHaveBeenCalledTimes(1); - expect(rmMock).toHaveBeenCalledTimes(1); - - const execArgs = execFileMock.mock.calls[0]; - expect(execArgs[0]).toBe("pmd"); - expect(execArgs[1]).toEqual([ - "ast-dump", - "--language", - "apex", - "--format", - "xml", - "--file", - path.join("/tmp/pmd-ast-123", "source.apex") - ]); - }); - - it("throws a helpful error when PMD CLI is missing", async () => { - mkdtempMock.mockResolvedValueOnce("/tmp/pmd-ast-123"); - writeFileMock.mockResolvedValueOnce(undefined); - rmMock.mockResolvedValueOnce(undefined); - execFileMock.mockImplementationOnce((_cmd, _args, _options, cb) => cb(new Error("ENOENT: pmd not found"), "", "")); - - const adapter = new PmdCliAstXmlAdapter(); - - await expect(adapter.generateAstXml("class X {}", "apex")) - .rejects.toThrow("PMD CLI not found on PATH"); - }); - - it("throws a generic error when PMD CLI fails", async () => { - mkdtempMock.mockResolvedValueOnce("/tmp/pmd-ast-123"); - writeFileMock.mockResolvedValueOnce(undefined); - rmMock.mockResolvedValueOnce(undefined); - execFileMock.mockImplementationOnce((_cmd, _args, _options, cb) => cb(new Error("bad stuff"), "", "")); - - const adapter = new PmdCliAstXmlAdapter(); - - await expect(adapter.generateAstXml("class X {}", "apex")) - .rejects.toThrow("Failed to generate AST XML via PMD: bad stuff"); - }); - - it("falls back to .txt when language is empty", async () => { - mkdtempMock.mockResolvedValueOnce("/tmp/pmd-ast-123"); - writeFileMock.mockResolvedValueOnce(undefined); - rmMock.mockResolvedValueOnce(undefined); - execFileMock.mockImplementationOnce((_cmd, _args, _options, cb) => cb(null, "", "")); - - const adapter = new PmdCliAstXmlAdapter(); - await adapter.generateAstXml("class X {}", ""); - - const execArgs = execFileMock.mock.calls[0]; - expect(execArgs[1]).toEqual([ - "ast-dump", - "--language", - "", - "--format", - "xml", - "--file", - path.join("/tmp/pmd-ast-123", "source.txt") - ]); - }); - - it("rejects source larger than the limit", async () => { - const adapter = new PmdCliAstXmlAdapter(); - const largeSource = "a".repeat(1_000_001); - - await expect(adapter.generateAstXml(largeSource, "apex")) - .rejects.toThrow("Source exceeds 1000000 bytes"); - }); -}); diff --git a/packages/mcp-provider-code-analyzer/test/e2e/run_code_analyzer-e2e.test.ts b/packages/mcp-provider-code-analyzer/test/e2e/run_code_analyzer-e2e.test.ts index 4d599106..b4b57caa 100644 --- a/packages/mcp-provider-code-analyzer/test/e2e/run_code_analyzer-e2e.test.ts +++ b/packages/mcp-provider-code-analyzer/test/e2e/run_code_analyzer-e2e.test.ts @@ -39,7 +39,7 @@ describe('run_code_analyzer', () => { { case: 'violations are present', target: path.join(__dirname, '..', 'fixtures', 'sample-targets', 'ApexTarget2.cls'), - expectedCount: 6 + expectedCount: 7 }, { case: 'no violations are present', diff --git a/packages/mcp-provider-code-analyzer/test/fixtures/comparison-files/violations-in-ApexTarget2-cls.goldfile.json b/packages/mcp-provider-code-analyzer/test/fixtures/comparison-files/violations-in-ApexTarget2-cls.goldfile.json index 3884ff07..35d8d77b 100644 --- a/packages/mcp-provider-code-analyzer/test/fixtures/comparison-files/violations-in-ApexTarget2-cls.goldfile.json +++ b/packages/mcp-provider-code-analyzer/test/fixtures/comparison-files/violations-in-ApexTarget2-cls.goldfile.json @@ -1,12 +1,12 @@ { "runDir": "{{RUNDIR}}{{SEP}}", "violationCounts": { - "total": 6, + "total": 7, "sev1": 0, "sev2": 0, "sev3": 3, "sev4": 3, - "sev5": 0 + "sev5": 1 }, "versions": { "code-analyzer": "{{CODE_ANALYZER_VERSION}}", @@ -157,6 +157,28 @@ "resources": [ "https://docs.pmd-code.org/pmd-doc-{{PMD_VERSION}}/pmd_rules_apex_performance.html#operationwithlimitsinloop" ] + }, + { + "rule": "NoTrailingWhitespace", + "engine": "regex", + "severity": 5, + "tags": [ + "Recommended", + "CodeStyle", + "Apex" + ], + "primaryLocationIndex": 0, + "locations": [ + { + "file": "test{{SEP}}fixtures{{SEP}}sample-targets{{SEP}}ApexTarget2.cls", + "startLine": 3, + "startColumn": 1, + "endLine": 4, + "endColumn": 1 + } + ], + "message": "Found trailing whitespace at the end of a line of code.", + "resources": [] } ] } \ No newline at end of file diff --git a/packages/mcp-provider-dx-core/test/e2e/dynamic-tools.test.ts b/packages/mcp-provider-dx-core/test/e2e/dynamic-tools.test.ts index d5c542dc..7a5e6c2d 100644 --- a/packages/mcp-provider-dx-core/test/e2e/dynamic-tools.test.ts +++ b/packages/mcp-provider-dx-core/test/e2e/dynamic-tools.test.ts @@ -19,13 +19,16 @@ import { Client } from '@modelcontextprotocol/sdk/client/index.js'; import { DxMcpTransport } from '@salesforce/mcp-test-client'; import { CallToolResult } from '@modelcontextprotocol/sdk/types.js'; -describe('sf-dynamic-tools', () => { +describe('sf-dynamic-tools', function() { + this.timeout(60000); // Set 60 second timeout for all tests in this suite + const client = new Client({ name: 'sf-dynamic-tools', version: '0.0.1', }); - before(async () => { + before(async function() { + this.timeout(60000); // Set 60 second timeout for before hook const transport = DxMcpTransport({ args: ['--orgs', 'ALLOW_ALL_ORGS', '--dynamic-tools', '--no-telemetry'], }); @@ -33,11 +36,13 @@ describe('sf-dynamic-tools', () => { await client.connect(transport); }); - after(async () => { + after(async function() { + this.timeout(60000); // Set 60 second timeout for after hook await client.close(); }); - it('should enable 2 tools', async () => { + it('should enable 2 tools', async function() { + this.timeout(60000); // Set 60 second timeout for this test const initialTools = (await client.listTools()).tools.map((t) => t.name).sort(); expect(initialTools.length).to.equal(4); @@ -70,7 +75,8 @@ describe('sf-dynamic-tools', () => { ); }); - it('should list available tools to be enabled', async () => { + it('should list available tools to be enabled', async function() { + this.timeout(60000); // Set 60 second timeout for this test const result = (await client.callTool({ name: 'list_tools', })) as CallToolResult; 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 85f82800..338d44ac 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 @@ -33,8 +33,11 @@ async function getMcpClient(opts: { args: string[] }) { return client; } -describe('specific tool registration', () => { - it('should enable 2 tools', async () => { +describe('specific tool registration', function() { + this.timeout(60000); // Set 60 second timeout for all tests in this suite + + it('should enable 2 tools', async function() { + this.timeout(60000); // Set 60 second timeout for this test const client = await getMcpClient({ args: ['--orgs', 'ALLOW_ALL_ORGS', '--tools', 'run_soql_query, deploy_metadata', '--no-telemetry'], }); @@ -54,7 +57,8 @@ describe('specific tool registration', () => { } }); - it('should not enable NON_GA tools if --allow-non-ga-tools is not specified', async () => { + it('should not enable NON_GA tools if --allow-non-ga-tools is not specified', async function() { + this.timeout(60000); // Set 60 second timeout for this test const client = await getMcpClient({ args: ['--orgs', 'ALLOW_ALL_ORGS', '--tools', 'run_soql_query, list_devops_center_work_items', '--no-telemetry'], }); @@ -73,7 +77,8 @@ describe('specific tool registration', () => { } }); - it('should enable 1 tool and a toolset', async () => { + it('should enable 1 tool and a toolset', async function() { + this.timeout(60000); // Set 60 second timeout for this test const client = await getMcpClient({ args: [ '--orgs', From 988095c080fb45aa7f6ea94be7a09d437a6631c9 Mon Sep 17 00:00:00 2001 From: Arun Tyagi Date: Thu, 12 Mar 2026 15:15:01 +0530 Subject: [PATCH 24/25] address points from PR review --- .../mcp-provider-code-analyzer/package.json | 1 + .../src/engines/engine-strategies.ts | 2 +- .../src/tools/create_custom_rule.ts | 10 +- .../src/tools/generate_xpath_prompt.ts | 4 +- yarn.lock | 1004 +++++++++++++++-- 5 files changed, 911 insertions(+), 110 deletions(-) diff --git a/packages/mcp-provider-code-analyzer/package.json b/packages/mcp-provider-code-analyzer/package.json index fb343aea..9e1b76db 100644 --- a/packages/mcp-provider-code-analyzer/package.json +++ b/packages/mcp-provider-code-analyzer/package.json @@ -21,6 +21,7 @@ "@salesforce/code-analyzer-regex-engine": "^0.33.0", "@salesforce/code-analyzer-retirejs-engine": "^0.32.0", "@salesforce/mcp-provider-api": "^0.4.1", + "fast-xml-parser": "^5.5.3", "zod": "^3.25.76" }, "devDependencies": { diff --git a/packages/mcp-provider-code-analyzer/src/engines/engine-strategies.ts b/packages/mcp-provider-code-analyzer/src/engines/engine-strategies.ts index c0631353..0aa5ba08 100644 --- a/packages/mcp-provider-code-analyzer/src/engines/engine-strategies.ts +++ b/packages/mcp-provider-code-analyzer/src/engines/engine-strategies.ts @@ -3,7 +3,7 @@ import { type AstNode } from "../ast/extract-ast-nodes.js"; import { getApexAstNodeMetadataByNames, type ApexAstNodeMetadata } from "../ast/metadata/apex-ast-reference.js"; import { LANGUAGE_NAMES } from "../constants.js"; -export type EngineName = "pmd"; +type EngineName = "pmd"; export type PromptInput = { language: string; 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 f8d570f4..8a639a84 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 @@ -81,7 +81,12 @@ export class CreateCustomRuleMcpTool extends McpTool Date: Thu, 12 Mar 2026 15:18:49 +0530 Subject: [PATCH 25/25] add iserror in create rule output --- .../mcp-provider-code-analyzer/src/tools/create_custom_rule.ts | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) 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 8a639a84..df33b038 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 @@ -160,6 +160,7 @@ function buildError(status: string): CallToolResult { const output = { status }; return { content: [{ type: "text", text: JSON.stringify(output) }], - structuredContent: output + structuredContent: output, + isError: true }; }