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
209 changes: 209 additions & 0 deletions packages/mcp-provider-code-analyzer/src/actions/query-results.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,209 @@
import fs from "node:fs";
import path from "node:path";
import { SEVERITY_NUMBER_TO_NAME } from "../constants.js";
import { QueryFilters } from "../entities/query.js";
import { getErrorMessage } from "../utils.js";

type JsonViolationOutput = {
rule: string;
engine: string;
severity: number;
tags: string[];
primaryLocationIndex: number;
locations: Array<{
file?: string;
startLine?: number;
startColumn?: number;
endLine?: number;
endColumn?: number;
comment?: string;
}>;
message: string;
resources: string[];
};

type JsonResultsOutput = {
runDir: string;
violationCounts: {
total: number;
sev1: number;
sev2: number;
sev3: number;
sev4: number;
sev5: number;
};
versions: Record<string, string>;
violations: JsonViolationOutput[];
};

export type QueryResultsInput = {
resultsFile: string;
filters: QueryFilters;
topN?: number;
Comment thread
aruntyagiTutu marked this conversation as resolved.
sortBy?: 'severity'|'rule'|'engine'|'file'|'none';
sortDirection?: 'asc'|'desc';
};

export type QueryResultsOutput = {
status: string;
resultsFile?: string;
totalViolations?: number;
totalMatches?: number;
violations?: Array<{
rule: string;
engine: string;
severity: number;
severityName: string;
tags: string[];
message: string;
primaryLocation: {
file?: string;
startLine?: number;
startColumn?: number;
};
Comment thread
aruntyagiTutu marked this conversation as resolved.
resources?: string[];
}>;
};

export interface QueryResultsAction {
exec(input: QueryResultsInput): Promise<QueryResultsOutput>;
}

export class QueryResultsActionImpl implements QueryResultsAction {
public async exec(input: QueryResultsInput): Promise<QueryResultsOutput> {
try {
const absResultsFile = path.resolve(input.resultsFile);
const data: JsonResultsOutput = JSON.parse(fs.readFileSync(absResultsFile, 'utf8'));
const allViolations: JsonViolationOutput[] = Array.isArray(data?.violations) ? data.violations : [];

const filters = input.filters;
let filtered: JsonViolationOutput[] = allViolations.filter(v => matchesFilters(v, filters));

const sortBy = input.sortBy ?? 'severity';
const sortDirection = input.sortDirection ?? 'asc';
filtered = sortViolations(filtered, sortBy, sortDirection);
const topN = input.topN ?? 5;
const limited = filtered.slice(0, topN);

// Transform each violation to a lean shape.
// We expose only the "primary" location for quick navigation (Problems, summaries).
// Example:
// v.locations = [{ file: "/a.ts", ... }, { file: "/b.ts", ... }]
// v.primaryLocationIndex = 1 → primary = locations[1] ("/b.ts")
// If locations are missing, we fallback to an empty primary location.
//
// Note: This uses Array.map (not a Map), so there are no "keys" here and no possibility of key collisions.
// When publishing diagnostics later, grouping by file path intentionally aggregates multiple diagnostics per file rather than overwriting them.
// If a stable per-violation identifier is ever needed, consider:
// `${v.engine}:${v.rule}:${primary.file ?? ''}:${primary.startLine ?? 0}:${primary.startColumn ?? 0}:${v.primaryLocationIndex ?? 0}`
const mapped = limited.map(v => {
const primary = v.locations?.[v.primaryLocationIndex] ?? {};
Comment thread
aruntyagiTutu marked this conversation as resolved.
return {
rule: v.rule,
engine: v.engine,
severity: v.severity,
severityName: SEVERITY_NUMBER_TO_NAME[v.severity as 1|2|3|4|5] ?? String(v.severity),
tags: v.tags,
message: v.message,
primaryLocation: {
file: primary.file,
startLine: primary.startLine,
startColumn: primary.startColumn
},
resources: v.resources
};
});

return {
status: 'success',
resultsFile: absResultsFile,
totalViolations: data?.violationCounts?.total ?? allViolations.length,
totalMatches: filtered.length,
violations: mapped
};
} catch (e) {
return { status: getErrorMessage(e as unknown) };
}
Comment thread
aruntyagiTutu marked this conversation as resolved.
}
}

