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>
207 lines
7.5 KiB
JavaScript
207 lines
7.5 KiB
JavaScript
#!/usr/bin/env node
|
||
/**
|
||
* Avoid AI Writing — self-scan
|
||
*
|
||
* Scores this repo's own documentation with this repo's own detector. A tool
|
||
* that flags "delve" in your writing should survive its own pass, and if it
|
||
* doesn't, that belongs in public rather than in a drawer.
|
||
*
|
||
* Two numbers are reported per file, and both are printed because reporting
|
||
* only the flattering one is the failure this project exists to criticize:
|
||
*
|
||
* raw every match, including patterns quoted as examples
|
||
* exempt the same scan with SKILL.md's self-reference escape hatch
|
||
* applied mechanically
|
||
*
|
||
* The escape hatch is not a fudge; it is a rule this skill already documents:
|
||
* "When writing *about* AI writing patterns … quoted examples are exempt from
|
||
* flagging. Text inside quotation marks, code blocks, or explicitly marked as
|
||
* illustrative should not be rewritten." Until now that rule existed only as
|
||
* prose instructions to a model. `applyExemptions()` is its executable form.
|
||
*
|
||
* Usage:
|
||
* node scripts/self-scan.js # table to stdout
|
||
* node scripts/self-scan.js --json # machine-readable
|
||
* node scripts/self-scan.js --check # exit 1 if a file exceeds its budget
|
||
* node scripts/self-scan.js --markdown # the PROOF.md table body
|
||
*
|
||
* Dependency-free; runs on node >= 18.
|
||
*/
|
||
|
||
const fs = require('node:fs');
|
||
const path = require('node:path');
|
||
const AIDetector = require('../detector/patterns.js');
|
||
|
||
const ROOT = path.resolve(__dirname, '..');
|
||
|
||
/**
|
||
* Score budgets for the exempt-applied scan. A file over budget fails --check.
|
||
*
|
||
* These are ceilings on measured values, not aspirations: they were set from
|
||
* the first clean run with a few points of headroom so ordinary edits don't
|
||
* trip CI, and they only move down. Raising one is a decision that belongs in
|
||
* a pull request with the new number stated.
|
||
*/
|
||
const BUDGETS = {
|
||
'README.md': 30,
|
||
'SKILL.md': 25,
|
||
'CONTRIBUTING.md': 15,
|
||
'detector/README.md': 15,
|
||
'detector/CATEGORIES.md': 15,
|
||
'examples/README.md': 10,
|
||
// Higher than the rest on purpose. A changelog enumerates the pattern names
|
||
// it added ("bustling, intricate, ever-evolving"), unquoted, which the
|
||
// exemption cannot reach, and Keep-a-Changelog headings carry a
|
||
// `## [3.21.0] — 2026-07-30` separator the em-dash rule does not carve out.
|
||
// PROOF.md itemizes both. See issue #67.
|
||
'CHANGELOG.md': 40,
|
||
'PROOF.md': 20,
|
||
};
|
||
|
||
const FILES = Object.keys(BUDGETS);
|
||
|
||
// ── The self-reference escape hatch, made executable ───────────────────
|
||
//
|
||
// Order matters: fenced code first (it can contain anything), then the
|
||
// line-oriented block forms, then inline spans.
|
||
const FENCED_CODE = /^(?:```|~~~)[^\n]*\n[\s\S]*?^(?:```|~~~)[ \t]*$/gm;
|
||
const TABLE_BLOCK = /(?:^[ \t]*\|[^\n]*\|[ \t]*(?:\n[ \t]*\|[^\n]*\|[ \t]*)+)/gm;
|
||
const BLOCKQUOTE_BLOCK = /(?:^[ \t]*>[^\n]*(?:\n[ \t]*>[^\n]*)*)/gm;
|
||
const INLINE_CODE = /`[^`\n]+`/g;
|
||
const QUOTED_SPAN = /(?:"[^"\n]{1,300}"|“[^”\n]{1,300}”|'[^'\n]{2,300}')/g;
|
||
|
||
/**
|
||
* Blank out the spans SKILL.md exempts, preserving line and column offsets so
|
||
* any reported match index still points at the right place in the source.
|
||
*/
|
||
function applyExemptions(text) {
|
||
const blank = (s) => s.replace(/[^\n]/g, ' ');
|
||
return text
|
||
.replace(FENCED_CODE, blank)
|
||
.replace(TABLE_BLOCK, blank)
|
||
.replace(BLOCKQUOTE_BLOCK, blank)
|
||
.replace(INLINE_CODE, blank)
|
||
.replace(QUOTED_SPAN, blank);
|
||
}
|
||
|
||
/**
|
||
* The detector refuses text over ~10k words. Long documents are scored in
|
||
* paragraph-aligned chunks and reported by their worst chunk, which is the
|
||
* conservative reading: a document is as machine-sounding as its worst section.
|
||
*/
|
||
const CHUNK_WORDS = 4000;
|
||
|
||
function scoreLongText(text) {
|
||
const paragraphs = text.split(/\n\s*\n/);
|
||
const chunks = [];
|
||
let current = [];
|
||
let words = 0;
|
||
for (const para of paragraphs) {
|
||
const n = (para.match(/\S+/g) || []).length;
|
||
if (words + n > CHUNK_WORDS && current.length) {
|
||
chunks.push(current.join('\n\n'));
|
||
current = [];
|
||
words = 0;
|
||
}
|
||
current.push(para);
|
||
words += n;
|
||
}
|
||
if (current.length) chunks.push(current.join('\n\n'));
|
||
|
||
const results = chunks
|
||
.map((chunk) => AIDetector.analyzeText(chunk))
|
||
.filter((r) => !r.tooShort && r.label !== 'Text too long');
|
||
|
||
if (!results.length) return { score: 0, issues: 0, wordCount: 0, chunks: chunks.length };
|
||
return {
|
||
score: Math.max(...results.map((r) => r.score)),
|
||
issues: results.reduce((sum, r) => sum + r.issues.length, 0),
|
||
wordCount: results.reduce((sum, r) => sum + (r.stats.wordCount || 0), 0),
|
||
chunks: results.length,
|
||
};
|
||
}
|
||
|
||
function score(text) {
|
||
const wordCount = (text.match(/\S+/g) || []).length;
|
||
if (wordCount > 9500) return scoreLongText(text);
|
||
const r = AIDetector.analyzeText(text);
|
||
return {
|
||
score: r.score,
|
||
issues: r.issues.length,
|
||
wordCount: r.stats.wordCount || wordCount,
|
||
chunks: 1,
|
||
topTypes: topTypes(r.issues),
|
||
};
|
||
}
|
||
|
||
function topTypes(issues) {
|
||
const counts = new Map();
|
||
for (const issue of issues) counts.set(issue.type, (counts.get(issue.type) || 0) + 1);
|
||
return [...counts.entries()].sort((a, b) => b[1] - a[1]).slice(0, 3);
|
||
}
|
||
|
||
function scanFile(rel) {
|
||
const text = fs.readFileSync(path.join(ROOT, rel), 'utf8');
|
||
const raw = score(text);
|
||
const exempt = score(applyExemptions(text));
|
||
return {
|
||
file: rel,
|
||
words: raw.wordCount,
|
||
rawScore: raw.score,
|
||
rawIssues: raw.issues,
|
||
exemptScore: exempt.score,
|
||
exemptIssues: exempt.issues,
|
||
budget: BUDGETS[rel],
|
||
overBudget: exempt.score > BUDGETS[rel],
|
||
chunked: raw.chunks > 1 ? raw.chunks : null,
|
||
topTypes: exempt.topTypes || [],
|
||
};
|
||
}
|
||
|
||
function main() {
|
||
const args = process.argv.slice(2);
|
||
const rows = FILES.map(scanFile);
|
||
|
||
if (args.includes('--json')) {
|
||
console.log(JSON.stringify({ generated_by: 'scripts/self-scan.js', rows }, null, 2));
|
||
} else if (args.includes('--markdown')) {
|
||
console.log('| Document | Words | Raw score | Exempt score | Budget |');
|
||
console.log('|---|---:|---:|---:|---:|');
|
||
for (const r of rows) {
|
||
console.log(`| \`${r.file}\` | ${r.words.toLocaleString()} | ${r.rawScore} | **${r.exemptScore}** | ${r.budget} |`);
|
||
}
|
||
} else {
|
||
console.log('\nself-scan — this skill\'s detector against this skill\'s docs\n');
|
||
console.log(' file words raw exempt budget');
|
||
for (const r of rows) {
|
||
const flag = r.overBudget ? ' OVER' : '';
|
||
console.log(
|
||
` ${r.file.padEnd(24)}${String(r.words).padStart(6)}${String(r.rawScore).padStart(7)}${String(r.exemptScore).padStart(8)}${String(r.budget).padStart(8)}${flag}`,
|
||
);
|
||
}
|
||
const over = rows.filter((r) => r.overBudget);
|
||
console.log(
|
||
`\n raw counts every quoted example as a violation; exempt applies SKILL.md's\n`
|
||
+ ` self-reference escape hatch. Budgets gate the exempt column only.\n`,
|
||
);
|
||
if (over.length) {
|
||
for (const r of over) {
|
||
console.log(` ${r.file} is over budget (${r.exemptScore} > ${r.budget}). Top categories: ${r.topTypes.map(([t, n]) => `${t}×${n}`).join(', ') || 'none'}`);
|
||
}
|
||
}
|
||
}
|
||
|
||
if (args.includes('--check')) {
|
||
const over = rows.filter((r) => r.overBudget);
|
||
if (over.length) {
|
||
console.error(`\nFAIL — ${over.length} file(s) over budget: ${over.map((r) => r.file).join(', ')}`);
|
||
process.exit(1);
|
||
}
|
||
console.error('\nPASS — every scanned document is within its score budget');
|
||
}
|
||
}
|
||
|
||
if (require.main === module) main();
|
||
|
||
module.exports = { applyExemptions, scanFile, BUDGETS };
|