/* Tier 2 structure typography rules (RFC-1069). Implements 10 rules across 3 families: HEAD (4), PAIR (3), MD (3). Rules operate on TextSegment objects produced by the text-surface extractor (RFC-1068), never on raw file bytes. All regexes use the `u` flag and Unicode property escapes where applicable. Do not extract text — that is text-surface.ts. Do not handle file I/O or command registration — that is the command adapter. Do not auto-fix — autofix is RFC-1072. Do not validate heading hierarchy — that is a semantic concern, not typographic. Do not validate list item punctuation — that is RFC-1067 (list.punctuation.validate). warpgogol-m000193: added TYPO-HEAD-04 (multi-sentence heading must end with terminal punctuation, autofixable) and gated TYPO-HEAD-01/03 to single-sentence headings — a heading composed of complete sentences may and must end with a sentence terminator, a title-fragment heading still may not. RFC-1069 */ import type { TextSegment } from "./text-surface.ts"; import { finding, type TypographyFinding, type TypographyRule } from "./rules-tier1.ts"; import { splitSentences } from "../semantic/extract.ts"; // --------------------------------------------------------------------------- // Types // --------------------------------------------------------------------------- export type Tier2StructureRuleId = `TYPO-${"HEAD" | "PAIR" | "MD"}-${string}`; // --------------------------------------------------------------------------- // Helper: extract last path segment from a JSON-path // e.g. "$.blocks[0].props.heading" → "heading" // --------------------------------------------------------------------------- function lastPathKey(path: string): string | undefined { const parts = path.split("."); const last = parts[parts.length - 1]; if (!last || last.startsWith("$") || last.includes("[")) return undefined; return last; } // --------------------------------------------------------------------------- // Helper: check if a segment is a heading (body or frontmatter) // --------------------------------------------------------------------------- function isBodyHeading(text: string): boolean { // A body segment is a heading only if it is a single line starting with # // (RFC-1083). Multi-line body fields starting with ## are NOT headings — // they are body content. The `m` flag is unnecessary because // !text.includes("\n") guarantees a single-line string. return /^#{1,6}\s+.+$/u.test(text) && !text.includes("\n"); } function isFrontmatterHeading(segment: TextSegment): boolean { if (segment.source !== "frontmatter") return false; const key = lastPathKey(segment.path); return key === "heading" || key === "subheading"; } function isHeadingSegment(segment: TextSegment): boolean { return isBodyHeading(segment.text) || isFrontmatterHeading(segment); } /** * Number of sentences inside a heading-class segment, via the locale-aware * splitter (abbreviations, digit list markers, and lowercase continuations * never produce a boundary). A single-sentence heading is a title fragment — * terminal punctuation is forbidden (HEAD-01/02/03). A multi-sentence heading * is prose in heading position — HEAD-04 requires the final sentence to be * terminated, and HEAD-01/03 must not punish that terminator. */ function headingSentenceCount(segment: TextSegment): number { return splitSentences(segment.text, segment.locale).length; } // --------------------------------------------------------------------------- // TYPO-HEAD-01: Heading ends with a period // --------------------------------------------------------------------------- const HEAD01_PERIOD = /[.]\s*$/u; const head01: TypographyRule = { id: "TYPO-HEAD-01", family: "HEAD", tier: 2, severity: "error", check(segment) { const text = segment.text; if (!isHeadingSegment(segment)) return []; // Multi-sentence headings are required to end with terminal punctuation // (HEAD-04) — a trailing period there is correct, not a defect. if (headingSentenceCount(segment) > 1) return []; const match = HEAD01_PERIOD.exec(text); if (!match) return []; const col = match.index; return [ finding( "TYPO-HEAD-01", segment, match[0], col, "Heading ends with a period — a heading is a title, not a sentence.", "Remove the trailing period.", ), ]; }, }; // --------------------------------------------------------------------------- // TYPO-HEAD-02: Heading ends with a colon or dash // --------------------------------------------------------------------------- const HEAD02_COLON_DASH = /[:\u2014\u2013-]\s*$/u; const head02: TypographyRule = { id: "TYPO-HEAD-02", family: "HEAD", tier: 2, severity: "error", check(segment) { const text = segment.text; if (!isBodyHeading(text) && !isFrontmatterHeading(segment)) return []; const match = HEAD02_COLON_DASH.exec(text); if (!match) return []; const col = match.index; return [ finding( "TYPO-HEAD-02", segment, match[0], col, "Heading ends with a colon or dash — trailing punctuation is always wrong in a heading.", "Remove the trailing colon or dash.", ), ]; }, }; // --------------------------------------------------------------------------- // TYPO-HEAD-03: Heading ends with other closing punctuation (!?)]) // --------------------------------------------------------------------------- const HEAD03_OTHER = /[)!?]\s*$/u; const head03: TypographyRule = { id: "TYPO-HEAD-03", family: "HEAD", tier: 2, severity: "error", check(segment) { const text = segment.text; if (!isHeadingSegment(segment)) return []; // Same gate as HEAD-01: a multi-sentence heading may end with ! or ? // (any sentence terminator satisfies HEAD-04). if (headingSentenceCount(segment) > 1) return []; const match = HEAD03_OTHER.exec(text); if (!match) return []; const col = match.index; return [ finding( "TYPO-HEAD-03", segment, match[0], col, "Heading ends with closing punctuation (!, ?, or )) — a heading should not end with these marks.", "Remove the trailing punctuation.", ), ]; }, }; // --------------------------------------------------------------------------- // TYPO-HEAD-04: Multi-sentence heading lacks terminal punctuation // A heading composed of two or more sentences is prose in heading position — // every sentence, including the last, must be terminated (. ! ?). Title // fragments (single sentence) are governed by HEAD-01..03 instead. // Autofixable: inserts "." at the end of the value. // --------------------------------------------------------------------------- const HEAD04_TERMINAL = /[.!?]\s*$/u; // Chars that differ between the parsed value and the YAML source line — // a fix match that cannot be located verbatim would corrupt the file. const HEAD04_UNSAFE_MATCH = /["'\\\n]/u; const head04: TypographyRule = { id: "TYPO-HEAD-04", family: "HEAD", tier: 2, severity: "error", check(segment) { const text = segment.text; if (!isHeadingSegment(segment)) return []; if (headingSentenceCount(segment) < 2) return []; if (HEAD04_TERMINAL.test(text)) return []; // The fix locates the raw value verbatim on the source line — only offer // it when the parsed value can appear there unescaped. const rawMatch = segment.raw.trimEnd(); const fix = rawMatch.length > 0 && !HEAD04_UNSAFE_MATCH.test(rawMatch) ? { type: "insert" as const, at: text.trimEnd().length, text: "." } : null; return [ finding( "TYPO-HEAD-04", segment, rawMatch, 0, "Heading contains multiple sentences but does not end with terminal punctuation (. ! ?).", "End the final sentence with a period, question mark, or exclamation mark — or shorten the heading to a single title phrase.", fix, ), ]; }, }; // --------------------------------------------------------------------------- // TYPO-PAIR-01: Unbalanced round parentheses // Exclusions: code spans (already stripped), formulas (replaced with placeholder) // --------------------------------------------------------------------------- function countChar(text: string, ch: string): number { let count = 0; for (const c of text) { if (c === ch) count++; } return count; } const pair01: TypographyRule = { id: "TYPO-PAIR-01", family: "PAIR", tier: 2, severity: "error", check(segment) { const text = segment.text; const open = countChar(text, "("); const close = countChar(text, ")"); if (open === close) return []; const col = open > close ? text.lastIndexOf("(") : text.lastIndexOf(")"); return [ finding( "TYPO-PAIR-01", segment, open > close ? "(" : ")", col, `Unbalanced round parentheses — ${open} opening, ${close} closing.`, "Add or remove a parenthesis to balance the pair.", ), ]; }, }; // --------------------------------------------------------------------------- // TYPO-PAIR-02: Orphaned closing square bracket // After removing balanced [...] pairs, any ] remaining is an orphan. // Exclusions: markdown link syntax (the link-stripping step removes ](...) leaving // [text — orphaned [ is expected and not a defect); code spans // --------------------------------------------------------------------------- const pair02: TypographyRule = { id: "TYPO-PAIR-02", family: "PAIR", tier: 2, severity: "error", check(segment) { const text = segment.text; // Find orphan ] by scanning the original string and skipping balanced [..] pairs. let i = 0; let bracketDepth = 0; while (i < text.length) { if (text[i] === "[") { bracketDepth++; } else if (text[i] === "]") { if (bracketDepth === 0) { return [ finding( "TYPO-PAIR-02", segment, "]", i, "Orphaned closing square bracket — a ] without a matching [.", "Add the opening [ or remove the orphaned ].", ), ]; } bracketDepth--; } i++; } return []; }, }; // --------------------------------------------------------------------------- // TYPO-PAIR-03: Unbalanced curly braces // Exclusions: formula expressions (replaced with placeholder), frontmatter // template variables (replaced with CMS placeholder by extractor) // --------------------------------------------------------------------------- const pair03: TypographyRule = { id: "TYPO-PAIR-03", family: "PAIR", tier: 2, severity: "error", check(segment) { const text = segment.text; const open = countChar(text, "{"); const close = countChar(text, "}"); if (open === close) return []; const col = open > close ? text.lastIndexOf("{") : text.lastIndexOf("}"); return [ finding( "TYPO-PAIR-03", segment, open > close ? "{" : "}", col, `Unbalanced curly braces — ${open} opening, ${close} closing.`, "Add or remove a brace to balance the pair.", ), ]; }, }; // --------------------------------------------------------------------------- // TYPO-MD-01: Unclosed inline code span (odd number of backticks) // Exclusions: fenced code blocks (removed by extractor) // --------------------------------------------------------------------------- const md01: TypographyRule = { id: "TYPO-MD-01", family: "MD", tier: 2, severity: "error", check(segment) { if (segment.source !== "body") return []; const text = segment.text; const backtickCount = countChar(text, "`"); if (backtickCount % 2 === 0) return []; const col = text.lastIndexOf("`"); return [ finding( "TYPO-MD-01", segment, "`", col, `Unclosed inline code span — odd number of backticks (${backtickCount}).`, "Add a closing backtick to match the opening.", ), ]; }, }; // --------------------------------------------------------------------------- // TYPO-MD-02: Broken link target syntax (](… without preceding [) // Only checks ]( without a preceding ] on the same segment. // Unclosed [text at end of segment is NOT checked because the link-stripping // step removes ](...) making [text indistinguishable from a complete link. // --------------------------------------------------------------------------- const MD02_PATTERN = /(?