function sortViolations(arr: JsonViolationOutput[], sortBy: 'severity'|'rule'|'engine'|'file'|'none', dir: 'asc'|'desc'): JsonViolationOutput[] {
if (sortBy === 'none') {
return arr.slice();
}
const mul = dir === 'desc' ? -1 : 1;
return arr.slice().sort((a, b) => {
const aFile = a.locations?.[a.primaryLocationIndex]?.file ?? '';
const bFile = b.locations?.[b.primaryLocationIndex]?.file ?? '';
let cmp = 0;
switch (sortBy) {
case 'severity':
cmp = (a.severity - b.severity);
break;
case 'rule':
cmp = a.rule.localeCompare(b.rule);
break;
case 'engine':
cmp = a.engine.localeCompare(b.engine);
break;
case 'file':
cmp = aFile.localeCompare(bFile);
break;
}
if (cmp !== 0) return cmp * mul;
const s2 = aFile.localeCompare(bFile);
if (s2 !== 0) return s2 * mul;
return a.rule.localeCompare(b.rule) * mul;
});
}

function matchesFilters(v: JsonViolationOutput, filters: QueryFilters): boolean {
const engine = v.engine.toLowerCase();
const rule = v.rule.toLowerCase();
const tagsLower = new Set(v.tags.map(t => t.toLowerCase()));
const primary = v.locations?.[v.primaryLocationIndex];
const fileLower = (primary?.file || '').toLowerCase();

if (filters.engines.length > 0 && !filters.engines.includes(engine)) {
return false;
}
if (filters.severities.length > 0 && !filters.severities.includes(v.severity)) {
return false;
}
if (filters.tags.length > 0) {
let tagMatch = false;
for (const t of filters.tags) {
if (tagsLower.has(t.toLowerCase())) {
tagMatch = true;
break;
}
}
if (!tagMatch) return false;
}
if (filters.rules.length > 0 && !filters.rules.map(r => r.toLowerCase()).includes(rule)) {
Comment thread
aruntyagiTutu marked this conversation as resolved.
return false;
}
if (filters.fileContains.length > 0) {
let any = false;
for (const s of filters.fileContains) {
if (fileLower.includes(s.toLowerCase())) {
any = true;
break;
}
}
if (!any) return false;
}
if (filters.fileEndsWith.length > 0) {
let any = false;
for (const s of filters.fileEndsWith) {
if (fileLower.endsWith(s.toLowerCase())) {
any = true;
break;
}
}
if (!any) return false;
}
return true;
}


Original file line number Diff line number Diff line change
Expand Up @@ -31,6 +31,7 @@ type RunAnalyzerActionOptions = {
// NOTE: THIS MUST ALIGN WITH THE ZOD SCHEMA DEFINED IN `tools/run_code_analyzer.ts`.
export type RunInput = {
target: string[]
selector?: string
}

// NOTE: THIS MUST ALIGN WITH THE ZOD SCHEMA DEFINED IN `tools/run_code_analyzer.ts`.
Expand Down Expand Up @@ -87,8 +88,11 @@ export class RunAnalyzerActionImpl implements RunAnalyzerAction {
...input.target
], input.target);

// At this time, we're hardcoding for the recommended rules.
const ruleSelection: RuleSelection = await analyzer.selectRules(['recommended'], {workspace});
// Select rules based on optional selector, defaulting to "recommended"
const selector: string = (input.selector && input.selector.trim().length > 0)
? input.selector.trim()
: 'recommended';
const ruleSelection: RuleSelection = await analyzer.selectRules([selector], {workspace});

