core/converters/violation.converter.ts

core/converters/violation.converter.ts is a file in GovLab Pipeline. 127 lines of code and 31 definitions.

import {
    CARRIAGE_RETURN,
    GAP_CHARS,
    MESSAGE_JOIN,
    MIN_FINDING_PARTS,
    MIN_GAP,
    POSITION_SEPARATOR,
    SGR_BODY,
    SGR_END,
    SGR_OPEN,
    SOURCE_EXTENSIONS,
} from "#configuration/constants/violation.constants";
import type { ParsedOutput, StepOutput, Violation, ViolationsArtifact } from "#types/report.types";
import { ROOT } from "@ssot/paths";

const ROOT_POSIX = ROOT.split("\\").join("/");

const sgrEnd = function sgrEnd(text: string, at: number): number {
    let scan = at + SGR_OPEN.length;
    while (scan < text.length && SGR_BODY.has(text.charAt(scan))) {
        scan += 1;
    }
    return text.charAt(scan) === SGR_END ? scan + 1 : -1;
};

export const stripAnsi = function stripAnsi(text: string): string {
    let out = "";
    let at = 0;
    while (at < text.length) {
        const end = text.startsWith(SGR_OPEN, at) ? sgrEnd(text, at) : -1;
        out += end === -1 ? text.charAt(at) : "";
        at = end === -1 ? at + 1 : end;
    }
    return out;
};

export const withoutTrailingReturn = function withoutTrailingReturn(text: string): string {
    return text.endsWith(CARRIAGE_RETURN) ? text.slice(0, -1) : text;
};

const gapEnd = function gapEnd(text: string, at: number): number {
    let scan = at;
    while (scan < text.length && GAP_CHARS.has(text.charAt(scan))) {
        scan += 1;
    }
    return scan;
};

export const splitOnGaps = function splitOnGaps(text: string): string[] {
    const parts: string[] = [];
    let start = 0;
    let at = 0;
    while (at < text.length) {
        const end = GAP_CHARS.has(text.charAt(at)) ? gapEnd(text, at) : at + 1;
        if (end - at >= MIN_GAP) {
            parts.push(text.slice(start, at));
            start = end;
        }
        at = end;
    }
    parts.push(text.slice(start));
    return parts.filter((part) => part.length > 0);
};

const fileHeaderOf = function fileHeaderOf(line: string): string | null {
    if (line.length === 0 || line.startsWith(" ") || line.includes(MESSAGE_JOIN)) {
        return null;
    }
    const posix = line.trim().split("\\").join("/");
    if (!SOURCE_EXTENSIONS.some((ext) => posix.endsWith(ext))) {
        return null;
    }
    return posix.startsWith(`${ROOT_POSIX}/`) ? posix.slice(ROOT_POSIX.length + 1) : posix;
};

const numberOr = function numberOr(value: string | undefined): number | null {
    const parsed = Number(value);
    return Number.isFinite(parsed) ? parsed : null;
};

const findingOf = function findingOf(line: string, step: string): Violation | null {
    const parts = line.startsWith(" ") ? splitOnGaps(line.trim()) : [];
    if (parts.length < MIN_FINDING_PARTS) {
        return null;
    }
    const [position = "", severity = "", ...rest] = parts;
    const [lineText, columnText] = position.split(POSITION_SEPARATOR);
    const lineNumber = numberOr(lineText);
    if (lineNumber === null) {
        return null;
    }
    const rule = rest.length > 1 ? (rest.at(-1) ?? "") : "";
    const message = (rest.length > 1 ? rest.slice(0, -1) : rest).join(MESSAGE_JOIN);
    return { column: numberOr(columnText), line: lineNumber, message, rule, severity, step };
};

export const parseViolations = function parseViolations(output: string, step: string): ParsedOutput {
    const files = new Map<string, Violation[]>();
    let current: string | null = null;
    for (const raw of output.split("\n")) {
        const line = withoutTrailingReturn(stripAnsi(raw));
        const header = fileHeaderOf(line);
        const finding = header === null && current !== null ? findingOf(line, step) : null;
        current = header ?? current;
        if (finding !== null && current !== null) {
            files.set(current, [...(files.get(current) ?? []), finding]);
        }
    }
    return { files, matched: files.size > 0 };
};

export const buildViolations = function buildViolations(
    outputs: readonly StepOutput[],
    label: string,
    stoppedAt: string | null,
    generatedAt: string,
): ViolationsArtifact {
    const files = new Map<string, Violation[]>();
    const unparsed: Record<string, string> = {};
    for (const output of outputs.filter((entry) => !entry.ok)) {
        const parsed = parseViolations(output.out, output.label);
        for (const [file, found] of parsed.files) {
            files.set(file, [...(files.get(file) ?? []), ...found]);
        }
        if (!parsed.matched) {
            unparsed[output.label] = output.out.trim();
        }
    }
    const sorted = [...files.entries()].toSorted((a, b) => a[0].localeCompare(b[0]));
    return {
        files: Object.fromEntries(sorted),
        generatedAt,
        label,
        stoppedAt,
        totals: { files: sorted.length, violations: sorted.reduce((sum, [, found]) => sum + found.length, 0) },
        unparsed,
    };
};