mirror of https://github.com/garrytan/gstack.git
110 lines
4.4 KiB
TypeScript
110 lines
4.4 KiB
TypeScript
import type { LensFindingInput, LensResult, LensSpec } from './types';
|
|
|
|
export interface ParsedLensOutput {
|
|
result: LensResult | null;
|
|
malformed_lines: Array<{ line: number; raw: string; reason: string }>;
|
|
ignored_blank_lines: number;
|
|
}
|
|
|
|
function isObject(value: unknown): value is Record<string, unknown> {
|
|
return Boolean(value) && typeof value === 'object' && !Array.isArray(value);
|
|
}
|
|
|
|
function validateEnvelope(value: unknown, spec: LensSpec): LensResult {
|
|
if (!isObject(value)) throw new Error('output line must be a JSON object');
|
|
const lens = value.lens;
|
|
if (lens !== spec.lens && !spec.cli_aliases.includes(String(lens))) {
|
|
throw new Error(`output lens '${String(lens)}' does not match '${spec.lens}'`);
|
|
}
|
|
const normalizedLens = spec.lens;
|
|
const status = value.status;
|
|
if (status === 'NO_MATERIAL_FINDINGS') {
|
|
return { lens: normalizedLens, status };
|
|
}
|
|
if (status === 'INSUFFICIENT_EVIDENCE') {
|
|
const missingRequired = value.missing_required;
|
|
if (!Array.isArray(missingRequired) || missingRequired.some((item) => typeof item !== 'string')) {
|
|
throw new Error('INSUFFICIENT_EVIDENCE requires missing_required as an array of strings');
|
|
}
|
|
if (typeof value.why_insufficient !== 'string' || typeof value.what_would_make_actionable !== 'string') {
|
|
throw new Error('INSUFFICIENT_EVIDENCE requires why_insufficient and what_would_make_actionable');
|
|
}
|
|
const missingOptional = value.missing_optional;
|
|
if (missingOptional !== undefined && (!Array.isArray(missingOptional) || missingOptional.some((item) => typeof item !== 'string'))) {
|
|
throw new Error('missing_optional must be an array of strings when present');
|
|
}
|
|
return {
|
|
lens: normalizedLens,
|
|
status,
|
|
missing_required: missingRequired as string[],
|
|
missing_optional: missingOptional as string[] | undefined,
|
|
why_insufficient: value.why_insufficient,
|
|
what_would_make_actionable: value.what_would_make_actionable,
|
|
};
|
|
}
|
|
if (status === 'FINDINGS') {
|
|
if (!Array.isArray(value.findings)) throw new Error('FINDINGS envelope requires a findings array');
|
|
return { lens: normalizedLens, status, findings: value.findings as LensFindingInput[] };
|
|
}
|
|
if ('evidence' in value && 'severity' in value) {
|
|
return { lens: normalizedLens, status: 'FINDINGS', findings: [value as unknown as LensFindingInput] };
|
|
}
|
|
throw new Error("object must be a finding or declare status FINDINGS, NO_MATERIAL_FINDINGS, or INSUFFICIENT_EVIDENCE");
|
|
}
|
|
|
|
export function parseLensOutput(raw: string, spec: LensSpec): ParsedLensOutput {
|
|
const trimmed = raw.trim();
|
|
if (trimmed === 'NO_MATERIAL_FINDINGS' || trimmed === 'NO FINDINGS') {
|
|
return {
|
|
result: { lens: spec.lens, status: 'NO_MATERIAL_FINDINGS' },
|
|
malformed_lines: [],
|
|
ignored_blank_lines: 0,
|
|
};
|
|
}
|
|
|
|
const malformed: ParsedLensOutput['malformed_lines'] = [];
|
|
const findings: LensFindingInput[] = [];
|
|
let terminal: LensResult | null = null;
|
|
let ignoredBlankLines = 0;
|
|
const lines = raw.split(/\r?\n/);
|
|
|
|
for (let index = 0; index < lines.length; index++) {
|
|
const line = lines[index].trim();
|
|
if (!line) {
|
|
ignoredBlankLines += 1;
|
|
continue;
|
|
}
|
|
if (line.startsWith('```')) {
|
|
malformed.push({ line: index + 1, raw: line, reason: 'Markdown fences are not allowed' });
|
|
continue;
|
|
}
|
|
try {
|
|
const parsed = JSON.parse(line);
|
|
const result = validateEnvelope(parsed, spec);
|
|
if (result.status === 'FINDINGS') {
|
|
findings.push(...result.findings);
|
|
} else if (terminal) {
|
|
malformed.push({ line: index + 1, raw: line, reason: 'multiple terminal status objects' });
|
|
} else {
|
|
terminal = result;
|
|
}
|
|
} catch (error) {
|
|
malformed.push({ line: index + 1, raw: line, reason: error instanceof Error ? error.message : String(error) });
|
|
}
|
|
}
|
|
|
|
if (terminal && findings.length > 0) {
|
|
malformed.push({ line: 0, raw: '', reason: 'terminal status cannot be combined with findings' });
|
|
return { result: null, malformed_lines: malformed, ignored_blank_lines: ignoredBlankLines };
|
|
}
|
|
if (terminal) return { result: terminal, malformed_lines: malformed, ignored_blank_lines: ignoredBlankLines };
|
|
if (findings.length > 0) {
|
|
return {
|
|
result: { lens: spec.lens, status: 'FINDINGS', findings },
|
|
malformed_lines: malformed,
|
|
ignored_blank_lines: ignoredBlankLines,
|
|
};
|
|
}
|
|
return { result: null, malformed_lines: malformed, ignored_blank_lines: ignoredBlankLines };
|
|
}
|