#!/usr/bin/env node "use strict"; // unslop-check — mechanical AI-tell checker (ms-unslop subset + SYSTEM.md phrase bans). // Plain JS, no deps, so pi extensions (jiti) and Claude Code hook scripts (node CLI) // share one implementation. // // The lists live in lists.json beside this file: committed machine source with // per-entry provenance (which ms-unslop pattern or SYSTEM.md rule each entry // mechanizes), the mention convention, and the punct thresholds. The loader // hard-fails closed: an empty, unparseable, or schema-invalid lists.json throws, // and the CLI exits 2 so a broken gate is never mistaken for a clean verdict. // // CLI: node unslop-check.js (or stdin) // exit 0 = clean, exit 1 = violations found (findings printed as JSON), // exit 2 = gate broken (lists.json missing/empty/invalid; error on stderr). // Env: UNSLOP_LISTS= overrides the lists.json location (testing; reuse by // other harnesses sharing this file). // // Provenance note (2026-08-19): the inline lists this file carried before C1 // moved to lists.json unchanged — 16 words, 17 phrases, 3 punct rules, 1 pattern. // The suite pins those counts; a list edit without a test edit is a drift signal. const fs = require("node:fs"); const path = require("node:path"); function stripCode(text) { // Fenced blocks (``` or ~~~), then inline code spans. Code is quoted material, // not the agent's prose style. This is also the mention convention: a banned // item quoted as inline code is a mention and must not flag (see lists.json). return text .replace(/```[\s\S]*?```/g, " ") .replace(/~~~[\s\S]*?~~~/g, " ") .replace(/`[^`\n]*`/g, " "); } // ── lists.json loading and validation ───────────────────────────────────────── function validateLists(data) { const fail = (why) => { throw new Error(`lists.json invalid: ${why}`); }; if (typeof data !== "object" || data === null || Array.isArray(data)) fail("top level must be an object"); for (const k of ["version", "convention", "punctPolicy", "thresholds", "words", "phrases", "punct", "patterns"]) { if (!(k in data)) fail(`missing key: ${k}`); } if (typeof data.version !== "number" || data.version < 1) fail("version must be a number >= 1"); for (const k of ["convention", "punctPolicy"]) { if (typeof data[k] !== "string" || !data[k].trim()) fail(`${k} must be a non-empty string`); } const th = data.thresholds; if (typeof th !== "object" || th === null) fail("thresholds must be an object"); // Number.isFinite, not just typeof: JSON.parse of 1e999 yields Infinity, which // passes typeof-number and would silently disable the punct gate (review F1). if (!Number.isFinite(th.punctMinCount) || th.punctMinCount < 1) fail("thresholds.punctMinCount must be a finite number >= 1"); if (!Number.isFinite(th.punctDensityPer1k) || !(th.punctDensityPer1k > 0)) fail("thresholds.punctDensityPer1k must be a finite number > 0"); const seen = new Set(); const checkEntries = (arr, kind, extra) => { if (!Array.isArray(arr) || arr.length === 0) fail(`${kind} must be a non-empty array`); arr.forEach((e, i) => { const at = `${kind}[${i}]`; if (typeof e !== "object" || e === null) fail(`${at} must be an object`); if (typeof e.value !== "string" || !e.value.trim()) fail(`${at}.value must be a non-empty string`); if (typeof e.source !== "string" || !e.source.trim()) fail(`${at}.source must be a non-empty string (pattern id or system-md)`); if (extra) extra(e, at, fail); if (seen.has(`${kind}:${e.value}`)) fail(`duplicate ${kind} value: ${e.value}`); seen.add(`${kind}:${e.value}`); }); }; checkEntries(data.words, "words"); checkEntries(data.phrases, "phrases", (e, at, fail) => { // Phrase matching splits a lowercased haystack, so an uppercase letter in a // phrase value is a silently dead rule (review F3). Reject, do not silently // normalize: list edits should fail loud (D-a). if (e.value !== e.value.toLowerCase()) fail(`${at}.value must be lowercase; phrase matching lowercases the haystack: ${e.value}`); }); checkEntries(data.punct, "punct", (e, at, fail) => { if (typeof e.label !== "string" || !e.label.trim()) fail(`${at}.label must be a non-empty string`); if (!Array.isArray(e.chars) || e.chars.length === 0 || !e.chars.every((c) => typeof c === "string" && c.length === 1)) { fail(`${at}.chars must be a non-empty array of single-char strings`); } }); checkEntries(data.patterns, "patterns", (e, at, fail) => { if (typeof e.regex !== "string" || !e.regex.trim()) fail(`${at}.regex must be a non-empty string`); if (typeof e.flags !== "string") fail(`${at}.flags must be a string`); if (typeof e.detail !== "string" || !e.detail.trim()) fail(`${at}.detail must be a non-empty string`); try { new RegExp(e.regex, e.flags); } catch (err) { fail(`${at}.regex does not compile: ${err.message}`); } }); return data; } let cache = null; function loadLists(filePath) { if (cache && !filePath) return cache; const p = filePath || process.env.UNSLOP_LISTS || path.join(__dirname, "lists.json"); let raw; try { raw = fs.readFileSync(p, "utf8"); } catch (e) { throw new Error(`lists.json invalid: cannot read ${p}: ${e.message}`); } if (!raw.trim()) throw new Error(`lists.json invalid: empty file: ${p}`); let data; try { data = JSON.parse(raw); } catch (e) { throw new Error(`lists.json invalid: unparseable JSON: ${e.message}`); } const validated = validateLists(data); if (!filePath) cache = validated; return validated; } // ── checker ─────────────────────────────────────────────────────────────────── const escapeRegex = (s) => s.replace(/[.*+?^${}()|[\]\\]/g, "\\$&"); function checkText(raw) { const lists = loadLists(); const text = stripCode(String(raw)); // Phrase haystack: lowercased, then curly apostrophes normalized to ASCII. // This must be a SEPARATE string from `text`: punct counting reads the // original, so curly quotes still fire the punct rule (review F2 ordering). const phraseHay = text.toLowerCase().replace(/[\u2018\u2019]/g, "'"); const findings = []; for (const w of lists.words) { const re = new RegExp("\\b" + escapeRegex(w.value) + "\\b", "gi"); const count = (text.match(re) || []).length; if (count > 0) findings.push({ rule: "word", detail: `banned word "${w.value}" x${count}`, count }); } for (const p of lists.phrases) { const count = phraseHay.split(p.value).length - 1; if (count > 0) findings.push({ rule: "phrase", detail: `phrase "${p.value}" x${count}`, count }); } // Density-gated punctuation. The deliberate divergence from SYSTEM.md's // outright em-dash ban is documented in lists.json punctPolicy, not only here. for (const pc of lists.punct) { let count = 0; for (const ch of pc.chars) count += text.split(ch).length - 1; if (count < lists.thresholds.punctMinCount) continue; if (count / Math.max(text.length, 1) * 1000 < lists.thresholds.punctDensityPer1k) continue; findings.push({ rule: "punct", detail: `${pc.label} x${count} (density-gated)`, count }); } for (const pt of lists.patterns) { const flags = pt.flags.includes("g") ? pt.flags : pt.flags + "g"; const m = text.match(new RegExp(pt.regex, flags)); const count = m ? m.length : 0; if (count > 0) findings.push({ rule: "pattern", detail: `"${pt.detail}" x${count}`, count }); } return { clean: findings.length === 0, findings, charsChecked: text.length }; } module.exports = { checkText, stripCode, loadLists, validateLists }; if (require.main === module) { let input; try { input = process.argv[2] ? fs.readFileSync(process.argv[2], "utf8") : fs.readFileSync(0, "utf8"); } catch (e) { // An unreadable input must not exit 1: that is the violations code, and a // wrapper keying on rc alone would report slop-free for a file it never // read (review S3). console.error(`unslop-check: cannot read input: ${e.message}`); process.exit(2); } let result; try { result = checkText(input); } catch (e) { if (String(e.message).startsWith("lists.json invalid")) { console.error(`unslop-check: ${e.message}`); process.exit(2); } throw e; } console.log(JSON.stringify(result, null, 2)); process.exit(result.clean ? 0 : 1); }