app
Prose Lint
Public Made by Adomby adom
Deterministic house-style linter for Adom copy. Flags em-dashes and the tics that make AI-written text read as AI-written (tell-words, boilerplate cadence, filler), with line:col, plain-language suggestions, --fix for safe swaps, and a --json envelope. Markdown/code-aware: never flags a --flag in a code span, a number range, or a URL.
123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385
#!/usr/bin/env node
'use strict';
/*
* prose-lint — Adom house-style linter.
*
* Flags em-dashes and the tics that make AI-written copy read as AI-written.
* Deterministic, zero-dependency, markdown/code-aware (it will not flag a
* `--flag` in a code span, a number range in a table, or a URL).
*
* Contract (adom-cli-design): human output is line-oriented and ends with an
* OK:/ERROR: verdict + Hint: lines; `--json` emits { status, data, hints }.
* Exit 0 = clean or soft-only; exit 1 = hard findings (em-dashes) — or any
* finding under --strict; exit 2 = usage error.
*/
const fs = require('fs');
const path = require('path');
// ----------------------------------------------------------------------------
// Rules. severity: 'error' (hard — em-dashes), 'warn' (AI tells), 'info' (filler)
// A rule is { id, severity, re, msg, fix? }. `re` must be a global regex.
// `msg(m)` receives the RegExp match array. `fix` (optional) is the literal
// replacement for a safe 1:1 swap used by --fix.
// ----------------------------------------------------------------------------
// tell-words → plain-language suggestion (also drives --fix for 1:1 swaps)
const TELL_WORDS = {
'delve': 'dig in / look at',
'seamless': 'smooth / simple',
'seamlessly': 'smoothly / cleanly',
'in-depth': 'detailed / thorough',
'robust': 'solid / reliable',
'leverage': 'use',
'leverages': 'uses',
'leveraging': 'using',
'utilize': 'use',
'utilizes': 'uses',
'utilizing': 'using',
'utilise': 'use',
'boasts': 'has',
'cutting-edge': 'new / modern',
'state-of-the-art': 'modern',
'unlock': 'enable / get',
'unleash': 'release / enable',
'elevate': 'improve / raise',
'empower': 'let / help',
'empowers': 'lets / helps',
'realm': 'area / world',
'landscape': 'field / market',
'tapestry': 'mix',
'testament': 'proof / sign',
'foster': 'build / support',
'fosters': 'builds / supports',
'underscore': 'show / stress',
'underscores': 'shows / stresses',
'showcase': 'show',
'showcases': 'shows',
'showcasing': 'showing',
'embark': 'start',
'ever-evolving': 'changing',
'ever-changing': 'changing',
'fast-paced': 'fast',
'game-changer': 'big deal',
'game-changing': 'major',
'revolutionize': 'change / improve',
'revolutionizes': 'changes / improves',
'revolutionizing': 'changing / improving',
'harness': 'use',
'harnesses': 'uses',
'streamline': 'simplify / speed up',
'streamlines': 'simplifies',
'holistic': 'whole / overall',
'myriad': 'many',
'plethora': 'plenty / many',
'bustling': 'busy',
'meticulous': 'careful',
'meticulously': 'carefully',
'vibrant': 'lively',
'pivotal': 'key',
'paramount': 'critical / top',
'nestled': 'set / located',
'endeavor': 'effort / try',
'facilitate': 'help / enable',
'facilitates': 'helps / enables',
'commence': 'start',
'commences': 'starts',
'plethora of': 'many',
};
// 1:1 lowercase swaps safe for --fix (no "/" alternatives in value)
const SAFE_FIX = {
'utilize': 'use', 'utilizes': 'uses', 'utilizing': 'using', 'utilise': 'use',
'leverage': 'use', 'leverages': 'uses', 'leveraging': 'using',
'commence': 'start', 'commences': 'starts',
'facilitate': 'help', 'facilitates': 'helps',
'boasts': 'has', 'myriad': 'many', 'plethora': 'plenty',
};
function tellWordRule() {
const words = Object.keys(TELL_WORDS).filter((w) => !w.includes(' '));
// longest first so "in-depth" wins over "depth"; escape hyphens
const alt = words
.sort((a, b) => b.length - a.length)
.map((w) => w.replace(/[-]/g, '\\-'))
.join('|');
return {
id: 'tell-word',
severity: 'warn',
re: new RegExp('\\b(' + alt + ')\\b', 'gi'),
msg: (m) => {
const key = m[1].toLowerCase();
const sug = TELL_WORDS[key];
return `AI-tell word "${m[1]}"` + (sug ? ` — prefer plain language (e.g. ${sug})` : '');
},
};
}
const RULES = [
// --- hard: em/en-dash used as an em-dash in prose ---
{
id: 'em-dash',
severity: 'error',
re: /—/g,
msg: () => 'Em-dash (—) in prose — replace with a comma, colon, period, or parentheses',
},
{
id: 'ascii-em-dash',
severity: 'error',
re: / -- /g,
msg: () => 'Spaced double-hyphen ( -- ) reads as an em-dash — use a comma, colon, or period',
},
{
id: 'en-dash',
severity: 'warn',
// en-dash NOT between digits (a real number range like 2–5 is fine)
re: /(?<!\d)–(?!\d)|(?<=\s)–(?=\s)/g,
msg: () => 'En-dash (–) in prose — use a hyphen for compounds or rewrite',
},
tellWordRule(),
// --- warn: AI cadence / boilerplate structures ---
{ id: 'cadence-not-just', severity: 'warn', re: /\bit['’]?s not just\b[^.?!\n]{0,60}?,\s*it['’]?s\b/gi,
msg: () => '"it\'s not just X, it\'s Y" — classic AI cadence; state the point plainly' },
{ id: 'cadence-not-only', severity: 'warn', re: /\bnot only\b[^.?!\n]{0,60}?\bbut also\b/gi,
msg: () => '"not only X but also Y" — tighten to a plain list or sentence' },
{ id: 'cadence-in-today', severity: 'warn', re: /\bin today['’]?s\b[^.?!\n]{0,40}?\b(world|landscape|era|market|age|environment)\b/gi,
msg: () => '"In today\'s [adjective] world" opener — cut it and start with the point' },
{ id: 'cadence-when-it-comes', severity: 'warn', re: /\bwhen it comes to\b/gi,
msg: () => '"When it comes to X" — usually deletable filler' },
{ id: 'cadence-thats-where', severity: 'warn', re: /\bthat['’]?s where\b[^.?!\n]{0,40}?\bcomes? in\b/gi,
msg: () => '"That\'s where X comes in" — marketing cadence; say what it does' },
{ id: 'cadence-whether', severity: 'warn', re: /\bwhether you['’]?re\b[^.?!\n]{0,60}?\bor\b/gi,
msg: () => '"Whether you\'re X or Y" opener — reads as landing-page copy' },
{ id: 'cadence-goodbye', severity: 'warn', re: /\bsay goodbye to\b/gi,
msg: () => '"Say goodbye to X" — ad copy; describe the change directly' },
{ id: 'cadence-look-no-further', severity: 'warn', re: /\blook no further\b/gi,
msg: () => '"Look no further" — ad cliché; delete' },
{ id: 'cadence-heart-of', severity: 'warn', re: /\bat the heart of\b/gi,
msg: () => '"At the heart of X" — vague; name the actual relationship' },
{ id: 'cadence-plays-role', severity: 'warn', re: /\bplays? an? (crucial|pivotal|vital|key|significant|important) role\b/gi,
msg: () => '"plays a [crucial] role" — say what it actually does' },
{ id: 'cadence-navigate', severity: 'warn', re: /\bnavigat(e|ing) the complexit/gi,
msg: () => '"navigate the complexities of X" — filler; be concrete' },
{ id: 'cadence-result-q', severity: 'warn', re: /\bThe result\?/g,
msg: () => 'Rhetorical "The result?" fragment — write a full sentence' },
// --- info: low-signal filler / intensifiers ---
{ id: 'filler-in-order-to', severity: 'info', re: /\bin order to\b/gi, fix: 'to',
msg: () => '"in order to" → "to"' },
{ id: 'filler-a-variety-of', severity: 'info', re: /\ba (variety|number) of\b/gi,
msg: () => '"a variety/number of" — prefer "several", "some", or a count' },
{ id: 'filler-intensifier', severity: 'info', re: /\b(very|really|simply|basically|actually|just literally|truly)\b/gi,
msg: (m) => `filler intensifier "${m[1]}" — usually deletable` },
];
// ----------------------------------------------------------------------------
// Masking: build skip-ranges so we never lint inside code / URLs / front matter.
// Ranges are [start,end) offsets in the original text; offsets are preserved so
// line:col stays accurate.
// ----------------------------------------------------------------------------
function skipRanges(text) {
const ranges = [];
// whole-file opt-out
if (/prose-lint-disable-file/.test(text)) return [[0, text.length]];
const add = (re) => { let m; re.lastIndex = 0; while ((m = re.exec(text))) { ranges.push([m.index, m.index + m[0].length]); if (m[0].length === 0) re.lastIndex++; } };
// opt-out regions: prose-lint-disable ... prose-lint-enable (any comment syntax).
// A region with no matching enable runs to EOF. Lets reference docs name the
// very words we ban without tripping the linter.
{
const marker = /prose-lint-(disable|enable)/g;
let m, open = -1;
while ((m = marker.exec(text))) {
if (m[1] === 'disable' && open < 0) open = m.index;
else if (m[1] === 'enable' && open >= 0) { ranges.push([open, m.index + m[0].length]); open = -1; }
}
if (open >= 0) ranges.push([open, text.length]);
}
// leading YAML front matter
const fm = /^---\n[\s\S]*?\n---/;
const fmm = fm.exec(text); if (fmm) ranges.push([0, fmm[0].length]);
add(/```[\s\S]*?```/g); // fenced code
add(/~~~[\s\S]*?~~~/g);
add(/<pre[\s\S]*?<\/pre>/gi);
add(/<code[\s\S]*?<\/code>/gi);
add(/`[^`\n]*`/g); // inline code
add(/<[^>\n]+>/g); // html tags (attributes, not prose)
add(/\]\(([^)\s]+)/g); // markdown link/image target (url only)
add(/^\[[^\]]+\]:\s.*$/gm); // reference-style link defs
add(/https?:\/\/[^\s)]+/g); // bare URLs
add(/[\w.+-]+@[\w.-]+\.\w+/g); // emails
add(/^\s{0,3}([-*_])(?:\s*\1){2,}\s*$/gm); // hr / thematic break (--- *** ___)
return ranges;
}
function inSkip(idx, ranges) { for (const [s, e] of ranges) if (idx >= s && idx < e) return true; return false; }
function offsetToLineCol(text, idx) {
let line = 1, col = 1;
for (let i = 0; i < idx; i++) { if (text[i] === '\n') { line++; col = 1; } else col++; }
return [line, col];
}
function lint(text) {
const ranges = skipRanges(text);
const findings = [];
for (const rule of RULES) {
let m; rule.re.lastIndex = 0;
while ((m = rule.re.exec(text))) {
if (m[0].length === 0) { rule.re.lastIndex++; continue; }
if (inSkip(m.index, ranges)) continue;
const [line, col] = offsetToLineCol(text, m.index);
findings.push({ rule: rule.id, severity: rule.severity, line, col, match: m[0].trim(), message: rule.msg(m) });
}
}
findings.sort((a, b) => a.line - b.line || a.col - b.col);
return findings;
}
// deterministic 1:1 autofix (safe swaps + "in order to" only)
function applyFix(text) {
const ranges = skipRanges(text);
const edits = [];
const push = (re, repl, keepCase) => {
let m; re.lastIndex = 0;
while ((m = re.exec(text))) {
if (inSkip(m.index, ranges)) continue;
let r = repl;
if (keepCase && /^[A-Z]/.test(m[0])) r = r[0].toUpperCase() + r.slice(1);
edits.push([m.index, m.index + m[0].length, r]);
}
};
for (const [w, r] of Object.entries(SAFE_FIX)) push(new RegExp('\\b' + w.replace(/-/g, '\\-') + '\\b', 'gi'), r, true);
push(/\bin order to\b/gi, 'to', true);
edits.sort((a, b) => a[0] - b[0]);
let out = '', last = 0;
for (const [s, e, r] of edits) { if (s < last) continue; out += text.slice(last, s) + r; last = e; }
out += text.slice(last);
return { text: out, count: edits.length };
}
// ----------------------------------------------------------------------------
// CLI
// ----------------------------------------------------------------------------
function parseArgs(argv) {
const o = { files: [], json: false, strict: false, fix: false, quiet: false };
for (let i = 0; i < argv.length; i++) {
const a = argv[i];
if (a === '--json') o.json = true;
else if (a === '--strict') o.strict = true;
else if (a === '--fix') o.fix = true;
else if (a === '--quiet' || a === '-q') o.quiet = true;
else if (a === '--stdin' || a === '-') o.stdin = true;
else if (a === '--help' || a === '-h') o.help = true;
else if (a === '--version' || a === '-V') o.version = true;
else if (a.startsWith('-')) { o.badFlag = a; }
else o.files.push(a);
}
return o;
}
const HELP = `prose-lint — Adom house-style linter (no em-dashes, no AI slop)
USAGE
prose-lint [FILE...] lint files (markdown/html/txt)
prose-lint --stdin < file lint stdin
echo "text" | prose-lint lint stdin (auto when no files)
OPTIONS
--fix apply safe 1:1 word swaps in place (utilize→use, "in order to"→to)
--strict exit non-zero on ANY finding (default: only em-dashes fail)
--json machine-readable { status, data, hints[] }
--quiet, -q suppress the per-finding lines (summary + verdict only)
--version print version
--help this help
EXIT 0 clean/soft-only · 1 hard findings (or any under --strict) · 2 usage`;
const SEV_ORDER = { error: 0, warn: 1, info: 2 };
const SEV_LABEL = { error: 'HARD ', warn: 'warn ', info: 'info ' };
function main() {
const o = parseArgs(process.argv.slice(2));
if (o.help) { process.stdout.write(HELP + '\n'); process.exit(0); }
if (o.version) {
let v = '0.0.0';
try { v = JSON.parse(fs.readFileSync(path.join(__dirname, '..', 'package.json'), 'utf8')).version; } catch (e) {}
process.stdout.write('prose-lint ' + v + '\n'); process.exit(0);
}
if (o.badFlag) { process.stderr.write('ERROR: unknown flag ' + o.badFlag + '\nHint: run `prose-lint --help`\n'); process.exit(2); }
// gather inputs
const inputs = []; // { name, text }
if (o.files.length) {
for (const f of o.files) {
try { inputs.push({ name: f, text: fs.readFileSync(f, 'utf8') }); }
catch (e) { process.stderr.write('ERROR: cannot read ' + f + ' (' + e.code + ')\n'); process.exit(2); }
}
} else {
let text = '';
try { text = fs.readFileSync(0, 'utf8'); } catch (e) {}
if (!text && process.stdin.isTTY) { process.stdout.write(HELP + '\n'); process.exit(0); }
inputs.push({ name: '<stdin>', text });
}
// --fix mode
if (o.fix) {
const results = [];
for (const inp of inputs) {
const { text, count } = applyFix(inp.text);
if (inp.name !== '<stdin>' && count > 0) fs.writeFileSync(inp.name, text);
else if (inp.name === '<stdin>') process.stdout.write(text);
results.push({ file: inp.name, fixed: count });
}
const total = results.reduce((n, r) => n + r.fixed, 0);
if (o.json) { process.stdout.write(JSON.stringify({ status: 'ok', data: { fixed: total, files: results }, hints: [] }) + '\n'); }
else {
for (const r of results) if (r.file !== '<stdin>') process.stdout.write(`fixed ${r.fixed} in ${r.file}\n`);
process.stdout.write((total ? 'OK: applied ' + total + ' safe swap(s)' : 'OK: nothing to auto-fix') +
'\nHint: --fix only handles 1:1 word swaps; em-dashes and cadence need a human rewrite. Re-run without --fix to see what remains.\n');
}
process.exit(0);
}
// lint mode
let all = [];
const perFile = [];
for (const inp of inputs) {
const f = lint(inp.text);
perFile.push({ file: inp.name, findings: f });
all = all.concat(f.map((x) => ({ file: inp.name, ...x })));
}
const counts = { error: 0, warn: 0, info: 0 };
for (const f of all) counts[f.severity]++;
if (o.json) {
const hard = counts.error;
const status = (o.strict ? all.length : hard) ? 'error' : 'ok';
process.stdout.write(JSON.stringify({
status,
data: { counts, total: all.length, files: perFile },
hints: hard ? [{ level: 'error', code: 'EM_DASH', message: `${hard} hard finding(s) — em-dashes/AI-tells must go before shipping copy` }] : [],
}) + '\n');
process.exit((o.strict ? all.length : hard) ? 1 : 0);
}
if (!o.quiet) {
for (const f of all.sort((a, b) => (a.file < b.file ? -1 : a.file > b.file ? 1 : 0) || a.line - b.line || a.col - b.col)) {
process.stdout.write(`${f.file}:${f.line}:${f.col} ${SEV_LABEL[f.severity]} [${f.rule}] ${f.message}\n`);
}
if (all.length) process.stdout.write('\n');
}
if (all.length === 0) {
process.stdout.write('OK: prose is clean — no em-dashes or AI-tells found\n');
process.exit(0);
}
const parts = [];
if (counts.error) parts.push(counts.error + ' hard');
if (counts.warn) parts.push(counts.warn + ' tell');
if (counts.info) parts.push(counts.info + ' filler');
const verdict = counts.error ? 'ERROR' : 'OK';
process.stdout.write(`${verdict}: ${parts.join(', ')} issue(s) across ${inputs.length} file(s)\n`);
if (counts.error) process.stdout.write('Hint: em-dashes and " -- " are the hard rule — swap for a comma, colon, or period, then re-run.\n');
if (counts.warn) process.stdout.write('Hint: "tell" lines are advisory; rewrite the ones that make the copy sound generated.\n');
process.stdout.write('Hint: `prose-lint --fix` applies the safe word swaps automatically.\n');
process.exit((o.strict ? all.length : counts.error) ? 1 : 0);
}
main();