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>
265 lines
16 KiB
JavaScript
265 lines
16 KiB
JavaScript
#!/usr/bin/env node
|
||
/* Tests for scripts/check-style.js — run by `npm test`. */
|
||
'use strict';
|
||
const assert = require('assert');
|
||
const fs = require('fs');
|
||
const os = require('os');
|
||
const path = require('path');
|
||
const { spawnSync } = require('child_process');
|
||
const { check, resolveConfig } = require('./check-style.js');
|
||
|
||
let passed = 0;
|
||
const t = (name, fn) => { fn(); passed += 1; process.stdout.write(` ✓ ${name}\n`); };
|
||
|
||
// --- quotes (hard) ---
|
||
t('quotes:straight flags curly marks; a clean doc passes', () => {
|
||
assert.strictEqual(check('# ok\n\nplain "straight" text', { quotes: 'straight' }).hard.length, 0);
|
||
assert.ok(check('# ok\n\nuse the “retry” option', { quotes: 'straight' }).hard.some((x) => x.rule === 'quotes-should-be-straight'));
|
||
});
|
||
t('quotes:curly flags straight quotes and apostrophes', () => {
|
||
const r = check('She said "hi" and it\'s fine.', { quotes: 'curly' });
|
||
assert.ok(r.hard.some((x) => x.rule === 'double-quote-should-be-curly'));
|
||
assert.ok(r.hard.some((x) => x.rule === 'apostrophe-should-be-curly'));
|
||
});
|
||
t('quotes:curly carves out feet/inch primes (5\'11")', () => {
|
||
assert.strictEqual(check('The wall is 5\'11" tall.', { quotes: 'curly' }).hard.length, 0);
|
||
});
|
||
|
||
// --- latinAbbrev (hard): never (Google) vs parentheses (Chicago) ---
|
||
t('latinAbbrev:never flags any e.g./i.e.', () => {
|
||
assert.ok(check('Retry, e.g. 3 times.', { latinAbbrev: 'never' }).hard.some((x) => x.rule === 'latin-abbrev-not-allowed'));
|
||
assert.strictEqual(check('Retry, for example 3 times.', { latinAbbrev: 'never' }).hard.length, 0);
|
||
});
|
||
t('latinAbbrev:parentheses flags outside parens, not inside (must-not-fire)', () => {
|
||
assert.ok(check('Retry, e.g. 3 times.', { latinAbbrev: 'parentheses' }).hard.some((x) => x.rule === 'latin-abbrev-outside-parens'));
|
||
assert.strictEqual(check('Retry a few times (e.g., 3).', { latinAbbrev: 'parentheses' }).hard.length, 0);
|
||
});
|
||
|
||
// --- heading case is ADVISORY now (proper nouns make it undecidable) ---
|
||
t('a title-case heading is advisory under headings:sentence, never hard', () => {
|
||
const r = check('# Configure Your Retries\n\ntext', { headings: 'sentence' });
|
||
assert.strictEqual(r.hard.length, 0);
|
||
assert.ok(r.advisory.some((x) => x.rule === 'heading-may-need-sentence-case'));
|
||
});
|
||
t('a proper-noun heading is NOT a hard violation', () => {
|
||
// "# Configure Docker and Kubernetes" is correct sentence case; must not hard-fail.
|
||
assert.strictEqual(check('# Configure Docker and Kubernetes\n\ntext', { headings: 'sentence' }).hard.length, 0);
|
||
assert.strictEqual(check('# Deploying to Google Cloud\n\ntext', { headings: 'sentence' }).hard.length, 0);
|
||
});
|
||
t('headings:title flags a fully-lowercase heading as advisory', () => {
|
||
assert.ok(check('# a plain heading\n\ntext', { headings: 'title' }).advisory.some((x) => x.rule === 'heading-may-need-title-case'));
|
||
});
|
||
|
||
// --- config validation: unrecognized keys/values become warnings ---
|
||
t('unknown key and unknown value are warned, not silently ignored', () => {
|
||
const r = check('text', { quote: 'straight', headings: 'sentance' });
|
||
assert.ok(r.warnings.some((x) => x.rule === 'unknown-key' && x.detail === 'quote'));
|
||
assert.ok(r.warnings.some((x) => x.rule === 'unknown-value' && x.detail.startsWith('headings')));
|
||
});
|
||
t('a valid config produces no warnings', () => {
|
||
assert.strictEqual(check('text', { quotes: 'straight', headings: 'sentence', serialComma: true }).warnings.length, 0);
|
||
});
|
||
|
||
// --- structure/code protection ---
|
||
t('frontmatter that closes is skipped; unterminated frontmatter does NOT swallow the doc', () => {
|
||
assert.strictEqual(check('---\ntitle: "x"\n---\n# ok\n\nRun `git --force`.', { quotes: 'straight' }).hard.length, 0);
|
||
// no closing --- : the body must still be checked, not silently blanked
|
||
assert.ok(check('---\nx\n\nuse the “retry” option', { quotes: 'straight' }).hard.some((x) => x.rule === 'quotes-should-be-straight'));
|
||
});
|
||
t('empty/omitted mechanics check nothing and warn nothing', () => {
|
||
const r = check('anything "here" it\'s fine', {});
|
||
assert.strictEqual(r.hard.length, 0);
|
||
assert.strictEqual(r.warnings.length, 0);
|
||
});
|
||
t('markdown link titles and reference definitions are syntax, not curly-quote violations', () => {
|
||
// Link titles MUST use straight quotes; flagging them hard-fails a correct document.
|
||
assert.strictEqual(check('See the [docs](https://x.example "The Title") for more.', { quotes: 'curly' }).hard.length, 0);
|
||
assert.strictEqual(check('[1]: https://x.example "Ref Title"', { quotes: 'curly' }).hard.length, 0);
|
||
// A real straight quote in prose on the same line is still caught.
|
||
assert.ok(check('He said "hi" in [docs](https://x.example "T").', { quotes: 'curly' }).hard.length > 0);
|
||
});
|
||
t('a parenthetical that wraps across lines keeps its latinAbbrev carve-out', () => {
|
||
const wrapped = 'A long aside (this parenthetical wraps,\ne.g. across two lines) ends here.';
|
||
assert.strictEqual(check(wrapped, { latinAbbrev: 'parentheses' }).hard.length, 0);
|
||
// Still fires once the parenthetical has closed.
|
||
assert.ok(check('An aside (closed here).\ne.g. now outside.', { latinAbbrev: 'parentheses' }).hard.some((x) => x.rule === 'latin-abbrev-outside-parens'));
|
||
});
|
||
t('a stray "(" does not suppress later latinAbbrev findings past the paragraph', () => {
|
||
// Regression: paren depth carried document-wide, so one ":(" silenced every later
|
||
// finding. Depth must reset at a blank line.
|
||
const stray = 'This made me sad :( honestly.\n\nLater we argue, e.g. this point, outside any parens.';
|
||
assert.ok(check(stray, { latinAbbrev: 'parentheses' }).hard.some((x) => x.rule === 'latin-abbrev-outside-parens'));
|
||
});
|
||
t('a link whose URL contains parentheses does not corrupt the paren balance', () => {
|
||
// Regression: [^)]* mis-terminated on a nested paren and left a stray ")" behind.
|
||
const wiki = '(We cite [Foo](https://en.wikipedia.org/wiki/Foo_(bar)), e.g. this one.)';
|
||
assert.strictEqual(check(wiki, { latinAbbrev: 'parentheses' }).hard.length, 0);
|
||
// ...and its straight-quoted title is still masked for quotes:curly.
|
||
assert.strictEqual(check('[Foo](https://en.wikipedia.org/wiki/Foo_(bar) "The Title")', { quotes: 'curly' }).hard.length, 0);
|
||
});
|
||
t('an unclosed "](" is left alone rather than swallowing the rest of the line', () => {
|
||
assert.ok(check('weird ]( text with "quotes" here.', { quotes: 'curly' }).hard.length > 0);
|
||
});
|
||
t('prose that merely looks like a reference definition keeps its quotes checked', () => {
|
||
assert.ok(check('[sic]: he said "hi" plainly.', { quotes: 'curly' }).hard.length > 0);
|
||
});
|
||
t('a link-led definition-list line is not swallowed as a reference definition', () => {
|
||
// Regression: masking the destination left "[Docs]: ..." which re-parsed as a ref
|
||
// definition, silently blanking the rest of an ordinary line.
|
||
assert.ok(check('[Docs](https://d.example): "Note"', { quotes: 'curly' }).hard.length > 0);
|
||
assert.ok(check('[Docs](https://d.example): e.g. outside.', { latinAbbrev: 'parentheses' }).hard.length > 0);
|
||
});
|
||
t('a reference-definition title may contain escaped quotes', () => {
|
||
assert.strictEqual(check('[1]: https://x.example "say \\"hi\\""', { quotes: 'curly' }).hard.length, 0);
|
||
});
|
||
t('a parenthetical survives an intervening code block or code-only line', () => {
|
||
// Regression: fenced/code-only lines mask to '' and were read as paragraph breaks,
|
||
// resetting the paren depth mid-parenthetical.
|
||
const fenced = '(See the config, for instance:\n```json\n{}\n```\nand, e.g., it closes here.)';
|
||
assert.strictEqual(check(fenced, { latinAbbrev: 'parentheses' }).hard.length, 0);
|
||
// A BLANK line inside the fence is not a paragraph break either.
|
||
const gappy = '(See the config:\n```json\n{\n\n}\n```\nand, e.g., it closes here.)';
|
||
assert.strictEqual(check(gappy, { latinAbbrev: 'parentheses' }).hard.length, 0);
|
||
const inline = '(An aside about\n`some code`\ne.g. still inside the parens.)';
|
||
assert.strictEqual(check(inline, { latinAbbrev: 'parentheses' }).hard.length, 0);
|
||
});
|
||
t('HTML attribute values are syntax, not curly-quote violations', () => {
|
||
assert.strictEqual(check('<div class="callout">\n\nText.', { quotes: 'curly' }).hard.length, 0);
|
||
assert.strictEqual(check('<img alt="a chart" />', { quotes: 'curly' }).hard.length, 0);
|
||
});
|
||
t('prose containing a comparison is not mistaken for an HTML tag', () => {
|
||
// Regression: a loose "<letter ... >" mask swallowed everything between the brackets,
|
||
// hiding real violations in ordinary technical prose.
|
||
assert.ok(check('For n<N, the "tail" sum > epsilon.', { quotes: 'curly' }).hard.length > 0);
|
||
assert.ok(check('if a<b it isn\'t > c.', { quotes: 'curly' }).hard.length > 0);
|
||
assert.ok(check('When x<y (e.g. small) > z holds.', { latinAbbrev: 'never' }).hard.length > 0);
|
||
});
|
||
t('a UTF-8 BOM does not hide the frontmatter', () => {
|
||
assert.strictEqual(check('---\ntitle: "x"\n---\nClean text.', { quotes: 'curly' }).hard.length, 0);
|
||
});
|
||
t('nested and mismatched fences do not leak code as prose (no false hard violation)', () => {
|
||
// An inner ``` inside an outer ```` is code content; curly quotes there must NOT flag.
|
||
const nested = '# ok\n\n````\nouter\n```\ninner “q”\n```\n````\n\nclean text';
|
||
assert.strictEqual(check(nested, { quotes: 'straight' }).hard.length, 0);
|
||
// A ~~~ line inside a ``` block does not close it either.
|
||
const tilde = '# ok\n\n```\n~~~\ninner “q”\n~~~\n```\n\nclean text';
|
||
assert.strictEqual(check(tilde, { quotes: 'straight' }).hard.length, 0);
|
||
// Prose after a properly closed fence is still checked.
|
||
assert.ok(check('# ok\n\n```\ncode\n```\n\nuse the “retry” option', { quotes: 'straight' }).hard.some((x) => x.rule === 'quotes-should-be-straight'));
|
||
});
|
||
|
||
// --- resolution (filename / path / unknown / traversal); no guide aliases ---
|
||
// path.join, not a '/' literal: on Windows these are examples\technical.json, so a
|
||
// hardcoded forward slash fails the whole suite locally while ubuntu CI stays green.
|
||
const EX = (f) => path.join(__dirname, '..', 'examples', f);
|
||
t('resolveConfig: filename, path, unknown, traversal (no guide aliases)', () => {
|
||
assert.strictEqual(resolveConfig('technical'), EX('technical.json'));
|
||
assert.strictEqual(resolveConfig('prose'), EX('prose.json'));
|
||
assert.strictEqual(resolveConfig(EX('technical.json')), EX('technical.json'));
|
||
assert.strictEqual(resolveConfig('cmos'), null); // no guide alias resolution
|
||
assert.strictEqual(resolveConfig('no-such-guide'), null);
|
||
assert.strictEqual(resolveConfig('../package'), null); // bare names can't traverse
|
||
});
|
||
t('a bare name is not shadowed by a same-named file in the working directory', () => {
|
||
// A file literally named "technical" in cwd must NOT hijack the bare-name lookup.
|
||
const dir = fs.mkdtempSync(path.join(os.tmpdir(), 'cs-shadow-'));
|
||
fs.writeFileSync(path.join(dir, 'technical'), '# not a config');
|
||
const cwd = process.cwd();
|
||
process.chdir(dir);
|
||
try { assert.strictEqual(resolveConfig('technical'), EX('technical.json')); }
|
||
finally { process.chdir(cwd); fs.rmSync(dir, { recursive: true, force: true }); }
|
||
});
|
||
|
||
// --- shipped example configs are generic (no guide names/aliases), parse, and apply ---
|
||
t('example configs are generic, parse with register + mechanics', () => {
|
||
const tech = JSON.parse(fs.readFileSync(path.join(__dirname, '..', 'examples', 'technical.json'), 'utf8'));
|
||
const prose = JSON.parse(fs.readFileSync(path.join(__dirname, '..', 'examples', 'prose.json'), 'utf8'));
|
||
for (const cfg of [tech, prose]) {
|
||
assert.ok(Array.isArray(cfg.register) && cfg.register.length);
|
||
assert.ok(cfg.mechanics && typeof cfg.mechanics === 'object');
|
||
assert.ok(cfg.aliases === undefined, 'no aliases shipped');
|
||
assert.ok(!/google|chicago|cmos|apa/i.test(cfg.name || ''), 'name is guide-neutral');
|
||
}
|
||
assert.strictEqual(tech.mechanics.latinAbbrev, 'never');
|
||
assert.strictEqual(prose.mechanics.latinAbbrev, 'parentheses');
|
||
assert.strictEqual(check('# Configure retries\n\nSet the value.', tech.mechanics).hard.length, 0);
|
||
});
|
||
|
||
// --- CLI exit codes: 0 clean, 1 hard, 2 tool error ---
|
||
const cli = (mdText, cfgArg) => {
|
||
const f = path.join(os.tmpdir(), `cs-${passed}-${Math.floor(process.hrtime()[1])}.md`);
|
||
fs.writeFileSync(f, mdText);
|
||
const r = spawnSync('node', [path.join(__dirname, 'check-style.js'), f, '--config', cfgArg], { encoding: 'utf8' });
|
||
fs.unlinkSync(f);
|
||
return r.status;
|
||
};
|
||
t('CLI exits 0 clean, 1 on a hard violation, 2 on a tool error', () => {
|
||
assert.strictEqual(cli('# ok\n\nplain text', 'technical'), 0);
|
||
assert.strictEqual(cli('# ok\n\nuse the “curly” quote', 'technical'), 1); // technical => quotes straight
|
||
assert.strictEqual(cli('# ok\n\ntext', 'no-such-guide'), 2);
|
||
});
|
||
|
||
// raw-argv runner: write {name: content} files into a temp dir, run the CLI from that
|
||
// dir with the given argv, return exit status; cleans up.
|
||
const cliRaw = (files, argv) => {
|
||
const dir = fs.mkdtempSync(path.join(os.tmpdir(), 'cs-cli-'));
|
||
for (const [name, content] of Object.entries(files)) fs.writeFileSync(path.join(dir, name), content);
|
||
const r = spawnSync('node', [path.join(__dirname, 'check-style.js'), ...argv], { cwd: dir, encoding: 'utf8' });
|
||
fs.rmSync(dir, { recursive: true, force: true });
|
||
return r.status;
|
||
};
|
||
t('CLI: a second file, an unknown flag, and a missing --config value each exit 2', () => {
|
||
assert.strictEqual(cliRaw({ 'a.md': '# ok\n\ntext', 'b.md': '# ok\n\ntext' }, ['a.md', 'b.md', '--config', 'technical']), 2);
|
||
assert.strictEqual(cliRaw({ 'a.md': '# ok\n\ntext' }, ['a.md', '--config', 'technical', '--bogus']), 2);
|
||
assert.strictEqual(cliRaw({ 'a.md': '# ok\n\ntext' }, ['a.md', '--config']), 2);
|
||
});
|
||
t('CLI: a config whose mechanics is null or an array exits 2, not a false-green 0', () => {
|
||
const withCfg = (cfgJson) => {
|
||
const dir = fs.mkdtempSync(path.join(os.tmpdir(), 'cs-mech-'));
|
||
fs.writeFileSync(path.join(dir, 'a.md'), '# ok\n\ntext');
|
||
fs.writeFileSync(path.join(dir, 'c.json'), cfgJson);
|
||
const r = spawnSync('node', [path.join(__dirname, 'check-style.js'), 'a.md', '--config', 'c.json'], { cwd: dir, encoding: 'utf8' });
|
||
fs.rmSync(dir, { recursive: true, force: true });
|
||
return r.status;
|
||
};
|
||
assert.strictEqual(withCfg('{"mechanics": null}'), 2);
|
||
assert.strictEqual(withCfg('{"mechanics": []}'), 2);
|
||
assert.strictEqual(withCfg('{"mechanics": {"quotes": "straight"}}'), 0);
|
||
});
|
||
t('CLI: a file named like the config value is still reachable (not filtered by value)', () => {
|
||
// `--config technical` resolves to examples/; the positional file also named `technical`
|
||
// must be read and checked (clean => 0), not treated as unreachable.
|
||
assert.strictEqual(cliRaw({ technical: '# ok\n\nplain text' }, ['technical', '--config', 'technical']), 0);
|
||
});
|
||
|
||
// --- indented code blocks (masked) vs lazy continuation and list content (prose) ---
|
||
t('4-space indented code after a blank line is masked (must-not-fire)', () => {
|
||
assert.strictEqual(check('Para.\n\n code with "straight" quotes\n\nMore.', { quotes: 'curly' }).hard.length, 0);
|
||
assert.strictEqual(check('Para.\n\n\tx = f(a, e.g., b)\n\nMore.', { latinAbbrev: 'never' }).hard.length, 0);
|
||
});
|
||
t('a 4-space line directly under a paragraph is lazy continuation and still checked', () => {
|
||
assert.ok(check('Para starts\n still "prose" here.', { quotes: 'curly' }).hard.length > 0);
|
||
});
|
||
t('4-space content inside a list item is prose and still checked', () => {
|
||
assert.ok(check('- item\n\n second "para" of the item', { quotes: 'curly' }).hard.length > 0);
|
||
});
|
||
t('a paragraph after a list resets list context, so indented code masks again', () => {
|
||
assert.strictEqual(check('- item\n\nPlain paragraph.\n\n code "x"\n\nAfter.', { quotes: 'curly' }).hard.length, 0);
|
||
});
|
||
|
||
// --- a leading thematic break is not frontmatter ---
|
||
t('a document opening with --- then a blank line is not frontmatter (must-fire)', () => {
|
||
assert.ok(check('---\n\nHe said "hi".\n\n---\n\nmore', { quotes: 'curly' }).hard.length > 0);
|
||
});
|
||
t('real frontmatter still masks, including a quoted value', () => {
|
||
assert.strictEqual(check('---\ntitle: "Straight quotes are yaml"\n---\n\nplain text', { quotes: 'curly' }).hard.length, 0);
|
||
});
|
||
|
||
// --- bare-name containment is by construction (no separator reaches that branch) ---
|
||
t('dot-bearing bare names resolve inside examples/ only, and miss', () => {
|
||
assert.strictEqual(resolveConfig('..'), null);
|
||
assert.strictEqual(resolveConfig('foo..bar'), null);
|
||
});
|
||
|
||
console.log(`\ncheck-style: ${passed} passed.`);
|