import { appendFileSync, existsSync, mkdirSync, readFileSync } from "node:fs"; import { dirname } from "node:path"; export interface ErrorRecord { type: "error"; ts: string; category: string; original: string; corrected: string; note: string; severity: string; project: string; } export interface CheckedRecord { type: "checked"; ts: string; project: string; wordCount: number; errorCount: number; } export type LogRecord = ErrorRecord | CheckedRecord; const ERROR_KEYS = ["type", "ts", "category", "original", "corrected", "note", "severity", "project"]; const CHECKED_KEYS = ["type", "ts", "project", "wordCount", "errorCount"]; /** Exact key match, so a record from a superseded schema is skipped rather than half-read. */ function hasExactKeys(record: Record, keys: string[]): boolean { const actual = Object.keys(record); return actual.length === keys.length && keys.every((k) => k in record); } function isErrorRecord(value: unknown): value is ErrorRecord { if (!value || typeof value !== "object") return false; const r = value as Record; return ( r.type === "error" && typeof r.ts === "string" && typeof r.category === "string" && typeof r.original === "string" && typeof r.corrected === "string" && typeof r.note === "string" && typeof r.severity === "string" && typeof r.project === "string" && hasExactKeys(r, ERROR_KEYS) ); } function isCheckedRecord(value: unknown): value is CheckedRecord { if (!value || typeof value !== "object") return false; const r = value as Record; return ( r.type === "checked" && typeof r.ts === "string" && typeof r.project === "string" && typeof r.wordCount === "number" && typeof r.errorCount === "number" && hasExactKeys(r, CHECKED_KEYS) ); } function ensureLogDir(logPath: string): void { const dir = dirname(logPath); if (!existsSync(dir)) mkdirSync(dir, { recursive: true }); } function appendRecord(logPath: string, record: LogRecord): void { ensureLogDir(logPath); appendFileSync(logPath, `${JSON.stringify(record)}\n`, "utf8"); } export function appendError(logPath: string, record: Omit): void { appendRecord(logPath, { type: "error", ...record }); } export function appendChecked(logPath: string, record: Omit): void { appendRecord(logPath, { type: "checked", ...record }); } export interface ReadLogResult { records: LogRecord[]; skipped: number; } /** * Skips malformed, truncated, and unknown-type lines instead of throwing, so a * torn line from a concurrent writer never makes the report unavailable. */ export function readLog(logPath: string): ReadLogResult { if (!existsSync(logPath)) return { records: [], skipped: 0 }; const lines = readFileSync(logPath, "utf8").split("\n"); const records: LogRecord[] = []; let skipped = 0; for (const line of lines) { const trimmed = line.trim(); if (!trimmed) continue; try { const parsed = JSON.parse(trimmed); if (isErrorRecord(parsed) || isCheckedRecord(parsed)) { records.push(parsed); } else { skipped++; } } catch { skipped++; } } return { records, skipped }; } export interface CategoryCount { category: string; count: number; } /** Groups error records by category key so repeated mistakes of one type collapse into one row. */ export function aggregateByCategory(records: LogRecord[]): CategoryCount[] { const counts = new Map(); for (const record of records) { if (record.type !== "error") continue; counts.set(record.category, (counts.get(record.category) ?? 0) + 1); } return [...counts.entries()].map(([category, count]) => ({ category, count })); } /** The messages-checked denominator: how many messages were actually examined, not skipped. */ export function countChecked(records: LogRecord[]): number { let total = 0; for (const record of records) { if (record.type !== "checked") continue; total++; } return total; } /** "YYYY-MM" bucket key for a record's timestamp, used for month-over-month grouping. */ export function monthKey(ts: string): string { return ts.slice(0, 7); } export function groupByMonth(records: LogRecord[]): Map { const groups = new Map(); for (const record of records) { const key = monthKey(record.ts); const bucket = groups.get(key); if (bucket) bucket.push(record); else groups.set(key, [record]); } return groups; }