mirror of
https://github.com/conorbronsdon/avoid-ai-writing.git
synced 2026-09-19 01:32:11 +08:00
f666087e52
* feat: optional --style house-style layer (config-driven, points at Vale) Implements the config-driven design recorded in #88, which #76 and the fork review converged on. No bundled guides: the layer applies register/voice directives and removes AI tells on top of whatever mechanics you enforce. --style ./house.json applies a user-supplied JSON config (register + mechanics) and scripts/check-style.js verifies the CHECKABLE mechanics deterministically: quotes and latinAbbrev gate the exit code (0 clean / 1 hard / 2 tool error), headings, emDash and spellNumbersUpTo are advisory, serialComma is never checked. The docs scope the verb accordingly rather than claiming "mechanics are verified" flatly. examples/ ships two generic, guide-neutral starters plus the schema, and links #88 for the licensing rule. A bare --style "APA" is a best-effort fallback, not a feature: SKILL.md instructs the model to print a no-compliance-claim status line and not to reproduce the guide's text. Those are instructions rather than checked rules, and the docs say so instead of asserting the behaviour as fact. --style is listed in the SKILL.md invocation line and has an explicit composition rule with --voice/--context, since the file specifies composition for every other axis. README: the "House style is a different job" section said "There's no --style input", which this makes false. The first paragraph now describes the config input and reconciles with the heading (enforcing a PUBLISHED guide is the different job). The Vale paragraph and the heading anchor are unchanged. cursor-rules: the ported rule would have told a Cursor user to run scripts/check-style.js and read examples/, neither of which ships in the .mdc. Two anchored spans in sync-cursor-rules.sh rewrite those the way span 3 already handles detector/validate.js, so the port stays self-contained. Worth noting the drift guard only covers edits to anchored spans, not newly added SKILL.md sections that introduce repo references. The checker went through three adversarial review rounds, and each round found the previous round's fix had introduced a new way to hard-fail a correct document or to miss a real one. The tests pin all of them: markdown link titles, reference definitions (including escaped quotes in a title), HTML attribute values, nested and tilde fences, BOM'd frontmatter, URLs containing parentheses, parentheticals that wrap or span a code block, and a stray "(" that must not suppress later findings, and prose containing a comparison ("n<N ... >") that must not be mistaken for an HTML tag. Masking runs refdef -> links -> tags, in that order, because masking a link destination first makes an ordinary "[a](url): text" line look like a reference definition. Link masking walks to the matching paren and scans linearly; the regex form was quadratic (22.7s on a hostile line, now 4ms). resolveConfig decides path-vs-name by shape so a cwd file can't shadow a bundled example, the CLI parses by position, a config with "mechanics": null or [] exits 2 rather than a false-green 0, and advisory findings print instead of hiding behind --json. Version 3.22.3 -> 3.23.0 in SKILL.md frontmatter, package.json and plugin.json; CHANGELOG entry under a dated heading; plugin and cursor copies regenerated. No detector changes: catalog counts unchanged (61 / 112). 32 tests in scripts/check-style.test.js under npm test; detector-test.yml path filters extended to scripts/** and examples/**. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> * style: mask indented code, tighten frontmatter open, announce the resolved mode Review fixes applied on top of the submitted branch: - Indented code blocks (4+ spaces / tab after a blank line, outside a list) are masked, so pasted terminal output can't hard-fail a correct document. Lazy continuation and list-item content stay checked; a code block nested in a list item needs a fence. Was the largest undocumented false positive. - A document opening with a thematic break is no longer read as frontmatter (which silently swallowed everything to the next ---). - The bare-name traversal regex was dead code its test never reached; a bare name is a single path segment by construction, so the guard is now a comment stating that argument, and a test pins dot-bearing names. - Config runs open by naming the resolved config, mirroring the fallback's status line, so which of the two modes ran is always explicit in output. - Known-limitations lists gain the double-backtick span and next-line reference-definition title gaps; examples/README.md joins the self-scan budgets; CHANGELOG updated; cursor-rule anchors follow the SKILL.md edit. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> --------- Co-authored-by: Claude Opus 4.8 <noreply@anthropic.com> Co-authored-by: Conor Bronsdon <120674402+conorbronsdon@users.noreply.github.com>
283 lines
15 KiB
JavaScript
283 lines
15 KiB
JavaScript
#!/usr/bin/env node
|
|
/*
|
|
* check-style.js — deterministic conformance check for a user-supplied house-style
|
|
* config's MECHANICS. It verifies "did the output apply the config it was given"; it
|
|
* does not judge register (that's the model's job). No bundled guides — the rules come
|
|
* entirely from the config you pass.
|
|
*
|
|
* Usage: node scripts/check-style.js <file.md> --config <config.json|name> [--json]
|
|
* Exit codes: 0 clean, 1 a hard violation, 2 a tool/usage error (missing or unreadable
|
|
* config, no mechanics). Unrecognized config keys/values are surfaced as warnings.
|
|
*
|
|
* Hard-checkable mechanics: quotes, latinAbbrev. Heading case, em-dash rate, and number
|
|
* spelling are ADVISORY (heading case can't be verified deterministically — proper nouns
|
|
* make sentence and title case ambiguous). serialComma is declared but model-applied only.
|
|
* See examples/README.md. Skipped before checking: frontmatter (only when it closes), code
|
|
* (fenced and inline), and markdown link destinations, link titles, and reference-definition
|
|
* tails, since a link title is delimited with straight quotes as SYNTAX.
|
|
* HTML tags and their attribute values are masked too. Known limitations: straight
|
|
* feet/inch primes (5'11") after a digit are carved out; an unclosed or multi-line HTML tag
|
|
* still registers, as do quotes inside an HTML comment; and the latinAbbrev parenthesis
|
|
* carve-out tracks depth across wrapped lines but resets at a paragraph break, so an
|
|
* unclosed "(" disables that rule for the rest of its paragraph. A double-backtick code
|
|
* span whose body contains a backtick leaks its body to the quote checks; a reference
|
|
* definition with its title on the following line is read as prose; and indented code
|
|
* inside a LIST item is treated as the item's prose (fence it to skip it).
|
|
*/
|
|
'use strict';
|
|
|
|
const fs = require('fs');
|
|
const path = require('path');
|
|
|
|
/**
|
|
* Resolve a --config argument to a file path. The decision is by SHAPE, not by what
|
|
* happens to exist in the working directory: an arg with a path separator or a .json
|
|
* suffix is a path (used as-is if it exists, else null); anything else is a bare name
|
|
* resolved against examples/<name>.json. Deciding by shape stops a file named "technical"
|
|
* sitting in the cwd from shadowing examples/technical.json. Bare names can't traverse
|
|
* out of examples/. Null if nothing matches.
|
|
*/
|
|
function resolveConfig(arg) {
|
|
if (!arg) return null;
|
|
if (/[\\/]/.test(arg) || /\.json$/i.test(arg)) return fs.existsSync(arg) ? arg : null;
|
|
const q = arg.trim().toLowerCase();
|
|
// No traversal guard needed: a bare name has no separator (anything with one took the
|
|
// path branch above), so it joins as a single segment inside examples/ by construction.
|
|
const direct = path.join(__dirname, '..', 'examples', `${q}.json`);
|
|
return fs.existsSync(direct) ? direct : null;
|
|
}
|
|
|
|
// Known mechanics keys and their allowed values (for config validation).
|
|
const KNOWN = {
|
|
quotes: ['straight', 'curly'],
|
|
headings: ['sentence', 'title'],
|
|
latinAbbrev: ['parentheses', 'never', 'any'],
|
|
emDash: ['sparing', 'deliberate'],
|
|
spellNumbersUpTo: 'number',
|
|
serialComma: 'boolean',
|
|
};
|
|
|
|
const SMALL = new Set(['a', 'an', 'and', 'as', 'at', 'but', 'by', 'for', 'in', 'of', 'on', 'or', 'the', 'to', 'with', 'vs', 'nor', 'so', 'yet']);
|
|
const majorWords = (h) => h.replace(/[*_`]/g, '').trim().split(/\s+/).slice(1)
|
|
.map((w) => w.replace(/[^A-Za-z]/g, ''))
|
|
.filter((b) => b && b !== b.toUpperCase() && !SMALL.has(b.toLowerCase())); // drop acronyms + minor words
|
|
const isTitleCase = (h) => majorWords(h).filter((b) => /^[A-Z]/.test(b)).length >= 2;
|
|
const looksSentenceCase = (h) => { const w = majorWords(h); return w.length >= 1 && w.every((b) => /^[a-z]/.test(b)); };
|
|
|
|
/**
|
|
* Blank a markdown link destination and title, `](...)`, keeping the link text. Walks to
|
|
* the MATCHING paren so a nested one in the URL (a Wikipedia disambiguation link, say)
|
|
* doesn't terminate it early and leave a stray `)` behind to corrupt the paren balance.
|
|
* Scans linearly: a regex here is quadratic on `](`-heavy input. An unclosed `](` is not a
|
|
* link, so the rest of the line is left alone rather than swallowed.
|
|
*/
|
|
function maskLinks(s) {
|
|
let out = '', i = 0;
|
|
for (;;) {
|
|
const j = s.indexOf('](', i);
|
|
if (j < 0) return out + s.slice(i);
|
|
let depth = 0, k = j + 1, closed = false;
|
|
for (; k < s.length; k += 1) {
|
|
if (s[k] === '(') depth += 1;
|
|
else if (s[k] === ')') { depth -= 1; if (depth === 0) { k += 1; closed = true; break; } }
|
|
}
|
|
if (!closed) return out + s.slice(i);
|
|
out += s.slice(i, j + 1); // keep through the ']'
|
|
i = k;
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Blank the tail of a reference definition (`[1]: https://x "Title"`), whose title also
|
|
* uses straight quotes as syntax. Requires a destination and an optional title and nothing
|
|
* else, so ordinary prose that happens to start `[sic]: he said "hi"` is left alone. Title
|
|
* bodies allow backslash escapes, which is how a title legally contains its own delimiter.
|
|
* MUST run before maskLinks: masking `[a](url): x` down to `[a]: x` would otherwise make an
|
|
* ordinary link-led definition list line look like a reference definition and blank it.
|
|
*/
|
|
const TITLE = '"(?:\\\\.|[^"\\\\])*"|\'(?:\\\\.|[^\'\\\\])*\'|\\((?:\\\\.|[^)\\\\])*\\)';
|
|
const REF_DEF = new RegExp(`^(\\s*\\[[^\\]]+\\]:\\s*)(?:<[^>]*>|\\S+)(?:\\s+(?:${TITLE}))?\\s*$`);
|
|
const maskRefDef = (s) => s.replace(REF_DEF, '$1');
|
|
|
|
/**
|
|
* Blank HTML tags, whose attribute values are straight-quoted as syntax. The shape follows
|
|
* CommonMark's raw-HTML grammar (tag name, then attribute-shaped pairs) rather than
|
|
* "<letter ... >": the loose form swallowed ordinary prose containing a comparison, such as
|
|
* `For n<N, the "tail" sum > epsilon`, hiding real violations between the two brackets.
|
|
*/
|
|
const maskTags = (s) => s.replace(
|
|
/<\/?[a-zA-Z][a-zA-Z0-9-]*(?:\s+[a-zA-Z_:][\w:.-]*(?:\s*=\s*(?:"[^"]*"|'[^']*'|[^\s"'=<>`]+))?)*\s*\/?>/g, '');
|
|
|
|
/** Returns { hard, advisory, warnings } for the config's mechanics; register is not checked. */
|
|
function check(text, mechanics) {
|
|
const m = mechanics || {};
|
|
const warnings = [];
|
|
for (const [k, v] of Object.entries(m)) {
|
|
if (!(k in KNOWN)) { warnings.push({ rule: 'unknown-key', detail: k }); continue; }
|
|
const spec = KNOWN[k];
|
|
if (Array.isArray(spec)) { if (!spec.includes(v)) warnings.push({ rule: 'unknown-value', detail: `${k}: ${JSON.stringify(v)}` }); }
|
|
else if (typeof v !== spec) warnings.push({ rule: 'unknown-value', detail: `${k}: ${JSON.stringify(v)} (expected ${spec})` });
|
|
}
|
|
|
|
const lines = text.replace(/^\uFEFF/, '').split('\n'); // a BOM would hide the frontmatter
|
|
const bare = (s) => s.replace(/\r$/, '');
|
|
// Frontmatter: skip a leading --- ... --- block only when it actually closes.
|
|
let fmEnd = -1;
|
|
// Only an opener with content on the next line is frontmatter: a document that OPENS
|
|
// with a thematic break (`---`, blank line after) would otherwise be swallowed to the
|
|
// next `---` anywhere in the file, silently hiding everything between.
|
|
if (bare(lines[0]) === '---' && lines.length > 1 && !/^\s*$/.test(bare(lines[1]))) {
|
|
for (let k = 1; k < lines.length; k += 1) { if (bare(lines[k]) === '---') { fmEnd = k; break; } }
|
|
}
|
|
// Fences track their character and length, so an inner ``` doesn't close an outer ````
|
|
// and a ~~~ doesn't close a ``` block. A bare toggle desyncs on a nested fence and then
|
|
// checks the code inside it as prose, which is a hard violation on a correct document.
|
|
let inFence = false, fenceChar = '', fenceLen = 0;
|
|
// Indented code (4+ spaces or a tab) opens only after a blank line and outside a list:
|
|
// a 4-space line directly under a paragraph is lazy continuation (prose), and inside a
|
|
// list item it is the item's own content (prose), so both stay checked. List tracking is
|
|
// approximate (a marker line enters list context, a flush-left non-marker line leaves
|
|
// it); a code block nested inside a list item needs a fence to be skipped.
|
|
let inIndent = false, prevBlank = true, listCtx = false;
|
|
// A paragraph break is a blank line in the ORIGINAL that is not inside frontmatter or a
|
|
// fence. Masked-empty lines aren't breaks (that would cut a parenthetical at a code
|
|
// block), and a blank line inside a fence isn't either.
|
|
const paraBreak = [];
|
|
const prose = lines.map((l, i) => {
|
|
if (i <= fmEnd) { paraBreak.push(false); return ''; }
|
|
const b = bare(l);
|
|
const fm = b.match(/^\s*(`{3,}|~{3,})(.*)$/);
|
|
if (fm) {
|
|
const ch = fm[1][0], len = fm[1].length;
|
|
if (!inFence) { inFence = true; fenceChar = ch; fenceLen = len; }
|
|
else if (ch === fenceChar && len >= fenceLen && /^\s*$/.test(fm[2])) inFence = false;
|
|
paraBreak.push(false);
|
|
inIndent = false; prevBlank = false;
|
|
return '';
|
|
}
|
|
if (inFence) { paraBreak.push(false); prevBlank = false; return ''; }
|
|
const blank = /^\s*$/.test(b);
|
|
const ind4 = /^(?: {4}|\t)/.test(b);
|
|
if (blank) inIndent = false;
|
|
else if (!inIndent && ind4 && prevBlank && !listCtx) inIndent = true;
|
|
else if (!ind4) inIndent = false;
|
|
const isCode = !blank && inIndent;
|
|
if (!blank && !isCode) {
|
|
if (/^ {0,3}(?:[-*+]|\d{1,9}[.)])(?:\s|$)/.test(b)) listCtx = true;
|
|
else if (/^\S/.test(b)) listCtx = false;
|
|
}
|
|
prevBlank = blank;
|
|
paraBreak.push(blank);
|
|
if (isCode) return '';
|
|
// Strip inline code, then link destinations and reference-definition tails. Markdown
|
|
// link titles are delimited with STRAIGHT quotes as syntax, so leaving them in makes
|
|
// quotes:curly hard-fail an ordinary titled link on a correct document.
|
|
return maskTags(maskLinks(maskRefDef(b.replace(/`+[^`]*`+/g, ''))));
|
|
});
|
|
const joined = prose.join('\n');
|
|
const words = (joined.match(/\b\w+\b/g) || []).length;
|
|
const hard = [];
|
|
const advisory = [];
|
|
const heads = prose.map((l, i) => [i + 1, l.match(/^#{1,6}\s+(.*)$/)]).filter(([, h]) => h);
|
|
|
|
if (m.quotes === 'straight') {
|
|
prose.forEach((l, i) => { if (/[“”‘’]/.test(l)) hard.push({ line: i + 1, rule: 'quotes-should-be-straight' }); });
|
|
} else if (m.quotes === 'curly') {
|
|
prose.forEach((l, i) => {
|
|
const c = l.replace(/(\d)['"]/g, '$1'); // carve out feet/inch primes (5'11")
|
|
if (/"/.test(c)) hard.push({ line: i + 1, rule: 'double-quote-should-be-curly' });
|
|
if (/[A-Za-z]'[A-Za-z]|[A-Za-z]'(?!\w)|(^|\s)'/.test(c)) hard.push({ line: i + 1, rule: 'apostrophe-should-be-curly' });
|
|
});
|
|
}
|
|
|
|
// Heading case is ADVISORY: proper nouns make sentence vs title case ambiguous, so it
|
|
// can't be verified deterministically without false positives on ordinary headings.
|
|
if (m.headings === 'sentence') {
|
|
heads.forEach(([ln, h]) => { if (isTitleCase(h[1])) advisory.push({ line: ln, rule: 'heading-may-need-sentence-case' }); });
|
|
} else if (m.headings === 'title') {
|
|
heads.forEach(([ln, h]) => { if (looksSentenceCase(h[1])) advisory.push({ line: ln, rule: 'heading-may-need-title-case' }); });
|
|
}
|
|
|
|
if (m.latinAbbrev === 'parentheses' || m.latinAbbrev === 'never') {
|
|
// Paren depth carries across lines, so a parenthetical that wraps keeps its carve-out,
|
|
// but RESETS at a blank line: a prose parenthetical can't span a paragraph, and without
|
|
// the reset one stray "(" (a ":(" smiley) would suppress every later finding in the
|
|
// document. That trades an unbounded silent false negative for a paragraph-wide one.
|
|
let depth = 0;
|
|
prose.forEach((l, i) => {
|
|
if (paraBreak[i]) { depth = 0; return; }
|
|
const re = /\b(e\.g\.|i\.e\.)/gi;
|
|
let mm;
|
|
while ((mm = re.exec(l)) !== null) {
|
|
if (m.latinAbbrev === 'never') { hard.push({ line: i + 1, rule: 'latin-abbrev-not-allowed' }); continue; }
|
|
const before = l.slice(0, mm.index);
|
|
const at = depth + (before.match(/\(/g) || []).length - (before.match(/\)/g) || []).length;
|
|
if (at <= 0) hard.push({ line: i + 1, rule: 'latin-abbrev-outside-parens' });
|
|
}
|
|
depth = Math.max(0, depth + (l.match(/\(/g) || []).length - (l.match(/\)/g) || []).length);
|
|
});
|
|
}
|
|
|
|
if (m.emDash === 'sparing') {
|
|
const em = (joined.match(/—/g) || []).length;
|
|
if (em > Math.floor(words / 1000)) advisory.push({ rule: 'em-dash-rate', detail: `${em} in ${words} words` });
|
|
}
|
|
|
|
if (typeof m.spellNumbersUpTo === 'number') {
|
|
prose.forEach((l, i) => {
|
|
if (/^\s*([-*]|\d+\.)\s/.test(l)) return;
|
|
const nums = (l.match(/(?<![\w.$:])\d{1,3}(?![\w.%:])/g) || []).filter((n) => +n <= m.spellNumbersUpTo);
|
|
if (nums.length) advisory.push({ line: i + 1, rule: 'number-may-need-spelling', detail: nums.join(', ') });
|
|
});
|
|
}
|
|
|
|
return { hard, advisory, warnings };
|
|
}
|
|
|
|
module.exports = { check, resolveConfig };
|
|
|
|
if (require.main === module) {
|
|
// Parse by position, not by value: the old `a !== cfgArg` filter made a file whose name
|
|
// equalled the config value (e.g. a real file `technical` with `--config technical`)
|
|
// unreachable, and silently ignored a second file argument.
|
|
const argv = process.argv.slice(2);
|
|
let cfgArg = null, json = false, file = null;
|
|
for (let i = 0; i < argv.length; i += 1) {
|
|
const a = argv[i];
|
|
if (a === '--config') {
|
|
const nx = argv[i + 1];
|
|
if (nx !== undefined && !nx.startsWith('--')) { cfgArg = nx; i += 1; } // else: missing value
|
|
} else if (a === '--json') { json = true; }
|
|
else if (a.startsWith('--')) { console.error(`unknown flag: ${a}`); process.exit(2); }
|
|
else if (file === null) { file = a; }
|
|
else { console.error(`unexpected extra argument: ${a}`); process.exit(2); }
|
|
}
|
|
if (!file || !cfgArg) { console.error('usage: check-style.js <file> --config <config.json|name> [--json]'); process.exit(2); }
|
|
const cfgPath = resolveConfig(cfgArg);
|
|
if (!cfgPath) {
|
|
console.error(`config not found: "${cfgArg}"\nPass a path to a JSON config, or a name matching a file in examples/ (for example, --config technical). See examples/README.md.`);
|
|
process.exit(2);
|
|
}
|
|
let config;
|
|
try { config = JSON.parse(fs.readFileSync(cfgPath, 'utf8')); }
|
|
catch (e) { console.error(`could not read config "${cfgPath}": ${e.message}`); process.exit(2); }
|
|
if (!config || typeof config !== 'object' || typeof config.mechanics !== 'object'
|
|
|| config.mechanics === null || Array.isArray(config.mechanics)) {
|
|
console.error(`config "${cfgPath}" has no "mechanics" object`); process.exit(2);
|
|
}
|
|
let text;
|
|
try { text = fs.readFileSync(file, 'utf8'); }
|
|
catch (e) { console.error(`could not read file "${file}": ${e.message}`); process.exit(2); }
|
|
const r = check(text, config.mechanics);
|
|
if (json) {
|
|
console.log(JSON.stringify(r, null, 2));
|
|
} else {
|
|
const w = r.warnings.length ? `, ${r.warnings.length} config warning(s)` : '';
|
|
console.log(`${config.name || cfgPath}: ${r.hard.length} hard, ${r.advisory.length} advisory${w}`);
|
|
r.warnings.forEach((x) => console.log(` ! ${x.rule}: ${x.detail}`));
|
|
r.hard.forEach((x) => console.log(` L${x.line || '-'} ${x.rule}`));
|
|
r.advisory.forEach((x) => console.log(` L${x.line || '-'} ${x.rule} (advisory)${x.detail ? `: ${x.detail}` : ''}`));
|
|
}
|
|
process.exit(r.hard.length > 0 ? 1 : 0);
|
|
}
|