Files
conorbronsdon__avoid-ai-wri…/scripts/check-style.test.js
Clay f666087e52 feat: optional --style house-style layer (config-driven, points at Vale) (#99)
* 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>
2026-08-03 17:48:46 -07:00

265 lines
16 KiB
JavaScript
Raw Permalink Blame History

This file contains invisible Unicode characters
This file contains invisible Unicode characters that are indistinguishable to humans but may be processed differently by a computer. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
#!/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.`);