mirror of
https://github.com/conorbronsdon/avoid-ai-writing.git
synced 2026-09-19 01:32:11 +08:00
788c6675c1
* feat: seed OpenAI plugin skill resources * feat: add OpenAI plugin manifest and public metadata * feat: add routed ChatGPT and Codex skill graph * test: add OpenAI plugin validation and submission pack * fix: correct listing character-count evidence * feat: add typed cross-skill handoff contract * docs: encode agency review lenses for orchestration * feat: add machine-validated typed skill graph * feat: upgrade router to typed orchestration protocol * feat: connect detector to typed handoff network * feat: connect rewriter to verification and representation guards * feat: add typed file-edit handoffs and repair loop * feat: make preservation verifier a blocking typed gate * feat: connect interpretation guardrail to evidence handoffs * refactor: replace legacy yaml graph with validated json graph * test: validate skill graph and cross-skill contracts * ci: validate cross-skill orchestration graph * refactor: package connection validator inside router skill * refactor: keep connection validator inside packaged router skill * ci: run packaged connection validator before and after extraction * docs: align routing matrix with typed graph * test: expand routing evals for connected skill graph * fix: remove unintended reviewer detector cycle * fix: make interpretation reviewer terminal in skill graph * test: reject terminal outgoing edges and unbounded graph cycles * docs: align handoff contract with terminal reviewer semantics * docs: route fresh interpretation evidence through router * docs: document graph v2 and connection validation * fix: route fresh detector requests through orchestrator * test: enforce agency lenses and router-return semantics * test: cover terminal reviewer return-to-router flow * docs: add connected skill graph v2 to release notes * fix: remove unsupported skill policy products * fix: remove unsupported skill policy products * fix: remove unsupported skill policy products * fix: remove unsupported skill policy products * fix: remove unsupported skill policy products * fix: remove unsupported skill policy products * fix: remove unsupported skill policy products * fix: align plugin validator with graph v2 and ingestion policy * fix: synchronize bundled detector and verifier resources * fix: bundle detector beside preservation validator * ci: watch canonical detector resources for plugin drift * test: smoke packaged preservation residual detector * ci: exercise residual detector before and after packaging * fix(port): sync ChatGPT/Codex plugin version with canonical SKILL.md The merge with main moves the canonical skill to 3.28.0; the plugin manifest, listing, and submission pack still carried 3.25.1, so scripts/sync-plugin-skill.sh and scripts/validate-openai-plugin.py both exit 1 on the effective merge. Bump the three manifests and regenerate the copies the sync script owns. Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com> * fix(port): publish the plugin from the canonical repository Every public destination in the ChatGPT/Codex package pointed at the contributor's fork: websiteURL, privacyPolicyURL, termsOfServiceURL, homepage, repository, the submission listing, and SUPPORT.md. After a merge the canonical owner would not control the published legal and support pages. Point them all at conorbronsdon/avoid-ai-writing and make developerName/packagePublisher the identity that will be verified in the OpenAI portal. Packaging credit stays: NOTICE.md and listing.json now name the contributor explicitly. Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com> * fix(detector): CRLF input no longer bypasses the preservation validator validate() extracts frontmatter, tables and headings with regexes that anchor on a bare newline. A CRLF document matched none of them, so changing 'title: one' to 'title: two' in Windows-formatted frontmatter returned { ok: true, errors: [] }. This is a pre-existing bug in the canonical detector, not something PR #135 introduced - the bundled plugin copy is byte-identical to detector/validate.js - so the fix and the must-fire fixtures go in the canonical source and the generated copy is re-synced from it. Adds four CRLF fixtures to detector/validate.test.js; all four fail against the unpatched validator. Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com> * fix(port): make the manifest the only source for listing metadata submission/listing.json restates twelve manifest fields and only three of them were asserted, so the listing could ship a different developer name, support URL or description than the manifest it claims to describe - which is exactly the drift that put a fork's URLs into the public package. Assert every duplicated field against .codex-plugin/plugin.json, derive the hand-maintained character counts instead of trusting them, and require every public URL to sit under the canonical author.url so publication cannot move off this repository without turning the check red. Also: guard the canonical-copy comparison against a missing plugin copy (it raised FileNotFoundError instead of reporting), scope the hygiene scan to the packaged surface instead of walking .git, and restore the CLAUDE.md path-filter rationale the port deleted from the workflow. Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com> * fix(port): bundle every resource the canonical skill references OpenAI's submission contract says each skill must include any referenced scripts, templates, or assets, and the Codex skill layout has no shared- resource mechanism, so skills/avoid-ai-writing/SKILL.md (byte-identical to root SKILL.md) now ships with synced copies of detector/patterns.js, detector/validate.js, detector/CATEGORIES.md, scripts/check-style.js, and examples/ at the same relative paths. scripts/sync-plugin-skill.sh produces them and the existing regenerate-then-diff CI gate covers them. A new smoke test runs check-style and the preservation validator from inside the bundled tree and from an extracted package. Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com> * fix(port): manifest supportURL, ancestry-checked baseCommit, policy.products interface.supportURL now lives in .codex-plugin/plugin.json and the listing value is validated against it. Both submission files record baseCommit as the origin/main merge-base and the validator checks it is an ancestor of HEAD so it cannot drift silently; the verifier's copied patterns.js joins the deterministic-resource inventory. The validator accepts policy.products with CHAT and/or CODEX per the current agents/openai.yaml schema and keeps rejecting unknown policy keys, with tests. Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com> * fix(port): follow main to 3.29.0 after #128 landed Bump .codex-plugin/plugin.json and the submission metadata to 3.29.0, set baseCommit to the new origin/main merge-base, and regenerate the bundled copies so the sync gate is clean on the merge ref. Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com> * fix(port): changelog and README for the plugin package; harden packager and validator Qodo re-review items: a dated 3.29.0 changelog entry and a README section for the native ChatGPT/Codex package; the packager and validator now reject symlinked top-level package files; JSON loading rejects a non-object root with a validation error instead of crashing, with tests for list and null roots and for the symlink case (skipped where symlinks need privileges). Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com> * fix(port): packager and validator hardening, lone-CR preservation, workflow paths Qodo re-review items 6-11: the marketplace policy no longer demands install-time authentication for a skills-only package (ON_USE, the only other documented value); the packager rejects an output path that is a packaged input or sits inside an included directory and fails on a missing top-level package file instead of silently omitting it; the validator type-checks nested submission objects before access; the preservation validator normalizes CRLF only, so a lone carriage return changed inside code is reported again; LICENSE joins the workflow path filters. Tests for each; bundled validator copies re-synced. Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com> * fix(port): marketplace authentication policy is first-use, not install-time The skills-only package has no authentication flow or hosted service. The marketplace docs say policy.authentication decides whether auth happens on install or first use; first use never triggers for local skills, so ON_USE replaces ON_INSTALL. (Qodo item 6, deferred from the previous commit because the implementer sandbox could not write .agents/.) Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com> * fix(port): graph validator rejects malformed JSON cleanly; canonical URL is a constant Qodo items 8 and 9. validate_connections.py now checks the graph root and edge field types and reports through its validation path instead of raising, with a test script wired into CI. validate-openai-plugin.py pins CANONICAL_PROJECT_URL and requires author.url to equal it, so changing every public URL to a fork no longer passes; test added. Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com> * fix(port): bound SVG parsing and stop empty skill names polluting the graph set Qodo items 19 and 20. The validator rejects SVGs carrying <!DOCTYPE or <!ENTITY declarations and anything over 256 KiB before handing text to ElementTree, closing the entity-expansion path; a skill whose frontmatter lacks a name reports that error once instead of inserting an empty name that cascades into router-graph errors. Tests for both. Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com> * ci(port): full-history checkout so the baseCommit ancestry check can run The validator's merge-base --is-ancestor check needs the base commit in the checkout; actions/checkout's default depth of 1 made CI report it as "not a valid commit name". Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com> --------- Co-authored-by: Conor Bronsdon <120674402+conorbronsdon@users.noreply.github.com> Co-authored-by: Claude Fable 5.1 <noreply@anthropic.com>
353 lines
15 KiB
JavaScript
353 lines
15 KiB
JavaScript
/**
|
|
* Avoid AI Writing — preservation validator
|
|
*
|
|
* SKILL.md promises that a rewrite leaves certain things alone: "Don't edit
|
|
* quoted material, code blocks, tables, or text attributed to someone else"
|
|
* (edit mode), "Preserve the original structure, intent, and all specific
|
|
* technical details" (rewrite mode). Nothing enforced those promises. This does.
|
|
*
|
|
* Usage:
|
|
* const { validate } = require('./validate.js');
|
|
* const result = validate(originalText, rewrittenText);
|
|
* if (!result.ok) { console.error(result.errors); }
|
|
*
|
|
* Errors block: they mean the rewrite destroyed or altered content it had no
|
|
* business touching. Warnings inform: they mean something changed that is
|
|
* usually legitimate but occasionally a mistake.
|
|
*
|
|
* Two carve-outs exist because this skill *documents* the edit in question,
|
|
* and a validator that fires on its own skill's instructions is worse than no
|
|
* validator:
|
|
*
|
|
* 1. AI-tool URL parameters (`utm_source=chatgpt.com` and friends). SKILL.md
|
|
* says "strip the AI-referrer tracking parameter from every URL that
|
|
* carries one." So URLs are compared with those parameters removed from
|
|
* both sides.
|
|
* 2. Heading text. SKILL.md says to convert Title Case headings to sentence
|
|
* case, and to cut emoji from headings. So heading *text* changing is a
|
|
* warning; heading count and nesting sequence changing is an error.
|
|
*
|
|
* Dependency-free. Runs on node >= 18. Mirrors the IIFE + module.exports shape
|
|
* of patterns.js so it can be loaded in a browser too.
|
|
*/
|
|
|
|
const AIDetectorValidate = (() => {
|
|
// ═══ Block extractors ══════════════════════════════════════════════
|
|
const FENCED_CODE = /^(?:```|~~~)[^\n]*\n[\s\S]*?^(?:```|~~~)[ \t]*$/gm;
|
|
const INLINE_CODE = /`[^`\n]+`/g;
|
|
const YAML_FRONTMATTER = /^---\n[\s\S]*?\n---(?=\n|$)/;
|
|
const BLOCKQUOTE_BLOCK = /(?:^[ \t]*>[^\n]*(?:\n[ \t]*>[^\n]*)*)/gm;
|
|
const TABLE_BLOCK = /(?:^[ \t]*\|[^\n]*\|[ \t]*(?:\n[ \t]*\|[^\n]*\|[ \t]*)+)/gm;
|
|
const MD_HEADING = /^(#{1,6})[ \t]+(.+?)[ \t]*$/gm;
|
|
const URL = /https?:\/\/[^\s)>\]"'`]+/g;
|
|
const MD_LINK_TARGET = /\[[^\]\n]*\]\(([^)\s]+)[^)]*\)/g;
|
|
const PATH = /(?:^|[\s(])((?:\.{0,2}\/)[A-Za-z0-9._~\-]+(?:\/[A-Za-z0-9._~\-]+)*|[A-Za-z]:\\[A-Za-z0-9._\\~\-]+)/g;
|
|
const NUMBER = /\b\d[\d,]*(?:\.\d+)?%?\b/g;
|
|
|
|
// Tracking parameters this skill is documented to strip (SKILL.md,
|
|
// "AI-tool URL parameters"). Kept in sync with the `ai-utm-source`
|
|
// detector category in patterns.js.
|
|
const AI_URL_PARAMS = /[?&](?:utm_source=(?:chatgpt\.com|openai(?:\.com)?|copilot\.com|claude\.ai|perplexity\.ai|gemini\.google\.com|grok\.com)|referrer=grok\.com)\b/gi;
|
|
|
|
function extractAll(re, text) {
|
|
const out = [];
|
|
const rx = new RegExp(re.source, re.flags.includes('g') ? re.flags : re.flags + 'g');
|
|
let m;
|
|
while ((m = rx.exec(text)) !== null) {
|
|
out.push(m[1] !== undefined ? m[1] : m[0]);
|
|
if (m.index === rx.lastIndex) rx.lastIndex++;
|
|
}
|
|
return out;
|
|
}
|
|
|
|
/**
|
|
* Blank out fenced code and inline code before scanning prose-level
|
|
* constructs. Without this, a URL inside a code sample counts twice and a
|
|
* `|` in a code block reads as a table row.
|
|
*/
|
|
function maskCode(text) {
|
|
return text
|
|
.replace(FENCED_CODE, (block) => block.replace(/[^\n]/g, ' '))
|
|
.replace(INLINE_CODE, (span) => ' '.repeat(span.length));
|
|
}
|
|
|
|
function normalizeUrl(u) {
|
|
return u.replace(AI_URL_PARAMS, '').replace(/[?&]$/, '');
|
|
}
|
|
|
|
/** Collapse cell padding so a re-aligned table isn't reported as edited. */
|
|
function normalizeTable(block) {
|
|
return block
|
|
.split('\n')
|
|
.map((row) => row.trim().replace(/\s*\|\s*/g, '|').replace(/-{2,}/g, '-'))
|
|
.join('\n');
|
|
}
|
|
|
|
function normalizeQuote(block) {
|
|
return block
|
|
.split('\n')
|
|
.map((line) => line.replace(/^[ \t]*>[ \t]?/, '').trimEnd())
|
|
.join('\n')
|
|
.trimEnd();
|
|
}
|
|
|
|
/**
|
|
* Indented code blocks are deliberately warning-level, not error-level.
|
|
* Four-space indentation is also how markdown continues a list item, and a
|
|
* validator that blocks a legitimate rewrite is worse than one that reports
|
|
* a soft signal. Only blocks that follow a blank line and are not inside a
|
|
* list are counted.
|
|
*/
|
|
function extractIndentedBlocks(text) {
|
|
const lines = text.split('\n');
|
|
const blocks = [];
|
|
let current = null;
|
|
let inList = false;
|
|
for (let i = 0; i < lines.length; i++) {
|
|
const line = lines[i];
|
|
const isIndented = /^(?: {4}|\t)\S/.test(line);
|
|
const isBlank = /^\s*$/.test(line);
|
|
if (/^\s*(?:[-*+]|\d+[.)])\s/.test(line)) inList = true;
|
|
else if (!isBlank && !isIndented) inList = false;
|
|
|
|
if (isIndented && !inList) {
|
|
if (current === null) current = [];
|
|
current.push(line);
|
|
} else if (!isBlank && current !== null) {
|
|
blocks.push(current.join('\n'));
|
|
current = null;
|
|
}
|
|
}
|
|
if (current !== null) blocks.push(current.join('\n'));
|
|
return blocks;
|
|
}
|
|
|
|
function counts(list) {
|
|
const map = new Map();
|
|
for (const item of list) map.set(item, (map.get(item) || 0) + 1);
|
|
return map;
|
|
}
|
|
|
|
/** Items present in `a` more often than in `b`. */
|
|
function missingFrom(a, b) {
|
|
const have = counts(b);
|
|
const out = [];
|
|
for (const item of a) {
|
|
const n = have.get(item) || 0;
|
|
if (n === 0) out.push(item);
|
|
else have.set(item, n - 1);
|
|
}
|
|
return out;
|
|
}
|
|
|
|
function sample(list, n = 5) {
|
|
const shown = list.slice(0, n);
|
|
return shown.join(', ') + (list.length > n ? ` (+${list.length - n} more)` : '');
|
|
}
|
|
|
|
function wordCount(text) {
|
|
const words = maskCode(text).trim().match(/\S+/g);
|
|
return words ? words.length : 0;
|
|
}
|
|
|
|
/**
|
|
* @param {string} original text as the writer supplied it
|
|
* @param {string} rewritten text the skill produced
|
|
* @param {object} [options]
|
|
* @param {object} [options.detector] AIDetector instance; defaults to
|
|
* requiring ./patterns.js when available
|
|
* @param {boolean} [options.skipResidual] skip the "patterns must not grow"
|
|
* check (used by structure-only tests)
|
|
* @param {number} [options.maxShrinkRatio] warn when the rewrite drops more
|
|
* than this fraction of words (default 0.4)
|
|
* @returns {{ok: boolean, errors: Array, warnings: Array, stats: object}}
|
|
*/
|
|
function validate(original, rewritten, options = {}) {
|
|
const errors = [];
|
|
const warnings = [];
|
|
const err = (code, message) => errors.push({ code, message });
|
|
const warn = (code, message) => warnings.push({ code, message });
|
|
|
|
if (typeof original !== 'string' || typeof rewritten !== 'string') {
|
|
throw new TypeError('validate(original, rewritten): both arguments must be strings');
|
|
}
|
|
|
|
// Every extractor above anchors on a bare \n. A Windows-authored document
|
|
// arrives with CRLF, so YAML_FRONTMATTER and TABLE_BLOCK match nothing and
|
|
// their protected content becomes invisible here: frontmatter could be
|
|
// rewritten and validate() still returned ok. See the CRLF must-fire cases
|
|
// in validate.test.js. Normalize once, up front, so extraction sees one
|
|
// line-ending shape. A rewrite that only re-terminates CRLF lines is not a
|
|
// preservation failure, but a lone carriage return can be meaningful code
|
|
// content and must remain visible to the exact-content comparisons.
|
|
original = original.replace(/\r\n/g, '\n');
|
|
rewritten = rewritten.replace(/\r\n/g, '\n');
|
|
|
|
// ── Fenced code: exact, in order. Code is never the skill's business. ──
|
|
const origFenced = extractAll(FENCED_CODE, original);
|
|
const newFenced = extractAll(FENCED_CODE, rewritten);
|
|
if (origFenced.length !== newFenced.length) {
|
|
err('code-block-count', `Fenced code blocks changed in number: ${origFenced.length} → ${newFenced.length}.`);
|
|
} else if (origFenced.some((block, i) => block !== newFenced[i])) {
|
|
const changed = origFenced.findIndex((block, i) => block !== newFenced[i]);
|
|
err('code-block-modified', `Fenced code block #${changed + 1} was modified.`);
|
|
}
|
|
|
|
// ── YAML frontmatter: exact. ──
|
|
const origYaml = original.match(YAML_FRONTMATTER);
|
|
const newYaml = rewritten.match(YAML_FRONTMATTER);
|
|
if ((origYaml ? origYaml[0] : null) !== (newYaml ? newYaml[0] : null)) {
|
|
err('frontmatter-modified', 'YAML frontmatter was modified, added, or removed.');
|
|
}
|
|
|
|
const origProse = maskCode(original);
|
|
const newProse = maskCode(rewritten);
|
|
|
|
// ── Blockquotes: someone else's words. ──
|
|
const origQuotes = extractAll(BLOCKQUOTE_BLOCK, origProse).map(normalizeQuote);
|
|
const newQuotes = extractAll(BLOCKQUOTE_BLOCK, newProse).map(normalizeQuote);
|
|
const lostQuotes = missingFrom(origQuotes, newQuotes);
|
|
if (lostQuotes.length) {
|
|
err('blockquote-modified', `Blockquote content was modified or removed (${lostQuotes.length} block(s)). Quoted material is attributed to someone else.`);
|
|
}
|
|
|
|
// ── Tables: reference content, not prose. ──
|
|
const origTables = extractAll(TABLE_BLOCK, origProse).map(normalizeTable);
|
|
const newTables = extractAll(TABLE_BLOCK, newProse).map(normalizeTable);
|
|
const lostTables = missingFrom(origTables, newTables);
|
|
if (lostTables.length) {
|
|
err('table-modified', `Markdown table content was modified or removed (${lostTables.length} table(s)).`);
|
|
}
|
|
|
|
// ── Inline code: identifiers, flags, filenames. ──
|
|
const lostInline = missingFrom(extractAll(INLINE_CODE, original), extractAll(INLINE_CODE, rewritten));
|
|
if (lostInline.length) {
|
|
err('inline-code-missing', `Inline code removed: ${sample(lostInline)}`);
|
|
}
|
|
|
|
// ── URLs, compared with AI tracking parameters stripped from both sides. ──
|
|
const origUrls = [
|
|
...extractAll(URL, origProse),
|
|
...extractAll(MD_LINK_TARGET, origProse),
|
|
].map(normalizeUrl);
|
|
const newUrls = [
|
|
...extractAll(URL, newProse),
|
|
...extractAll(MD_LINK_TARGET, newProse),
|
|
].map(normalizeUrl);
|
|
const lostUrls = missingFrom(origUrls, newUrls);
|
|
if (lostUrls.length) {
|
|
err('url-missing', `URL removed or altered: ${sample(lostUrls)}`);
|
|
}
|
|
|
|
// ── Filesystem paths. ──
|
|
const lostPaths = missingFrom(extractAll(PATH, origProse), extractAll(PATH, newProse));
|
|
if (lostPaths.length) {
|
|
err('path-missing', `File path removed or altered: ${sample(lostPaths)}`);
|
|
}
|
|
|
|
// ── Headings: structure is an error, wording is a warning. ──
|
|
const origHeadings = [];
|
|
const newHeadings = [];
|
|
for (const [, hashes, text] of original.matchAll(MD_HEADING)) origHeadings.push({ level: hashes.length, text });
|
|
for (const [, hashes, text] of rewritten.matchAll(MD_HEADING)) newHeadings.push({ level: hashes.length, text });
|
|
if (origHeadings.length !== newHeadings.length) {
|
|
err('heading-count', `Heading count changed: ${origHeadings.length} → ${newHeadings.length}. Restructuring the document is out of scope for a rewrite.`);
|
|
} else {
|
|
const levelDrift = origHeadings.findIndex((h, i) => h.level !== newHeadings[i].level);
|
|
if (levelDrift !== -1) {
|
|
err('heading-level', `Heading nesting changed at heading #${levelDrift + 1}: h${origHeadings[levelDrift].level} → h${newHeadings[levelDrift].level}.`);
|
|
}
|
|
const reworded = origHeadings.filter((h, i) => h.text !== newHeadings[i].text);
|
|
if (reworded.length) {
|
|
warn('heading-text', `${reworded.length} heading(s) reworded. Expected when fixing Title Case or removing emoji; check nothing else moved.`);
|
|
}
|
|
}
|
|
|
|
// ── Numbers: SKILL.md says preserve specific technical details. ──
|
|
const lostNumbers = missingFrom(extractAll(NUMBER, origProse), extractAll(NUMBER, newProse));
|
|
if (lostNumbers.length) {
|
|
warn('number-missing', `Figures present in the original are absent from the rewrite: ${sample(lostNumbers)}. Legitimate when a numeral was spelled out; a fabrication risk otherwise.`);
|
|
}
|
|
|
|
// ── Volume: a rewrite that halves the text probably dropped content. ──
|
|
const origWords = wordCount(original);
|
|
const newWords = wordCount(rewritten);
|
|
const maxShrink = options.maxShrinkRatio == null ? 0.4 : options.maxShrinkRatio;
|
|
if (origWords > 0 && newWords / origWords < 1 - maxShrink) {
|
|
warn('large-shrink', `Rewrite dropped ${Math.round((1 - newWords / origWords) * 100)}% of the words (${origWords} → ${newWords}). Check for lost content.`);
|
|
}
|
|
|
|
// ── Residual patterns: the rewrite must not introduce new tells. ──
|
|
let residual = null;
|
|
if (!options.skipResidual) {
|
|
let detector = options.detector;
|
|
if (!detector && typeof require === 'function') {
|
|
try {
|
|
detector = require('./patterns.js');
|
|
} catch (_) {
|
|
detector = null;
|
|
}
|
|
}
|
|
if (detector && typeof detector.analyzeText === 'function') {
|
|
const before = detector.analyzeText(original);
|
|
const after = detector.analyzeText(rewritten);
|
|
residual = {
|
|
issuesBefore: before.issues.length,
|
|
issuesAfter: after.issues.length,
|
|
scoreBefore: before.score,
|
|
scoreAfter: after.score,
|
|
};
|
|
if (after.issues.length > before.issues.length) {
|
|
err('residual-grew', `Rewrite introduced AI patterns: ${before.issues.length} → ${after.issues.length} flagged issues. A rewrite may leave patterns behind; it may not add them.`);
|
|
}
|
|
}
|
|
}
|
|
|
|
return {
|
|
ok: errors.length === 0,
|
|
errors,
|
|
warnings,
|
|
stats: {
|
|
wordsBefore: origWords,
|
|
wordsAfter: newWords,
|
|
fencedBlocks: origFenced.length,
|
|
headings: origHeadings.length,
|
|
indentedBlocks: extractIndentedBlocks(original).length,
|
|
residual,
|
|
},
|
|
};
|
|
}
|
|
|
|
/** Human-readable one-liner per finding, for CLI and skill output. */
|
|
function formatResult(result) {
|
|
const lines = [];
|
|
lines.push(result.ok ? 'PASS — preservation checks clear' : `FAIL — ${result.errors.length} preservation error(s)`);
|
|
for (const e of result.errors) lines.push(` error [${e.code}] ${e.message}`);
|
|
for (const w of result.warnings) lines.push(` warning [${w.code}] ${w.message}`);
|
|
return lines.join('\n');
|
|
}
|
|
|
|
return { validate, formatResult, maskCode, extractIndentedBlocks };
|
|
})();
|
|
|
|
if (typeof module !== 'undefined' && module.exports) {
|
|
module.exports = AIDetectorValidate;
|
|
}
|
|
|
|
// CLI: node detector/validate.js <original> <rewritten>
|
|
// Exits 1 on a preservation error so it can gate an edit-mode run in CI.
|
|
if (typeof require !== 'undefined' && typeof module !== 'undefined' && require.main === module) {
|
|
const fs = require('node:fs');
|
|
const [origPath, newPath] = process.argv.slice(2);
|
|
if (!origPath || !newPath) {
|
|
console.error('usage: node detector/validate.js <original-file> <rewritten-file>');
|
|
process.exit(2);
|
|
}
|
|
const result = AIDetectorValidate.validate(
|
|
fs.readFileSync(origPath, 'utf8'),
|
|
fs.readFileSync(newPath, 'utf8'),
|
|
);
|
|
console.log(AIDetectorValidate.formatResult(result));
|
|
process.exit(result.ok ? 0 : 1);
|
|
}
|