const results: RunResults = await analyzer.run(ruleSelection, {workspace});
this.emitEngineTelemetry(ruleSelection, results, enginePlugins.flatMap(p => p.getAvailableEngineNames()));
Expand Down
3 changes: 2 additions & 1 deletion packages/mcp-provider-code-analyzer/src/constants.ts
Original file line number Diff line number Diff line change
Expand Up @@ -3,7 +3,8 @@ export const TelemetrySource = "MCP"

export const McpTelemetryEvents = {
ENGINE_SELECTION: 'engine_selection',
ENGINE_EXECUTION: 'engine_execution'
ENGINE_EXECUTION: 'engine_execution',
RESULTS_QUERY: 'results_query'
}

export const ENGINE_NAMES = [
Expand Down
51 changes: 51 additions & 0 deletions packages/mcp-provider-code-analyzer/src/entities/query.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,51 @@
export type QueryFilters = {
/**
* Engine names to include (lowercased).
* Why: Allow users to scope queries to a specific engine, e.g., ESLint or PMD.
* Example: engines = ["pmd"] → only PMD violations are considered.
*/
engines: string[];
/**
* Severities to include (1..5; lower number = more severe).
* Why: Support “top N most severe” and per-severity filtering.
* Example: severities = [1,2] → only Critical and High violations.
*/
severities: number[];
/**
* Tags (case-insensitive categories/languages/general tags), lowercased.
* Why: Aligns with selector semantics for categories like Security/Performance
* and languages like JavaScript/TypeScript.
* Example: tags = ["security","performance"] → only those categories.
*/
tags: string[];
/**
* Exact rule names, lowercased.
* Why: Let users drill down to a specific rule across files/engines.
* Example: rules = ["eslint.no-eval"] → only that rule’s violations.
*/
rules: string[];
/**
* File path substring filters, lowercased.
* Why: Quickly scope to a folder or partial path without exact matches.
* Example: fileContains = ["src/app/"] → only files under src/app.
*/
fileContains: string[];
/**
* File path suffix filters, lowercased.
* Why: Target files by name or extension with a precise suffix match.
* Example: fileEndsWith = ["foo.ts"] → files ending in “foo.ts”.
*/
fileEndsWith: string[];
};

export function emptyFilters(): QueryFilters {
return {
engines: [],
severities: [],
tags: [],
rules: [],
fileContains: [],
fileEndsWith: []
};
}

5 changes: 4 additions & 1 deletion packages/mcp-provider-code-analyzer/src/provider.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,8 @@ import { McpProvider, McpTool, Services } from "@salesforce/mcp-provider-api";
import { CodeAnalyzerRunMcpTool } from "./tools/run_code_analyzer.js";
import { CodeAnalyzerDescribeRuleMcpTool } from "./tools/describe_code_analyzer_rule.js";
import { CodeAnalyzerListRulesMcpTool } from "./tools/list_code_analyzer_rules.js";
import { CodeAnalyzerQueryResultsMcpTool } from "./tools/query_code_analyzer_results.js";
import { QueryResultsActionImpl } from "./actions/query-results.js";
import {CodeAnalyzerConfigFactory, CodeAnalyzerConfigFactoryImpl} from "./factories/CodeAnalyzerConfigFactory.js";
import {EnginePluginsFactory, EnginePluginsFactoryImpl} from "./factories/EnginePluginsFactory.js";
import {RunAnalyzerActionImpl} from "./actions/run-analyzer.js";
Expand Down Expand Up @@ -31,7 +33,8 @@ export class CodeAnalyzerMcpProvider extends McpProvider {
configFactory,
enginePluginsFactory,
telemetryService: services.getTelemetryService()
}))
})),
new CodeAnalyzerQueryResultsMcpTool(new QueryResultsActionImpl(), services.getTelemetryService())
]);
}
}
Loading