sarif-explorer
Version:
A modern SARIF report viewer that converts SARIF files into interactive, shareable HTML reports with file explorer, collapsible issue lists, and code snippets.
276 lines • 10.8 kB
JavaScript
import fs from 'fs/promises';
import path from 'path';
import { validateSarifFile } from '../utils/validators.js';
import { loadArtifactContent, validateSourceDirectory } from '../utils/artifact-loader.js';
/**
* Parse SARIF file and extract violations grouped by file
*/
export async function parseSarifFile(filePath, options = {}) {
// Validate the SARIF file first
await validateSarifFile(filePath);
// Read and parse the SARIF file
const content = await fs.readFile(filePath, 'utf8');
const sarifData = JSON.parse(content);
// Process all runs and merge results
const allResults = [];
const allArtifacts = new Map();
for (const run of sarifData.runs) {
// Collect artifacts from this run
if (run.artifacts) {
run.artifacts.forEach((artifact, index) => {
allArtifacts.set(index, artifact);
});
}
// Collect results from this run
if (run.results) {
allResults.push(...run.results);
}
}
// Group violations by file
const fileViolations = {};
for (const result of allResults) {
const location = result.locations[0];
if (location && location.physicalLocation) {
const fileUri = location.physicalLocation.artifactLocation.uri;
const region = location.physicalLocation.region;
const artifactIndex = location.physicalLocation.artifactLocation.index;
if (!fileViolations[fileUri]) {
fileViolations[fileUri] = [];
}
const violation = {
ruleId: result.ruleId,
level: result.level,
message: result.message.text,
line: region.startLine,
column: region.startColumn || 1,
endLine: region.endLine,
endColumn: region.endColumn,
fileUri,
artifactIndex
};
fileViolations[fileUri].push(violation);
}
}
// Attach code snippets to each violation if enabled
if (options.includeSnippets !== false) {
await attachCodeSnippets(fileViolations, path.dirname(filePath), allArtifacts, options);
}
// Calculate statistics
const files = Object.keys(fileViolations).sort();
const totalViolations = files.reduce((sum, file) => sum + fileViolations[file].length, 0);
const statistics = calculateStatistics(fileViolations);
return {
files,
fileViolations,
totalViolations,
statistics
};
}
/**
* Attach code snippets to violations by reading source files or using embedded content
*/
async function attachCodeSnippets(fileViolations, basePath, artifacts, options) {
for (const [fileUri, violations] of Object.entries(fileViolations)) {
let lines = null;
let loadError = null;
// Step 1: Try to get content from embedded artifacts first
for (const violation of violations) {
if (violation.artifactIndex !== undefined && artifacts.has(violation.artifactIndex)) {
const artifact = artifacts.get(violation.artifactIndex);
if (artifact.contents?.text) {
lines = artifact.contents.text.split(/\r?\n/);
if (options.verbose) {
console.log(`📄 Using embedded content for ${fileUri}`);
}
break;
}
}
}
// Step 2: If no embedded content, try to find by URI in artifacts
if (!lines) {
for (const [index, artifact] of artifacts.entries()) {
if (artifact.location?.uri === fileUri && artifact.contents?.text) {
lines = artifact.contents.text.split(/\r?\n/);
if (options.verbose) {
console.log(`📄 Using embedded content by URI for ${fileUri}`);
}
break;
}
}
}
// Step 3: If no embedded content, try loading from file system
if (!lines) {
const sourceDir = options.sourceDir || basePath;
// Validate source directory if provided
if (options.sourceDir && !(await validateSourceDirectory(options.sourceDir))) {
loadError = `Source directory not found or not accessible: ${options.sourceDir}`;
if (options.verbose) {
console.log(`⚠️ ${loadError}`);
}
}
else {
try {
const result = await loadArtifactContent(fileUri, sourceDir);
if (result.success && result.content) {
lines = result.content.split(/\r?\n/);
if (options.verbose) {
console.log(`📄 Loaded content from file system: ${result.filePath}`);
}
}
else {
loadError = result.error || 'Failed to load file content';
if (options.verbose) {
console.log(`⚠️ Failed to load ${fileUri}: ${loadError}`);
}
}
}
catch (error) {
loadError = error instanceof Error ? error.message : 'Unknown error loading file';
if (options.verbose) {
console.log(`⚠️ Error loading ${fileUri}: ${loadError}`);
}
}
}
}
// Step 4: Generate snippets for all violations in this file
for (const violation of violations) {
if (lines) {
violation.snippet = generateCodeSnippet(lines, violation);
}
else {
violation.snippet = loadError
? `[Source file not found: ${loadError}]`
: '[Source file not found]';
}
}
}
}
/**
* Generate formatted code snippet for a violation
*/
function generateCodeSnippet(lines, violation) {
// Show up to 3 lines before and after, and all lines in the violation region
const start = Math.max(0, violation.line - 4); // 0-based
const end = Math.min(lines.length, (violation.endLine || violation.line) + 3);
return lines.slice(start, end).map((line, idx) => {
const lineNum = start + idx + 1;
const isViolationLine = lineNum >= violation.line &&
lineNum <= (violation.endLine || violation.line);
const marker = isViolationLine ? '▶ ' : ' ';
const lineNumber = lineNum.toString().padStart(4);
return `${marker}${lineNumber}: ${line}`;
}).join('\n');
}
/**
* Calculate comprehensive statistics from file violations
*/
function calculateStatistics(fileViolations) {
let errorCount = 0;
let warningCount = 0;
let infoCount = 0;
let noteCount = 0;
let noneCount = 0;
const ruleIdCounts = new Map();
const fileViolationCounts = new Map();
for (const [fileUri, violations] of Object.entries(fileViolations)) {
fileViolationCounts.set(fileUri, violations.length);
for (const violation of violations) {
// Count by level
switch (violation.level) {
case 'error':
errorCount++;
break;
case 'warning':
warningCount++;
break;
case 'info':
infoCount++;
break;
case 'note':
noteCount++;
break;
case 'none':
noneCount++;
break;
}
// Count by rule ID
const currentCount = ruleIdCounts.get(violation.ruleId) || 0;
ruleIdCounts.set(violation.ruleId, currentCount + 1);
}
}
const totalFiles = Object.keys(fileViolations).length;
const totalViolations = errorCount + warningCount + infoCount + noteCount + noneCount;
const averageViolationsPerFile = totalFiles > 0 ? Math.round((totalViolations / totalFiles) * 10) / 10 : 0;
// Find most common rule violations
const sortedRules = Array.from(ruleIdCounts.entries())
.sort((a, b) => b[1] - a[1])
.slice(0, 5);
// Find files with most violations
const sortedFiles = Array.from(fileViolationCounts.entries())
.sort((a, b) => b[1] - a[1])
.slice(0, 5);
return {
errorCount,
warningCount,
infoCount,
noteCount,
noneCount,
averageViolationsPerFile,
mostCommonRules: sortedRules,
filesWithMostViolations: sortedFiles,
totalFiles,
totalViolations
};
}
/**
* Get detailed information about a specific file's violations
*/
export function getFileViolationDetails(fileUri, fileViolations) {
const violations = fileViolations[fileUri] || [];
const levelCounts = {
error: violations.filter(v => v.level === 'error').length,
warning: violations.filter(v => v.level === 'warning').length,
info: violations.filter(v => v.level === 'info').length,
note: violations.filter(v => v.level === 'note').length,
none: violations.filter(v => v.level === 'none').length
};
const ruleCounts = new Map();
violations.forEach(v => {
const count = ruleCounts.get(v.ruleId) || 0;
ruleCounts.set(v.ruleId, count + 1);
});
return {
totalViolations: violations.length,
levelCounts,
ruleCounts: Array.from(ruleCounts.entries()).sort((a, b) => b[1] - a[1]),
violations
};
}
/**
* Filter violations by various criteria
*/
export function filterViolations(fileViolations, filters) {
const filtered = {};
for (const [fileUri, violations] of Object.entries(fileViolations)) {
// Filter by file pattern
if (filters.filePattern && !fileUri.includes(filters.filePattern)) {
continue;
}
const filteredViolations = violations.filter(violation => {
// Filter by level
if (filters.level && filters.level.length > 0 && !filters.level.includes(violation.level)) {
return false;
}
// Filter by rule ID
if (filters.ruleId && filters.ruleId.length > 0 && !filters.ruleId.includes(violation.ruleId)) {
return false;
}
return true;
});
if (filteredViolations.length > 0) {
filtered[fileUri] = filteredViolations;
}
}
return filtered;
}
//# sourceMappingURL=sarif-parser.js.map