#!/usr/bin/env node /** * lint-voice.mjs — deterministic voice linter for Oleg's texts. * * Checks for AI/corporate artifacts, wrong typography, and banned patterns * that make text sound like a model rather than Oleg. * * Usage: * node lint-voice.mjs "text to check" * echo "text" | node lint-voice.mjs * node lint-voice.mjs < input.txt * * Exit codes: * 0 — clean (no ERRORs; WARNs alone do not fail) * 1 — ERRORs found (agent must fix and re-run) * 2 — usage error */ // [level, id, pattern, message] // level: "ERROR" = must fix before delivery; "WARN" = check in context, may be OK const CHECKS = [ // ── Typography ───────────────────────────────────────────────────────────── ["ERROR", "EM_DASH", /—/g, "Em-dash (—). Replace with colon, comma, or rephrase as separate sentence."], ["ERROR", "SMART_QUOTES", /[""„«»]/g, 'Smart/typographic double quote. Use straight " or rephrase.'], ["WARN", "SMART_APOSTROPHE", /[''`]/g, "Smart/curly apostrophe or backtick. Use straight apostrophe '."], // ── AI / corporate words ─────────────────────────────────────────────────── ["ERROR", "AI_LEVERAGE", /\bleverag(?:e|es|ed|ing)\b/gi, '"leverage" — AI/corporate word. Replace with plain verb (use, apply, build on).'], ["ERROR", "AI_ROBUST", /\brobust\b/gi, '"robust" — AI/corporate word. Replace with specific description.'], ["ERROR", "AI_SEAMLESS", /\bseamless(?:ly)?\b/gi, '"seamless" — AI/corporate word. Cut or replace with plain description.'], ["ERROR", "AI_PASSIONATE", /\bpassionat(?:e|ely)\b|\bmy passion\b/gi, '"passionate/passion" — AI/corporate word. Replace with direct statement.'], ["ERROR", "AI_THRILLED", /\bthrilled\b/gi, '"thrilled" — AI word. Replace with direct statement.'], ["ERROR", "AI_DELVE", /\bdelv(?:e|ing|ed)\b/gi, '"delve" — AI word. Replace with explore / look into / dig.'], ["ERROR", "AI_TAPESTRY", /\btapestry\b/gi, '"tapestry" — AI word. Remove or replace.'], ["ERROR", "AI_EMPOWER", /\bempower(?:s|ed|ing)?\b/gi, '"empower" — AI/corporate word. Replace with plain verb.'], ["ERROR", "AI_STREAMLINE", /\bstreamline[ds]?\b|\bstreamlining\b/gi, '"streamline" — AI/corporate word. Replace with plain verb.'], ["ERROR", "AI_ACTIONABLE", /\bactionable\b/gi, '"actionable" — AI/corporate word. Replace with plain description.'], ["ERROR", "AI_UNLOCK", /\bunlock(?:s|ed|ing)?\b/gi, '"unlock" — AI/corporate word. Replace with plain verb.'], ["ERROR", "AI_CUTTING_EDGE", /cutting[- ]edge/gi, '"cutting-edge" — AI/corporate phrase. Replace with specific description.'], ["WARN", "AI_INNOVATIVE", /\binnovativ(?:e|ely)\b|\binnovation\b/gi, '"innovative/innovation" — often AI/corporate filler. Replace with specific if used generically.'], // ── Sentence openers (capitalised opener form) ──────────────────────────── // Matches word at line start OR after terminal punctuation + whitespace ["ERROR", "OPENER_FURTHERMORE", /(?:^|(?<=[.!?])[ \t]+)Furthermore[,\s]/gm, '"Furthermore" opener. Cut — connection should come from content, not connective.'], ["ERROR", "OPENER_MOREOVER", /(?:^|(?<=[.!?])[ \t]+)Moreover[,\s]/gm, '"Moreover" opener. Cut — restructure.'], ["ERROR", "OPENER_ADDITIONALLY", /(?:^|(?<=[.!?])[ \t]+)Additionally[,\s]/gm, '"Additionally" opener. Cut — restructure without connective.'], ["ERROR", "OPENER_ULTIMATELY", /(?:^|(?<=[.!?])[ \t]+)Ultimately[,\s]/gm, '"Ultimately" opener. Cut — restructure.'], ["ERROR", "OPENER_HOWEVER", /(?:^|(?<=[.!?])[ \t]+)However[,\s]/gm, '"However" opener. Cut — use contrast from content, not a gluing connective.'], ["ERROR", "OPENER_IMPORTANTLY", /(?:^|(?<=[.!?])[ \t]+)Importantly[,\s]/gm, '"Importantly" opener — filler frame. Cut.'], ["ERROR", "OPENER_THAT_SAID", /(?:^|(?<=[.!?])[ \t]+)That said[,\s]/gm, '"That said" opener. Cut — restructure.'], ["ERROR", "OPENER_IN_CONCLUSION", /in conclusion[,\s]/gi, '"In conclusion" — empty closing summary. Cut entirely.'], ["ERROR", "OPENER_OVERALL", /(?:^|(?<=[.!?])[ \t]+)Overall[,\s]/gm, '"Overall" opener — empty summary signal. Cut.'], ["WARN", "OPENER_SO_COMMA", /(?:^|(?<=[.!?])[ \t]+)So[,\s]/gm, '"So," opener. Oleg cuts these — connection should come from content.'], // ── Filler frames ────────────────────────────────────────────────────────── ["ERROR", "FILLER_WORTH_NOTING", /it'?s worth noting/gi, '"It\'s worth noting" — filler frame. Cut, say the thing directly.'], ["ERROR", "FILLER_IMPORTANT_REMEMBER", /it'?s important to remember/gi, '"It\'s important to remember" — filler frame. Cut.'], ["ERROR", "FILLER_I_AM_WRITING", /\bI am writing to\b/gi, '"I am writing to" — generic opener. Replace with direct lead.'], ["ERROR", "FILLER_IN_TODAYS", /in today'?s\b/gi, '"In today\'s..." — AI/generic opener. Cut.'], // ── AI structural patterns ───────────────────────────────────────────────── ["ERROR", "PATTERN_ITS_NOT_JUST", /it'?s not just/gi, '"it\'s not just X, it\'s Y" construction. Rephrase as direct statement.'], ["WARN", "PATTERN_NOT_X_BUT", /\bnot \w+(?:\s+\w+){0,3},?\s+but\b/gi, '"not X, but Y" — often AI phrasing. Rephrase if rhetorical.'], ["ERROR", "PATTERN_HONEST_FRAMING", /honest framing|one thing to flag|I want to be honest about/gi, '"honest framing / one thing to flag" template. Not Oleg\'s pattern — remove.'], // ── AI closings ──────────────────────────────────────────────────────────── ["ERROR", "CLOSING_LOOKING_FORWARD", /looking forward to/gi, '"Looking forward to" closing. Replace e.g. "Open to chat if it\'s a fit."'], ["ERROR", "CLOSING_HAPPY_TO", /\bhappy to\b/gi, '"happy to" — AI phrasing. Replace with plain statement.'], // ── Warn-only borderline cases ───────────────────────────────────────────── ["WARN", "WARN_NAVIGATE", /\bnavigat(?:e|ing|ed)\b/gi, '"navigate" — check if metaphorical AI-speak ("navigate challenges"). OK if literal.'], ["WARN", "WARN_LANDSCAPE", /\blandscape\b/gi, '"landscape" — check if metaphorical AI-speak. OK if literal/technical.'], ]; function lineOf(text, pos) { return text.slice(0, pos).split("\n").length; } function getContext(text, start, end, window = 60) { const from = Math.max(0, start - window); const to = Math.min(text.length, end + window); let snippet = text.slice(from, to).replace(/\s+/g, " ").trim(); if (from > 0) snippet = "…" + snippet; if (to < text.length) snippet = snippet + "…"; return snippet; } function runChecks(text) { const findings = []; for (const [level, id, pattern, message] of CHECKS) { // Reset lastIndex before each use (patterns are defined with /g) pattern.lastIndex = 0; let m; while ((m = pattern.exec(text)) !== null) { findings.push({ level, id, line: lineOf(text, m.index), context: getContext(text, m.index, m.index + m[0].length), message, }); // Prevent infinite loops on zero-width matches if (m[0].length === 0) pattern.lastIndex++; } } findings.sort((a, b) => a.line - b.line || a.id.localeCompare(b.id)); return findings; } async function readStdin() { const chunks = []; for await (const chunk of process.stdin) chunks.push(chunk); return Buffer.concat(chunks).toString("utf8"); } async function main() { let text; if (process.argv.length > 2) { text = process.argv.slice(2).join(" "); } else if (!process.stdin.isTTY) { text = await readStdin(); } else { process.stderr.write('Usage: node lint-voice.mjs "text" OR pipe text via stdin\n'); process.exit(2); } if (!text.trim()) { process.stdout.write("✓ CLEAN — empty input.\n"); process.exit(0); } const findings = runChecks(text); const errors = findings.filter(f => f.level === "ERROR"); const warns = findings.filter(f => f.level === "WARN"); if (findings.length === 0) { process.stdout.write("✓ CLEAN — no issues found.\n"); process.exit(0); } process.stdout.write(`Found ${findings.length} issue(s): ${errors.length} ERROR, ${warns.length} WARN\n\n`); for (const f of findings) { const action = f.level === "ERROR" ? "Fix" : "Note"; process.stdout.write(`[${f.level}] ${f.id} — line ${f.line}\n`); process.stdout.write(` Context: "${f.context}"\n`); process.stdout.write(` ${action}: ${f.message}\n\n`); } process.stdout.write("---\n"); if (errors.length > 0) { process.stdout.write(`EXIT 1 — ${errors.length} error(s) present. Fix and re-run.\n`); process.exit(1); } else { process.stdout.write("EXIT 0 — no errors (warnings only). Review WARNs if applicable.\n"); process.exit(0); } } main();