← Files Avoid AI WritingARCHIVED FILE

skills/preservation-verifier/scripts/validate.js

15.4 KB · Oct 2, 2026 · 00:32 UTC

↓ Download file

/**
 * 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);
}

SHA-256: e2a65bb9d0e543cdf4ebf2aa621a60c65c653aba555504fd41486e289c7f55fb