afv-library/skills/dx-apexguru-scan/scripts/decode-report.js

390 lines
16 KiB
JavaScript
Executable File

#!/usr/bin/env node
// Decode the base64 `report` field from an ApexGuru SFAP Scan SUCCEEDED body
// and summarize violations grouped by rule. This is the ONLY sanctioned way to
// read the report — never inline base64/jq/Read against the raw result file.
//
// Usage:
// node decode-report.js <raw-result.json> [--group rule|severity|file]
// [--rule RULE] [--severity S]
// [--file NAME] [--top N] [--full]
// [--present]
//
// Default: prints a single-line JSON summary to stdout:
// { analysisMode, attribution, violationCount, filesScanned, severityCounts,
// groups:[{key,count,severityCounts,sample:[...]}], topViolations:[...],
// truncated }
// With --full, `groups[].items` holds every violation in that group.
//
// With --present: renders ready-to-show markdown directly (severity legend,
// one "### Issue N" card per violation with message/code/fix/resource, a
// collapsed "CPU Hotspots" table for the ExpensiveMethods rule, and a closing
// "## Summary" table listing every violation) instead of dumping raw JSON.
// Implies --full (no silent caps in a response meant to be presented as complete).
const fs = require("fs");
function fail(msg) {
console.error(msg);
process.exit(1);
}
const args = process.argv.slice(2);
if (args.length < 1) fail("Usage: node decode-report.js <raw-result.json> [options]");
const rawPath = args[0];
const opts = { group: "rule", top: 10, full: false, rule: null, severity: null, file: null, present: false };
for (let i = 1; i < args.length; i++) {
const a = args[i];
if (a === "--group") opts.group = args[++i];
else if (a === "--top") opts.top = parseInt(args[++i], 10) || 10;
else if (a === "--full") opts.full = true;
else if (a === "--present") { opts.present = true; opts.full = true; }
else if (a === "--rule") opts.rule = args[++i];
else if (a === "--severity") opts.severity = String(args[++i]);
else if (a === "--file") opts.file = args[++i];
}
let raw;
try {
raw = JSON.parse(fs.readFileSync(rawPath, "utf8"));
} catch (e) {
fail(`Could not read/parse raw result file: ${e.message}`);
}
if (raw.status && raw.status !== "SUCCEEDED") {
fail(`Scan status is ${raw.status}, not SUCCEEDED — nothing to decode. Message: ${raw.message || "(none)"}`);
}
// --- decode the base64 report into a violations array ---
let violations = [];
if (raw.report) {
let decoded;
try {
decoded = Buffer.from(raw.report, "base64").toString("utf8");
} catch (e) {
fail(`report field is not valid base64: ${e.message}`);
}
let parsed;
try {
parsed = JSON.parse(decoded);
} catch (e) {
fail(`decoded report is not valid JSON: ${e.message}`);
}
// API contract: report is a JSON array of violations. Tolerate a wrapper obj.
violations = Array.isArray(parsed) ? parsed : parsed.violations || [];
}
// --- normalize each violation to a stable shape (tolerate field-name drift) ---
function loc(v) {
const l = v.location || (Array.isArray(v.locations) && v.locations[0]) || {};
return {
file: basename(l.file || v.file || "unknown"),
line: l.line || l.startLine || v.line || 0,
comment: l.comment || "",
};
}
function basename(p) {
return String(p).split(/[\\/]/).pop();
}
// The API's real field for fix code is `suggestions: [{message, location}]` —
// each suggestion's `message` IS the replacement code. Older/hypothetical
// shapes (fixes/suggestedFixes/suggestedFix) are tolerated as a fallback.
function fixesOf(v) {
if (Array.isArray(v.suggestions) && v.suggestions.length) {
return v.suggestions.map((s) => (typeof s === "string" ? s : s.message)).filter(Boolean);
}
const f = v.fixes || v.suggestedFixes || (v.suggestedFix ? [v.suggestedFix] : []);
return (Array.isArray(f) ? f : [f]).filter(Boolean);
}
// `locations[].comment` holds "ClassName.methodName" — split for a display name.
function methodOf(comment) {
if (!comment) return "";
const parts = comment.split(".");
return parts.length > 1 ? parts.slice(1).join(".") : comment;
}
// The API sometimes prefixes each line of `original_code` with "Line N:" —
// strip that so the snippet reads as plain Apex inside a code block.
function stripLinePrefixes(code) {
return code.replace(/^Line \d+:\s?/gm, "").replace(/\n+$/, "");
}
function norm(v) {
const { file, line, comment } = loc(v);
const metadata = v.metadata || {};
return {
rule: v.rule || v.ruleName || v.type || "UNKNOWN",
message: v.message || v.description || "",
severity: String(v.severity ?? v.sev ?? ""),
file,
line,
method: methodOf(comment),
originalCode: metadata.original_code ? stripLinePrefixes(metadata.original_code) : "",
cpuTimePercentage: typeof metadata.cpu_time_percentage === "number" ? metadata.cpu_time_percentage : null,
fixes: fixesOf(v),
resources: v.resources || v.helpDocs || v.help || [],
};
}
const allItems = violations.map(norm);
let items = allItems;
// --- optional filters ---
if (opts.rule) items = items.filter((v) => v.rule === opts.rule);
if (opts.severity) items = items.filter((v) => v.severity === opts.severity);
if (opts.file) items = items.filter((v) => v.file === opts.file || v.file.includes(opts.file));
// --- aggregates ---
const severityCounts = {};
for (const v of items) severityCounts[v.severity || "?"] = (severityCounts[v.severity || "?"] || 0) + 1;
function keyOf(v) {
return opts.group === "severity" ? (v.severity || "?") : opts.group === "file" ? v.file : v.rule;
}
const byKey = new Map();
for (const v of items) {
const k = keyOf(v);
if (!byKey.has(k)) byKey.set(k, []);
byKey.get(k).push(v);
}
const groups = [...byKey.entries()]
.map(([key, arr]) => {
const sc = {};
for (const v of arr) sc[v.severity || "?"] = (sc[v.severity || "?"] || 0) + 1;
const g = { key, count: arr.length, severityCounts: sc, sample: arr.slice(0, 3) };
if (opts.full) g.items = arr;
return g;
})
.sort((a, b) => b.count - a.count);
// Top violations by severity (numeric asc = most severe first when sev is 1..5).
const topViolations = items
.slice()
.sort((a, b) => (parseInt(a.severity, 10) || 99) - (parseInt(b.severity, 10) || 99) || a.rule.localeCompare(b.rule))
.slice(0, opts.top);
// full = enriched with runtime metrics; static = source-only. Onboarded orgs with
// no runtime data for this code yet come back full but static-equivalent (no
// cpu_time_percentage), so only call it "Production insights" when metrics exist.
// Compute this from the FULL (unfiltered) violation list — attribution is a
// property of the whole scan, so a --rule/--severity/--file drill-down must not
// flip an enriched scan to "Static only" just because the selected subset has
// no cpu_time_percentage.
const analysisMode = raw.analysisMode || "static";
const hasRuntimeMetrics = allItems.some((v) => v.cpuTimePercentage != null);
const attribution = analysisMode === "full" && hasRuntimeMetrics ? "Production insights" : "Static only";
const summary = {
scanId: raw.scanId || null,
analysisMode,
attribution,
violationCount: items.length,
filesScanned: raw.filesScanned ?? null,
serverViolationBreakdown: raw.violationBreakdown || null,
severityCounts,
groupedBy: opts.group,
groups: opts.full ? groups : groups.slice(0, opts.top),
topViolations,
truncated: !opts.full && groups.length > opts.top,
};
if (!opts.present) {
console.log(JSON.stringify(summary));
process.exit(0);
}
// --- --present: render ready-to-read markdown (icons, per-issue detail, ---
// --- summary table) instead of leaving table-building up to the reader. ---
// ApexGuru severities run 1 (critical) .. 5 (info); map to icon + label.
const SEVERITY_MAP = {
1: { icon: "\u{1F534}", label: "Critical" }, // red
2: { icon: "\u{1F7E0}", label: "Major" }, // orange
3: { icon: "\u{1F7E0}", label: "Major" }, // orange
4: { icon: "\u{1F7E1}", label: "Minor" }, // yellow
5: { icon: "\u{1F7E1}", label: "Minor" }, // yellow
};
function sevInfo(sev) {
return SEVERITY_MAP[parseInt(sev, 10)] || { icon: "⚪", label: `Severity ${sev}` };
}
// Human-friendly rule names mapping (for violation.rule field)
const RULE_DISPLAY_NAMES = {
// Single-file scan rules (API_SUPPORTED_AP_TYPES)
"SoqlWithoutAWhereClauseOrLimitStatement": "SOQL Without WHERE Clause or LIMIT Statement",
"SoqlWithUnusedFields": "SOQL With Unused Fields",
"SoqlWithWildcardFilter": "SOQL With Wildcard Filter",
"SchemaGetGlobalDescribeNotEfficient": "Inefficient Schema.getGlobalDescribe() Usage",
"SoqlInALoop": "SOQL in Loop",
"SoqlInALoopOneHop": "SOQL in One-Hop Loop",
"DmlInALoop": "DML in Loop",
"SortingInApex": "Sorting in Apex",
"BusyLoopDelay": "Busy Loop Delay",
"CopyingListOrSetElementsUsingAForLoop": "Copying List/Set Elements Using a For Loop",
"SObjectMapInAForLoop": "SObject Map Lookup in a Loop",
"SoqlWithNegativeExpressions": "SOQL With Negative Expressions",
"SoqlWithApexFilter": "SOQL With Apex Filter",
"ExpensiveMethods": "Expensive Methods",
"UsingTheTestMethodKeyword": "Using the testMethod Keyword",
"WritingFillerStatements": "Writing Filler Statements",
// Project-scope scan rules (API_SCAN_PROJECT_AP_TYPES)
"UnusedMethods": "Unused Methods",
"LimitsGetHeapsizeMethods": "Limits.getHeapSize() in Loop",
"ExpensiveStringComparison": "Expensive String Comparison",
"ExpensiveDebugStatements": "Expensive Debug Statements",
// Fallback-style entries (no explicit ANTI_PATTERN_RULE_NAMES mapping)
"Soql Aggregation": "SOQL Aggregation",
"Redundant Soql": "Redundant SOQL",
};
// Mapping for violationBreakdown keys (SCREAMING_SNAKE_CASE internal IDs)
const BREAKDOWN_DISPLAY_NAMES = {
"SOQL_NO_WHERE_LIMIT": "SOQL Without WHERE Clause or LIMIT Statement",
"SOQL_UNUSED_FIELDS": "SOQL With Unused Fields",
"SOQL_WILDCARD": "SOQL With Wildcard Filter",
"GGD": "Inefficient Schema.getGlobalDescribe() Usage",
"SOQL_IN_LOOP": "SOQL in Loop",
"SOQL_IN_LOOP_1HOP": "SOQL in One-Hop Loop",
"DML_IN_LOOP": "DML in Loop",
"SOQL_SORT_IN_APEX": "Sorting in Apex",
"BUSY_DELAY_LOOP": "Busy Loop Delay",
"COPY_LIST_ELEMENTS_LOOP": "Copying List/Set Elements Using a For Loop",
"CREATE_MAP_WITH_LOOP": "SObject Map Lookup in a Loop",
"SOQL_NEGATIVE_EXPR": "SOQL With Negative Expressions",
"SOQL_FILTER_IN_APEX": "SOQL With Apex Filter",
"EXPENSIVE_METHODS": "Expensive Methods",
"DEPRECATED_TEST_METHOD_KEYWORD": "Using the testMethod Keyword",
"CODE_COVERAGE_INFLATION": "Writing Filler Statements",
"OBSOLETE_CLASSES_METHODS": "Unused Methods",
"EXPENSIVE_LIMIT_METHODS": "Limits.getHeapSize() in Loop",
"EXPENSIVE_STRING_COMPARE": "Expensive String Comparison",
"EXPENSIVE_DEBUG_STMT": "Expensive Debug Statements",
"SOQL_AGGREGATION": "SOQL Aggregation",
"REDUNDANT_SOQL": "Redundant SOQL",
};
function displayRuleName(rule) {
return RULE_DISPLAY_NAMES[rule] || rule;
}
// Three ApexGuru states, distinguished per the SFAP contract (hasRuntimeMetrics
// and attribution computed above):
// analysisMode: static → org is NOT onboarded to ApexGuru
// analysisMode: full + runtime data → onboarded; runtime metrics applied
// analysisMode: full + NO runtime → onboarded, but no runtime data for this
// code yet (result is static-equivalent)
const isFull = summary.analysisMode === "full";
const isEnriched = isFull && hasRuntimeMetrics;
const isOnboardedNoData = isFull && !hasRuntimeMetrics;
const violationWord = summary.violationCount === 1 ? "violation" : "violations";
// attribution already reflects runtime presence ("Production insights" only when enriched).
let out = `# ApexGuru Scan Results — ${summary.attribution}\n\n`;
out += `Found **${summary.violationCount} performance ${violationWord}** across ${summary.filesScanned ?? "?"} file(s). `;
if (isEnriched) {
out += "Findings enriched with production runtime metrics.\n";
} else if (isOnboardedNoData) {
out += "No runtime metrics were found for this class yet. Generate an ApexGuru report in Scale Center to see runtime insights.\n";
} else {
out += "ApexGuru static analysis is active. To see how this code performs in your production org, generate a runtime report in Scale Center.\n";
}
out += "\n";
out += "## Severity Legend\n\n";
out += "- \u{1F534} Critical — blocks deployment and causes test failures\n";
out += "- \u{1F7E0} Major — reduces reliability without blocking deployment\n";
out += "- \u{1F7E1} Minor — deviates from quality standards\n";
if (isEnriched) out += "- \u{1F4A1} Severity adjusted based on production performance.\n";
out += "\n";
if (summary.violationCount === 0) {
out += "No performance antipatterns found.\n";
console.log(out);
process.exit(0);
}
const allViolations = groups.flatMap((g) => g.items || g.sample);
allViolations.sort((a, b) => (parseInt(a.severity, 10) || 99) - (parseInt(b.severity, 10) || 99));
// ExpensiveMethods is a per-method CPU-hotspot ranking, not a line-level
// antipattern — with N methods ranked it produces N near-identical cards, so
// collapse it into one ranked table instead of repeating the detail format.
const hotspots = allViolations.filter((v) => v.rule === "ExpensiveMethods");
const otherViolations = allViolations.filter((v) => v.rule !== "ExpensiveMethods");
if (hotspots.length) {
out += `## CPU Hotspots (${hotspots.length})\n\n`;
out += "Methods ranked by share of observed Apex CPU time (production metrics):\n\n";
out += "| Method | % of CPU time |\n";
out += "|--------|---------------|\n";
hotspots
.slice()
.sort((a, b) => (b.cpuTimePercentage || 0) - (a.cpuTimePercentage || 0))
.forEach((v) => {
const pct = v.cpuTimePercentage != null ? Math.round(v.cpuTimePercentage * 10) / 10 : "?";
out += `| \`${v.method || v.file}\` | ${pct}% |\n`;
});
out += "\n";
}
// Full-detail cards for the remaining (non-hotspot) violations, capped at
// 10 most critical. Both the detailed cards AND the summary table are capped.
const cardLimit = 10;
const cards = otherViolations.slice(0, cardLimit);
const remainingCount = otherViolations.length - cardLimit;
if (cards.length) {
out += `## Issues${otherViolations.length > cardLimit ? ` (top ${cardLimit} of ${otherViolations.length}, most critical first)` : ""}\n\n`;
cards.forEach((v, i) => {
const sev = sevInfo(v.severity);
const where = v.method ? ` in \`${v.method}\`` : "";
const ruleName = displayRuleName(v.rule);
out += `### Issue ${i + 1}${isEnriched ? "\u{1F4A1}" : ""}${sev.icon} ${sev.label}: ${ruleName}${where} (Line ${v.line})\n\n`;
out += `${v.message}\n\n`;
if (v.originalCode) {
out += `**Current code** (\`${v.file}\`, line ${v.line}):\n\n\`\`\`apex\n${v.originalCode}\n\`\`\`\n\n`;
}
if (v.fixes.length) {
out += `**Suggested fix:**\n\n\`\`\`apex\n${v.fixes[0]}\n\`\`\`\n\n`;
}
if (v.resources.length) {
out += `[Learn more](${v.resources[0]})\n\n`;
}
});
}
// Summary table also capped at top 10 (matches the detailed cards above).
out += "## Summary\n\n";
out += "| # | Severity | Rule | Method | Line |\n";
out += "|---|----------|------|--------|------|\n";
const summaryViolations = allViolations.slice(0, cardLimit);
summaryViolations.forEach((v, i) => {
const sev = sevInfo(v.severity);
const lineCell = v.rule === "ExpensiveMethods" ? "-" : v.line;
const ruleName = displayRuleName(v.rule);
out += `| ${i + 1} | ${sev.icon} ${sev.label} | ${ruleName} | ${v.method || "-"} | ${lineCell} |\n`;
});
// Add Anti-pattern breakdown section with summary (always show the full breakdown)
if (summary.serverViolationBreakdown && Object.keys(summary.serverViolationBreakdown).length > 0) {
out += `\n---\n\n`;
const breakdown = summary.serverViolationBreakdown;
const ruleTypeCount = Object.keys(breakdown).length;
// Summary line
out += `Detected **${summary.violationCount} anti-patterns** across **${ruleTypeCount} rule types**.\n\n`;
// Breakdown list
out += `**Anti-pattern breakdown:**\n\n`;
const sortedBreakdown = Object.entries(breakdown).sort((a, b) => b[1] - a[1]);
sortedBreakdown.forEach(([key, count]) => {
const displayName = BREAKDOWN_DISPLAY_NAMES[key] || key.replace(/_/g, " ").replace(/\b\w/g, c => c.toUpperCase());
out += `- ${displayName} (${count})\n`;
});
// Footer message if more than 10 violations
if (remainingCount > 0) {
out += `\n---\n\n`;
out += `Showing the top ${cardLimit} of ${otherViolations.length} antipatterns. `;
out += `Type **"show all"** or ask for details to see the remaining ${remainingCount}.\n`;
}
}
console.log(out);