Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
@@ -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;
Expand All @@ -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<CreateCustomRuleOutput>;
export interface CreateXpathCustomRuleAction {
exec(input: CreateXpathCustomRuleInput): Promise<CreateXpathCustomRuleOutput>;
}

export class CreateCustomRuleActionImpl implements CreateCustomRuleAction {
public async exec(input: CreateCustomRuleInput): Promise<CreateCustomRuleOutput> {
export class CreateXpathCustomRuleActionImpl implements CreateXpathCustomRuleAction {
public async exec(input: CreateXpathCustomRuleInput): Promise<CreateXpathCustomRuleOutput> {
const normalized = normalizeInput(input);
if ("error" in normalized) {
return { status: normalized.error };
Expand All @@ -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 };
}
Expand All @@ -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" };
Expand Down Expand Up @@ -104,9 +104,10 @@ async function buildRuleXml(input: NormalizedInput): Promise<string> {

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")
};
}
Expand All @@ -115,13 +116,13 @@ function applyTemplate(template: string, values: Record<string, string>): string
return template.replace(/\{\{(\w+)\}\}/g, (_, key: string) => values[key] ?? "");
}

async function upsertCodeAnalyzerConfig(configPath: string, rulesetPath: string): Promise<void> {
async function upsertCodeAnalyzerConfig(configPath: string, rulesetPath: string, engine: string): Promise<void> {
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;
Expand All @@ -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");
}
Original file line number Diff line number Diff line change
@@ -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;
Expand All @@ -20,17 +21,16 @@ export interface GetAstNodesAction {
export class GetAstNodesActionImpl implements GetAstNodesAction {
public async exec(input: GetAstNodesInput): Promise<GetAstNodesOutput> {
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)));
Expand All @@ -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<string> {
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<ApexAstNodeMetadata[]> {
if (language === "apex") {
const normalized = (language ?? "").toLowerCase().trim();
if (normalized === LANGUAGE_NAMES.Apex) {
return getApexAstNodeMetadataByNames(nodeNames);
}
return [];
}

Original file line number Diff line number Diff line change
Expand Up @@ -11,6 +11,7 @@ export interface AstNode {
const parser = new XMLParser({
ignoreAttributes: false,
attributeNamePrefix: "@_",
ignoreDeclaration: true,
});

/**
Expand Down Expand Up @@ -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, []);
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -18,25 +18,27 @@ function sanitizeExtension(language: string): string {
*/
export async function generateAstXmlFromSource(
code: string,
language: string,
pmdBinPath: string
language: string
): Promise<string> {
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",
"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 });
}
Expand Down
13 changes: 12 additions & 1 deletion packages/mcp-provider-code-analyzer/src/constants.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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 = [
Expand Down Expand Up @@ -91,6 +92,16 @@ export type Language = typeof LANGUAGES[number];

export const LANGUAGE_SET: ReadonlySet<Language> = 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'
Expand Down
4 changes: 2 additions & 2 deletions packages/mcp-provider-code-analyzer/src/provider.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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 {
Expand Down Expand Up @@ -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())
]);
}
}
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
engines:
pmd:
rulesets:
custom_rulesets:
- "{{rulesetPath}}"
Loading