/* Mechanical fix engine for typography violations (RFC-1072). Provides the FixAction discriminated union, the FIXABLE_RULE_IDS whitelist, and the applyFixes pure function that transforms findings into fixed segment text. Only rules whitelisted in FIXABLE_RULE_IDS populate the fix field on TypographyFinding. Do not read or write files — that is the command adapter in werkstatt-site. Do not run convergence guards — the command adapter re-runs typography.validate. Do not fix rules outside the whitelist — those require human judgment. warpgogol-m000193: whitelisted TYPO-HEAD-04 for autofix — missing terminal punctuation on a multi-sentence heading is a mechanical insert of ".". RFC-1072 */ import type { TextSegment } from "./text-surface.ts"; import type { TypographyFinding, TypographyContext } from "./rules-tier1.ts"; // --------------------------------------------------------------------------- // FixAction: discriminated union for mechanical fix actions // --------------------------------------------------------------------------- /** * Discriminated union for mechanical fix actions. * Stored on `TypographyFinding.fix` (replacing `null` from RFC-1068). * Only rules whitelisted in FIXABLE_RULE_IDS populate this field; * all other rules keep `fix: null`. */ export type FixAction = | { type: "replace"; old: string; new: string } | { type: "insert"; at: number; text: string } | { type: "delete"; at: number; length: number }; // --------------------------------------------------------------------------- // Whitelist of fixable rule IDs (RFC-1072) // --------------------------------------------------------------------------- /** * Rules that are safe for mechanical fixing. Only PUNCT, SPACE, APOS, ABBR, * and HEAD-04 are fixable — other families require human judgment. */ export const FIXABLE_RULE_IDS: readonly string[] = [ "TYPO-PUNCT-01", "TYPO-SPACE-01", "TYPO-SPACE-02", "TYPO-APOS-01", "TYPO-APOS-02", "TYPO-ABBR-01", "TYPO-ABBR-02", "TYPO-HEAD-04", ] as const; const FIXABLE_SET: ReadonlySet = new Set(FIXABLE_RULE_IDS); /** * Check if a rule ID is in the fixable whitelist. */ export function isFixable(ruleId: string): boolean { return FIXABLE_SET.has(ruleId); } // --------------------------------------------------------------------------- // Segment identity key (for Map) // --------------------------------------------------------------------------- function segmentKey(segment: TextSegment): string { return `${segment.file}:${segment.line}:${segment.path}`; } // --------------------------------------------------------------------------- // Apply a single fix action to segment text // --------------------------------------------------------------------------- function applyAction(text: string, action: FixAction, column: number): string { switch (action.type) { case "replace": { // Replace the match at the given column const before = text.slice(0, column); const after = text.slice(column + action.old.length); return before + action.new + after; } case "insert": { // Insert text at the specified position (relative to segment text) const before = text.slice(0, action.at); const after = text.slice(action.at); return before + action.text + after; } case "delete": { // Delete `length` characters starting at `at` const before = text.slice(0, action.at); const after = text.slice(action.at + action.length); return before + after; } } } // --------------------------------------------------------------------------- // applyFixes: apply all fixes to findings, returning fixed segment text // --------------------------------------------------------------------------- /** * Apply mechanical fixes to findings. Each finding must have a non-null `fix` * (populated by the rule when whitelisted in FIXABLE_RULE_IDS). * * The function groups findings by segment, then applies all fixes within a * segment in reverse column order (to avoid offset shifts). Returns a Map keyed * by segment identity, with the full fixed segment text as the value. * * Findings with `fix: null` (non-whitelisted rules) are skipped. */ export function applyFixes( findings: TypographyFinding[], _context: TypographyContext, ): { fixedSegments: Map; fixCount: number } { // Group findings by segment key const bySegment = new Map(); for (const f of findings) { if (f.fix === null) continue; const key = segmentKey(f.segment); let entry = bySegment.get(key); if (!entry) { entry = { segment: f.segment, findings: [] }; bySegment.set(key, entry); } entry.findings.push(f); } const fixedSegments = new Map(); let fixCount = 0; for (const [key, { segment, findings: segFindings }] of bySegment) { // Sort findings by column in descending order to apply from right to left const sorted = [...segFindings].sort((a, b) => b.column - a.column); let text = segment.text; for (const f of sorted) { if (f.fix === null) continue; text = applyAction(text, f.fix, f.column); fixCount++; } fixedSegments.set(key, text); } return { fixedSegments, fixCount }; }