Skip to content
Open
Show file tree
Hide file tree
Changes from 22 commits
Commits
Show all changes
28 commits
Select commit Hold shift + click to select a range
9f4ae16
create action declaration which helps in returning ast nodes of sampl…
aruntyagiTutu Feb 4, 2026
a5d4836
@W-21085164 - method to extract ast nodes from ast xml (#369)
aruntyagiTutu Feb 4, 2026
2f41b07
orchestartion setps for xpath creation for apex code (#370)
aruntyagiTutu Feb 4, 2026
835f99e
tool declaration (#371)
aruntyagiTutu Feb 5, 2026
a955aec
@W-21085164 - mcp tool added in provider (#372)
aruntyagiTutu Feb 5, 2026
e78f946
ast nodes generation via pmd cli method (#376)
aruntyagiTutu Feb 6, 2026
1b75821
ast nodes generation via pmd cli method (#377)
aruntyagiTutu Feb 9, 2026
5718c3d
@W-21102083 - creation ast local cache (#378)
aruntyagiTutu Feb 9, 2026
0529310
@W-21102094 - implemented create custom rule tool (#380)
aruntyagiTutu Feb 10, 2026
c986c77
create custom rule implementation (#383)
aruntyagiTutu Feb 11, 2026
16a02e7
@W-21102094 - Refactor create rule tool implementation (#384)
aruntyagiTutu Feb 13, 2026
6ffbd3e
@W-21102094 - follow clean code for create rule tool and remove chunk…
aruntyagiTutu Feb 13, 2026
8830fc0
implement patterns whereever possible (#387)
aruntyagiTutu Feb 13, 2026
2b08094
path updation in code analyzer yml to be relative (#388)
aruntyagiTutu Feb 13, 2026
bbc6c5e
add comprehensive guidline in the prompt
aruntyagiTutu Feb 16, 2026
8eaf915
custom code analyzer yaml template
aruntyagiTutu Feb 16, 2026
1669e64
@W-21102094 - create rule unit tests for ast and actions functions (#…
aruntyagiTutu Feb 17, 2026
bd56528
Merge branch 'main' into arun.tyagi/feature/pmd_apex_rule_creation_tool
aruntyagiTutu Feb 17, 2026
dc9b8d0
test cases for create rule tools and services code (#393)
aruntyagiTutu Feb 17, 2026
29d63e6
file ops validations (#394)
aruntyagiTutu Feb 17, 2026
2934af6
add clean-all scripts for missing packages
aruntyagiTutu Feb 18, 2026
f687802
Merge branch 'main' into arun.tyagi/feature/pmd_apex_rule_creation_tool
aruntyagiTutu Feb 18, 2026
b80becb
Merge branch 'main' into arun.tyagi/feature/pmd_apex_rule_creation_tool
aruntyagiTutu Feb 23, 2026
1ee9b00
remove unused field
aruntyagiTutu Feb 25, 2026
54ef0a5
Merge branch 'main' into arun.tyagi/feature/pmd_apex_rule_creation_tool
aruntyagiTutu Mar 2, 2026
5bef88a
Merge branch 'main' into arun.tyagi/feature/pmd_apex_rule_creation_tool
iowillhoit Mar 5, 2026
1fc13a7
remove empty file
aruntyagiTutu Mar 5, 2026
0dc5da1
Merge branch 'main' into arun.tyagi/feature/pmd_apex_rule_creation_tool
aruntyagiTutu Mar 9, 2026
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
2 changes: 1 addition & 1 deletion packages/mcp-provider-code-analyzer/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -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 && cp -R src/templates dist/templates",
"clean": "tsc --build tsconfig.build.json --clean",
"clean-all": "yarn clean && rimraf node_modules",
"lint": "eslint **/*.ts",
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,215 @@
import path from "node:path";
import fs from "node:fs/promises";
import { escapeXml, toSafeFilenameSlug } from "../utils.js";

// Creates PMD XPath ruleset XML and updates code-analyzer.yml.

export type CreateXpathCustomRuleInput = {
xpath: string;
ruleName?: string;
description?: string;
language?: string;
engine?: string;
priority?: number;
workingDirectory?: string;
};

export type CreateXpathCustomRuleOutput = {
status: string;
ruleXml?: string;
rulesetPath?: string;
configPath?: string;
};

export interface CreateXpathCustomRuleAction {
exec(input: CreateXpathCustomRuleInput): Promise<CreateXpathCustomRuleOutput>;
}

export class CreateXpathCustomRuleActionImpl implements CreateXpathCustomRuleAction {
public async exec(input: CreateXpathCustomRuleInput): Promise<CreateXpathCustomRuleOutput> {
const normalized = normalizeInput(input);
if ("error" in normalized) {
return { status: normalized.error };
}

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, rulesetPathForConfig, normalized.engine);

return { status: "success", ruleXml, rulesetPath, configPath };
}
}

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: CreateXpathCustomRuleInput): 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<string> {
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),
language: escapeXml(input.language),
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);
const safeRuleName = toSafeFilenameSlug(input.ruleName);
return {
customRulesDir,
rulesetPath: path.join(customRulesDir, `${safeRuleName}-${input.engine}-rules.xml`),
configPath: path.join(input.workingDirectory, "code-analyzer.yml")
};
}

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("/");
}

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, engine: string): Promise<void> {
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<string | null> {
try {
return await fs.readFile(configPath, "utf8");
} catch (error) {
const code = (error as NodeJS.ErrnoException).code;
if (code === "ENOENT") {
return null;
}
throw error;
}
}

async function writeNewCodeAnalyzerConfig(
configPath: string,
rulesetPath: string,
engine: string
): Promise<void> {
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;

for (let i = 0; i < lines.length; i++) {
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;
}
}

return { enginesLineIndex, engineLineIndex, customRulesetsLineIndex };
}

function appendEngineRulesetBlock(configContent: string, rulesetPath: string, engine: string): string {
return [
configContent.trimEnd(),
"",
"engines:",
` ${engine}:`,
" custom_rulesets:",
` - "${rulesetPath}"`
].join("\n");
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,32 @@
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 = {
code: string;
language: string;
};

export type GetAstNodesOutput = {
status: string;
nodes: AstNode[];
metadata: ApexAstNodeMetadata[];
};

export interface GetAstNodesAction {
exec(input: GetAstNodesInput): Promise<GetAstNodesOutput>;
}

export class GetAstNodesActionImpl implements GetAstNodesAction {
public async exec(input: GetAstNodesInput): Promise<GetAstNodesOutput> {
try {
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: [] };
}
}
}

Original file line number Diff line number Diff line change
@@ -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<AstPipelineOutput> {
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<string>;

protected extractNodes(astXml: string): AstNode[] {
return extractAstNodesFromXml(astXml);
}

protected async enrichMetadata(
_input: AstPipelineInput,
_nodes: AstNode[]
): Promise<ApexAstNodeMetadata[]> {
return [];
}
}

export class PmdAstNodePipeline extends AstNodePipeline {
private readonly strategy = getEngineStrategy("pmd");

protected async generateAstXml(input: AstPipelineInput): Promise<string> {
return this.strategy.astGenerator.generateAstXml(input.code, input.language);
}

protected async enrichMetadata(
input: AstPipelineInput,
nodes: AstNode[]
): Promise<ApexAstNodeMetadata[]> {
const nodeNames = Array.from(new Set(nodes.map((node) => node.nodeName)));
return this.strategy.metadataProvider.getMetadata(input.language, nodeNames);
}
}
Loading