Files
conorbronsdon__avoid-ai-wri…/detector/patterns.js

2772 lines
135 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.
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
/**
* Avoid AI Writing — detection engine (canonical source of truth)
* Implements regex, structural, and stylometric pattern detection. This repo's SKILL.md
* catalogs the human-editable pattern rules; this engine is the executable
* expression of the regex-detectable subset and extends it with stylometric and
* AI-tool-fingerprint detectors that don't make sense as skill prose
* (cross-paragraph burstiness, smart-punct signatures, function-word
* trigram entropy, low type-token ratio, AI-tool URL parameters,
* chatbot citation markup leaks, unfilled placeholders).
*
* Scoring model:
* Each category has a weight in the ISSUE_WEIGHTS table. Detection runs
* produce raw (possibly duplicate) issues which are then deduplicated by
* (type, text) pair. rawScore is the sum of category weights across the
* deduped list — so the number reflects the same distinct signals the
* user sees in the issue list.
*
* Weights are deliberately non-flat across severity tags. Cutoff
* disclaimers (10) and chatbot artifacts (8) weigh more than vague
* attributions (5), even though all three are tagged `critical`, because
* the skill treats them as stronger or weaker AI-origin signals.
*
* rawScore is then normalized to 0-100 via `log2(wordCount/50)` so longer
* texts don't accumulate unboundedly on the same density of patterns.
*/
const AIDetector = (() => {
// ═══ Tier 1 pre-pass: normalize bypass tricks ══════════════════════
//
// Humanizer tools and prompt-injection bypass techniques insert
// invisible / lookalike chars to defeat exact-string detectors. Strip
// them BEFORE pattern matching so "delve" with a Cyrillic 'е' still
// hits the Tier 1 list. Unicode ranges sourced from
// It-s-AI/llm-detection/detection/attacks/.
//
// Tracks what was stripped so the trinary classifier can use
// "normalization triggered" as a corroborating AI signal — humans don't
// paste ZWSPs into their own writing.
const CYRILLIC_LOOKALIKES = {
'а': 'a', 'е': 'e', 'о': 'o', 'р': 'p', 'с': 'c', 'х': 'x',
'у': 'y', 'к': 'k', 'м': 'm', 'н': 'h', 'в': 'b', 'т': 't',
'А': 'A', 'Е': 'E', 'О': 'O', 'Р': 'P', 'С': 'C', 'Х': 'X',
'У': 'Y', 'К': 'K', 'М': 'M', 'Н': 'H', 'В': 'B', 'Т': 'T',
};
const GREEK_LOOKALIKES = { 'ο': 'o', 'Ο': 'O', 'α': 'a', 'Α': 'A', 'ρ': 'p', 'Ρ': 'P' };
// ─── Source-coordinate mapping (issue #189) ─────────────────────────
//
// Each entry maps one code unit in the working string to the matching
// code unit in the caller's source. Deletion passes copy the entries for
// retained characters once, so interleaved or overlapping removals cannot
// double-count offsets. Masking and homoglyph replacement keep their input
// length and therefore keep the current map unchanged.
function identitySourceMap(length) {
return Array.from({ length }, (_, index) => index);
}
function appendMapRange(target, source, start, end) {
for (let index = start; index < end; index += 1) target.push(source[index]);
}
function remapFindingsToSource(issues, regions, sourceMap) {
for (const issue of issues) {
if (Number.isInteger(issue.index)) issue.index = sourceMap[issue.index];
}
for (const region of regions) {
region.start = sourceMap[region.start];
region.end = sourceMap[region.end - 1] + 1;
}
}
const ZERO_WIDTH_RE = /[​-‍⁠]/u;
const ZERO_WIDTH_GLOBAL_RE = /[​-‍⁠]/gu;
const HOMOGLYPH_GLOBAL_RE = /[Ѐ-ӿͰ-Ͽ]/gu;
const ROLEPLAY_VERBS_RE = /^(?:nods|sighs|laughs|smiles|frowns|shrugs|grins|winks|chuckles|gasps|pauses|thinks|wonders|whispers|shouts|gestures|raises|leans|turns|looks|glances|smirks|blinks|nodding|sighing|laughing|smiling|thinking|gesturing)\b/i;
const ROLEPLAY_MARKER_RE = /(?<!\*)\*([^*\n]{1,80}?)\*(?!\*)/gu;
function normalizeText(text, sourceMap) {
const flags = { zeroWidth: 0, homoglyph: 0, roleplay: 0 };
let out = text;
let map = Array.isArray(sourceMap) ? sourceMap : null;
// 1. Strip zero-width chars (ZWSP U+200B, ZWNJ U+200C, ZWJ U+200D,
// BOM U+FEFF, word joiner U+2060).
if (map) {
const chars = [];
const nextMap = [];
for (let i = 0; i < out.length; i += 1) {
if (ZERO_WIDTH_RE.test(out[i])) {
flags.zeroWidth += 1;
continue;
}
chars.push(out[i]);
nextMap.push(map[i]);
}
out = chars.join('');
map = nextMap;
} else {
out = out.replace(ZERO_WIDTH_GLOBAL_RE, () => {
flags.zeroWidth += 1;
return '';
});
}
// 2. Swap Cyrillic / Greek Latin-lookalike chars back to Latin so
// pattern matching catches obfuscated tokens.
out = out.replace(HOMOGLYPH_GLOBAL_RE, (m) => {
const swap = CYRILLIC_LOOKALIKES[m] ?? GREEK_LOOKALIKES[m];
if (swap) { flags.homoglyph++; return swap; }
return m;
});
// 3. Strip *roleplay-action* markers — paired *...* containing an
// action verb (nods, sighs, laughs, smiles, etc.) anchored to
// the start of the inner phrase. This is the actual chat-model
// artifact shape. Markdown `**bold**` is rejected by the
// lookbehind/lookahead; legitimate multi-word `*italic*` is
// preserved because the verb whitelist is narrow.
if (map) {
const chars = [];
const nextMap = [];
const matcher = new RegExp(ROLEPLAY_MARKER_RE.source, ROLEPLAY_MARKER_RE.flags);
let cursor = 0;
let match;
while ((match = matcher.exec(out)) !== null) {
if (!ROLEPLAY_VERBS_RE.test(match[1])) continue;
chars.push(out.slice(cursor, match.index));
appendMapRange(nextMap, map, cursor, match.index);
flags.roleplay += 1;
cursor = match.index + match[0].length;
}
chars.push(out.slice(cursor));
appendMapRange(nextMap, map, cursor, map.length);
out = chars.join('');
map = nextMap;
} else {
out = out.replace(ROLEPLAY_MARKER_RE, (m, inner) => {
if (ROLEPLAY_VERBS_RE.test(inner)) {
flags.roleplay += 1;
return '';
}
return m;
});
}
return map ? { text: out, flags, sourceMap: map } : { text: out, flags };
}
// ─── Tier 1: Always flag ───────────────────────────────────────────
const TIER1 = {
'delve': 'explore, dig into, look at',
'tapestry': 'describe the actual complexity',
'paradigm': 'model, approach, framework',
'beacon': 'rewrite entirely',
'robust': 'strong, reliable, solid',
'comprehensive': 'thorough, complete, full',
'cutting-edge': 'latest, newest, advanced',
'pivotal': 'important, key, critical',
'meticulous': 'careful, detailed, precise',
'meticulously': 'carefully, precisely',
'seamless': 'smooth, easy, without friction',
'seamlessly': 'smoothly, easily',
'game-changer': 'describe what changed',
'game-changing': 'describe what changed',
'nestled': 'is located, sits',
'vibrant': 'describe what makes it active',
'thriving': 'growing, active',
'bustling': 'busy, active',
'intricate': 'complex, detailed',
'intricacies': 'complexities, details',
'ever-evolving': 'changing, growing',
'enduring': 'lasting, long-running',
'daunting': 'hard, difficult',
'holistic': 'complete, full, whole',
'holistically': 'completely, fully',
'actionable': 'practical, useful, concrete',
'impactful': 'effective, significant',
'learnings': 'lessons, findings, takeaways',
'synergy': 'describe the combined effect',
'synergies': 'describe the combined effect',
'interplay': 'relationship, connection',
'symphony': 'describe the coordination',
'embrace': 'adopt, accept, use',
};
// Multi-word tier 1 phrases
const TIER1_PHRASES = [
{ pattern: /\bdelve\s+into\b/gi, replace: 'explore, dig into' },
{ pattern: /\blandscape\b/gi, replace: 'field, space, industry', filter: true },
{ pattern: /\brealm\b/gi, replace: 'area, field, domain' },
{ pattern: /\btestament\s+to\b/gi, replace: 'shows, proves' },
{ pattern: /\bleverag(?:e|es|ing|ed)\b/gi, replace: 'use' },
{ pattern: /\bwatershed\s+moment\b/gi, replace: 'turning point, shift' },
{ pattern: /\bmarking\s+a\s+pivotal\s+moment\b/gi, replace: 'state what happened' },
{ pattern: /\bthe\s+future\s+looks\s+bright\b/gi, replace: 'cut or say something specific' },
{ pattern: /\bonly\s+time\s+will\s+tell\b/gi, replace: 'cut or say something specific' },
{ pattern: /\bdespite\s+challenges[^.]*continues?\s+to\s+thrive\b/gi, replace: 'name the challenge and response' },
{ pattern: /\bdeep\s+dive\b/gi, replace: 'look at, examine' },
{ pattern: /\bdive\s+into\b/gi, replace: 'look at, examine' },
{ pattern: /\bunpack(?:ing)?\b/gi, replace: 'explain, break down' },
{ pattern: /\bcomplexities\b/gi, replace: 'name the actual problems' },
{ pattern: /\bthought\s+leader(?:ship)?\b/gi, replace: 'expert, authority' },
{ pattern: /\bbest\s+practices\b/gi, replace: 'what works, proven methods' },
{ pattern: /\bat\s+its\s+core\b/gi, replace: 'cut, just state it' },
{ pattern: /\bin\s+order\s+to\b/gi, replace: 'to', clarity: true },
{ pattern: /\bdue\s+to\s+the\s+fact\s+that\b/gi, replace: 'because', clarity: true },
{ pattern: /\bserves\s+as\b/gi, replace: 'is', clarity: true },
{ pattern: /\bfeatures\b/gi, replace: 'has, includes', filter: true, clarity: true },
{ pattern: /\bboasts\b/gi, replace: 'has', clarity: true },
{ pattern: /\butiliz(?:e|es|ing|ed)\b/gi, replace: 'use', clarity: true },
{ pattern: /\bshowcas(?:e|es|ing|ed)\b/gi, replace: 'show, demonstrate' },
{ pattern: /\bembark(?:s|ing|ed)?\b/gi, replace: 'start, begin' },
{ pattern: /\bcommenc(?:e|es|ing|ed)\b/gi, replace: 'start, begin', clarity: true },
{ pattern: /\bascertain(?:s|ing|ed)?\b/gi, replace: 'find out, determine', clarity: true },
{ pattern: /\bendeavou?r(?:s|ing|ed)?\b/gi, replace: 'effort, attempt, try', clarity: true },
{ pattern: /\bunderscor(?:es|ing|ed)\b/gi, replace: 'highlights, shows' },
// Hyphen required. The unhyphenated "load bearing" is ordinary English —
// "the load bearing down on the bridge" — where `bearing` is a participle,
// not part of a compound modifier. The tell is always hyphenated.
//
// Only match an immediately following abstract noun from this seed list.
// Unknown nouns, mixed physical/abstract nouns, and predicative uses pass:
// precision over recall (#56). Keep the lookahead out of the matched span.
{ pattern: /\bload-bearing\b(?=[ \t]+(?:assumptions?|claims?|invariants?|premises?|constraints?|dependenc(?:y|ies)|arguments?|abstractions?)\b)/gi, replace: 'essential, critical, or say what breaks if you remove it' },
];
// ─── Tier 2: Flag in clusters (2+ per paragraph) ──────────────────
const TIER2 = {
'harness': 'use, take advantage of',
'navigate': 'work through, handle',
'navigating': 'working through, handling',
'foster': 'encourage, support, build',
'elevate': 'improve, raise, strengthen',
'unleash': 'release, enable, unlock',
'streamline': 'simplify, speed up',
'empower': 'enable, let, allow',
'bolster': 'support, strengthen',
'spearhead': 'lead, drive, run',
'resonate': 'connect with, appeal to',
'resonates': 'connects with, appeals to',
'revolutionize': 'change, transform',
'facilitate': 'enable, help, allow',
'facilitates': 'enables, helps, allows',
'underpin': 'support, form the basis of',
'nuanced': 'specific, subtle, detailed',
'crucial': 'important, key, necessary',
'multifaceted': 'describe the actual facets',
'ecosystem': 'system, community, network',
'myriad': 'many, numerous',
'plethora': 'many, a lot of',
'encompass': 'include, cover, span',
'catalyze': 'start, trigger, accelerate',
'reimagine': 'rethink, redesign, rebuild',
'galvanize': 'motivate, rally, push',
'augment': 'add to, expand, supplement',
'cultivate': 'build, develop, grow',
'illuminate': 'clarify, explain, show',
'elucidate': 'explain, clarify',
'juxtapose': 'compare, contrast',
'transformative': 'describe what changed',
'transformation': 'describe what changed',
'cornerstone': 'foundation, basis, key part',
'paramount': 'most important, top priority',
'poised': 'ready, set, about to',
'burgeoning': 'growing, emerging',
'nascent': 'new, early-stage',
'quintessential': 'typical, classic, defining',
'overarching': 'main, central, broad',
'quietly': 'cut, or name the concrete contrast',
'underpinning': 'basis, foundation',
'underpinnings': 'basis, foundations',
'paradigm-shifting': 'describe what shifted',
};
// Conditional Tier 2 entries: everyday words whose AI tell is a specific
// significance collocation, not the word itself. They join a paragraph's
// Tier 2 cluster only when the collocation matches — bare uses ("deeply
// nested JSON", "cares deeply") never count, because the base rate of
// these words in innocent prose is far higher than the rest of the table
// and an unconditional entry measurably flags clean human writing.
const TIER2_CONDITIONAL = [
{
word: 'deeply',
pattern: /\bdeeply\s+(?:integrated|committed|rooted|personal|human|flawed|resonant|transformative|interconnected|ingrained|embedded|meaningful)\b/i,
suggestion: 'cut, or name what specifically runs deep',
},
];
// ─── Tier 3: Flag by density ───────────────────────────────────────
const TIER3 = [
'significant', 'significantly', 'innovative', 'innovation',
'effective', 'effectively', 'dynamic', 'dynamics',
'scalable', 'scalability', 'compelling', 'unprecedented',
'exceptional', 'exceptionally', 'remarkable', 'remarkably',
'sophisticated', 'instrumental',
'world-class', 'state-of-the-art', 'best-in-class',
// `verbatim` is usually redundant with the verb it modifies ("copies X
// verbatim" = "copies X"). It has a genuine term-of-art sense in legal,
// research, and QA registers ("verbatim transcript"), so it lives at Tier 3:
// density-gated, it only fires on overuse, not on a single legitimate use.
'verbatim',
];
// Multi-word Tier 3 phrases. Density-gated like single Tier 3 words because
// these legitimately show up in human crypto/web3/dev writing. Threshold is
// intentionally lower than single-word Tier 3 (≥2 occurrences of the same
// phrase) — repeating the same multi-word boilerplate is a stronger AI tell
// than re-using "significant."
const TIER3_PHRASES = [
/\bemerging\s+(?:sector|space|category|industry)\b/gi,
/\bthe\s+integration\s+of\b/gi,
/\bthe\s+intersection\s+of\b/gi,
/\bcommunity-?driven\b/gi,
/\blong-?term\s+sustainability\b/gi,
/\buser\s+engagement\b/gi,
/\bdecentralized\s+compute\b/gi,
/\b(?:sustainable\s+)?reward\s+emissions?\b/gi,
/\btokenized\s+incentive\s+structures?\b/gi,
/\bdesigned\s+for\s+long-?term\b/gi,
];
// O(1) lookup from any token form (hyphenated or dashless) to its canonical
// Tier 3 word. Counting originally did nested-loop word matching which was
// O(tokens × TIER3) — slow on long pastes.
const TIER3_LOOKUP = new Map();
for (const word of TIER3) {
TIER3_LOOKUP.set(word, word);
const dashless = word.replace(/-/g, '');
if (dashless !== word) TIER3_LOOKUP.set(dashless, word);
}
// Per-category score weights. Applied to distinct (deduplicated) issues so
// the score reflects the same signals the user sees in the issue list.
// Non-uniform on purpose: critical rules like cutoff disclaimers (×10) and
// chatbot artifacts (×8) weigh more than vague attributions (×5), even
// though all three are tagged `critical`.
const ISSUE_WEIGHTS = {
tier1: 5,
// Wordiness, not an AI-frequency marker. Weighted like tier2 so a
// clarity fix cannot push a document toward an AI classification.
'tier1-clarity': 3,
tier2: 3,
tier3: 2,
transition: 2,
chatbot: 8,
sycophantic: 8,
filler: 2,
'generic-conclusion': 3,
'lets-construction': 2,
'reasoning-artifact': 6,
'acknowledgment-loop': 3,
'significance-inflation': 4,
'vague-attribution': 5,
'hollow-intensifier': 2,
'emotional-flatline': 2,
'lingering-attention': 3,
'novelty-inflation': 3,
'cutoff-disclaimer': 10,
'template-phrase': 3,
'false-concession': 2,
'rhetorical-question': 2,
'confidence-calibration': 2,
// Writing-quality guidance whose authorship polarity changes across model
// generations. Keep the flag visible without moving authorship outputs.
'em-dash': 0,
uniformity: 5,
formatting: 3,
'tier3-phrase': 3,
// Structural / cluster signals are deliberately weighted high. Unlike
// single-vocabulary hits they're near-dispositive on social-length
// posts (a 15-hashtag block, a 6-item bullet-NP list, three distinct
// crypto-shill phrases stacked) and would otherwise be suppressed by
// the log2(words/50) length divisor on short pastes.
'tier3-phrase-cluster': 12,
'hashtag-stuff': 12,
'bullet-np-list': 10,
'hedge-stack': 6,
'future-narrative': 12,
'real-actual-inflation': 5,
// Social endorsement / CTA closer. Weighted like formulaic-opener: a
// strong single-hit social tell that the length divisor would
// otherwise wash out on a short LinkedIn-length post.
'social-cta-closer': 8,
// Performed-insight tics: single hits are common in human essays, so
// weighted like tier2 vocabulary — density does the classifying.
'performed-insight': 3,
// Negation chains are a strong single-hit structural tell.
'negation-chain': 5,
'dev-blog-boilerplate': 3,
'formulaic-opener': 8,
// Speculative scenario opener ("Imagine a world where…"). Weighted like
// formulaic-opener: a single strong opener tell the length divisor would
// otherwise wash out on a short post.
'speculative-opener': 8,
// Launch-copy introduction ("Enter X.", "Meet X, your new...").
// Weighted like the other single-hit opener tells: strong on the
// short launch posts where it actually appears.
'launch-intro': 8,
// Dramatized crowd contrast. Gated hard on the dismissive verb, so
// a hit is meaningful, but the surface shares words with ordinary
// narrative — weighted below the opener tells on purpose.
'crowd-contrast': 6,
// Fake-casual props (stage directions, wink asides). Near-costume
// when present; same class as the opener tells on short posts.
'fake-casual-prop': 8,
'title-case-header': 4,
'parenthetical-hedge': 3,
'smart-punct-signature': 6,
'punct-distribution': 6,
'fnword-trigram-entropy': 5,
'cross-para-burstiness': 5,
'normalization-flag': 9,
// Vocabulary-diversity signal (type-token ratio). Weighted modestly
// because the threshold (>=200 tokens AND TTR<0.4) is conservative;
// it stacks with structural signals to push borderline scores up.
'low-ttr': 3,
// AI-tool fingerprints. Weighted higher than statistical patterns
// because each is a near-definitive single-hit signal — the AI tool
// literally left its mark in the text. citation-markup ranks highest
// (smoking gun: the literal internal markup of ChatGPT/Grok/etc.),
// UTM tracking second (auto-appended by the tool to URLs it writes),
// placeholders third (strong but humans use bracketed slots in
// templates legitimately and forget them — still a publishing bug
// but slightly less definitive AI evidence).
'ai-placeholder': 10,
'ai-citation-markup': 15,
'ai-utm-source': 12,
// Curated P2 copyedits, not authorship evidence. Keep them visible in the
// issue list without moving the AI score, label, probabilities, or
// trinary classification on short documents.
'unnecessary-hyphenation': 0,
};
// ─── Transition phrases ────────────────────────────────────────────
const TRANSITIONS = [
/\bmoreover\b/gi,
/\bfurthermore\b/gi,
/\badditionally\b/gi,
/\bin\s+today'?s\b/gi,
/\bin\s+an\s+era\s+where\b/gi,
/\bit'?s\s+worth\s+noting\s+that\b/gi,
/\bnotably\b/gi,
/\bin\s+conclusion\b/gi,
/\bin\s+summary\b/gi,
/\bto\s+summarize\b/gi,
/\bwhen\s+it\s+comes\s+to\b/gi,
/\bat\s+the\s+end\s+of\s+the\s+day\b/gi,
/\bthat\s+(?:being\s+)?said\b/gi,
];
// ─── Chatbot artifacts ─────────────────────────────────────────────
const CHATBOT_ARTIFACTS = [
/\bi\s+hope\s+this\s+helps\b/gi,
/\bcertainly!\b/gi,
/\babsolutely!\b/gi,
/\bgreat\s+question!\b/gi,
/\bexcellent\s+point!\b/gi,
/\bfeel\s+free\s+to\s+reach\s+out\b/gi,
/\blet\s+me\s+know\s+if\s+you\s+need\s+anything\b/gi,
/\bin\s+this\s+article,?\s+we\s+will\s+explore\b/gi,
/\blet'?s\s+dive\s+in!?\b/gi,
];
// ─── Sycophantic tone ──────────────────────────────────────────────
const SYCOPHANTIC = [
/\byou'?re\s+absolutely\s+right\b/gi,
/\bthat'?s\s+a\s+really\s+insightful\b/gi,
/\bthat'?s\s+a\s+great\s+question\b/gi,
/\bexcellent\s+question\b/gi,
];
// ─── Filler phrases ────────────────────────────────────────────────
const FILLERS = [
/\bit\s+is\s+important\s+to\s+note\s+that\b/gi,
/\bin\s+terms\s+of\b/gi,
/\bthe\s+reality\s+is\s+that\b/gi,
/\bit'?s\s+important\s+to\s+note\s+that\b/gi,
];
// ─── Generic conclusions ───────────────────────────────────────────
const GENERIC_CONCLUSIONS = [
/\bthe\s+future\s+looks\s+bright\b/gi,
/\bonly\s+time\s+will\s+tell\b/gi,
/\bone\s+thing\s+is\s+certain\b/gi,
/\bas\s+we\s+move\s+forward\b/gi,
];
// ─── "Let's" constructions ─────────────────────────────────────────
const LETS_PATTERNS = [
/\blet'?s\s+explore\b/gi,
/\blet'?s\s+take\s+a\s+look\b/gi,
/\blet'?s\s+break\s+this\s+down\b/gi,
/\blet'?s\s+examine\b/gi,
/\blet'?s\s+(?:consider|discuss|delve|unpack|walk\s+through)\b/gi,
];
// ─── Reasoning chain artifacts ─────────────────────────────────────
const REASONING_ARTIFACTS = [
/\blet\s+me\s+think\s+step\s+by\s+step\b/gi,
/\bbreaking\s+this\s+down\b/gi,
/\bto\s+approach\s+this\s+systematically\b/gi,
/\bhere'?s\s+my\s+thought\s+process\b/gi,
/\bfirst,?\s+let'?s\s+consider\b/gi,
/\bworking\s+through\s+this\s+logically\b/gi,
];
// ─── Acknowledgment loops ──────────────────────────────────────────
const ACKNOWLEDGMENT_LOOPS = [
/\byou'?re\s+asking\s+about\b/gi,
/\bthe\s+question\s+of\s+whether\b/gi,
/\bto\s+answer\s+your\s+question\b/gi,
];
// ─── Significance inflation ────────────────────────────────────────
const SIGNIFICANCE_INFLATION = [
/\bmarking\s+a\s+(?:pivotal|significant|important)\s+moment\b/gi,
/\ba\s+watershed\s+moment\s+for\b/gi,
/\bin\s+the\s+evolution\s+of\b/gi,
/\ba\s+(?:pivotal|defining)\s+moment\s+in\b/gi,
];
// ─── Vague attributions ────────────────────────────────────────────
const VAGUE_ATTRIBUTIONS = [
/\bexperts\s+(?:believe|say|suggest|agree)\b/gi,
/\bstudies\s+(?:show|suggest|indicate)\b/gi,
/\bresearch\s+(?:shows|suggests|indicates)\b/gi,
/\bindustry\s+leaders\s+(?:agree|believe|say)\b/gi,
];
// ─── Hollow intensifiers ──────────────────────────────────────────
const HOLLOW_INTENSIFIERS = [
/\bgenuine(?:ly)?\b/gi,
/\btruly\b/gi,
/\bquite\s+frankly\b/gi,
/\bto\s+be\s+honest\b/gi,
/\blet'?s\s+be\s+clear\b/gi,
];
// ─── Emotional flatline ────────────────────────────────────────────
// The "interesting (part|thing|aspect|piece)" family is matched in two
// shapes: (1) "the most interesting X" inline (the canonical AI list
// intro), and (2) bare "Interesting X:" used as a section-header opener,
// which is the section-break variant that slipped past v3.3.x.
const EMOTIONAL_FLATLINE = [
/\bwhat\s+surprised\s+me\s+most\b/gi,
/\bi\s+was\s+fascinated\s+to\b/gi,
/\bwhat\s+struck\s+me\s+was\b/gi,
/\bi\s+was\s+excited\s+to\s+learn\b/gi,
/\bthe\s+most\s+interesting\s+(?:part|thing|aspect|piece)\b/gi,
// Multiline flag (/m) so `^` matches at every line start, including
// position 0 of a pasted text that has no leading newline. The earlier
// `(?:^|\n)` form silently missed bare openers at the very start of
// input — caught by silent-failure audit 2026-05-16. Leading whitespace
// is `[ \t]*`, not `\s*`: with /m every line start is a match attempt,
// and a `\s*` that can cross newlines rescans the whole blank run from
// each of them, which made a long masked block quadratic (#235).
/^[ \t]*interesting\s+(?:part|thing|aspect|piece)(?:\s+of\s+(?:the\s+)?\w+)?\s*:/gim,
];
// ─── Lingering-attention claims ────────────────────────────────────
// The share-post opener that claims duration of attention instead of
// saying anything about the thing ("the line I keep coming back to").
//
// Precision note: the bare verb phrase "I keep coming back to X" is NOT
// matched on its own, because it is legitimate whenever a reason follows
// ("I keep coming back to Hirschman because it predicts who quits"), and
// the reason clause is not reliably detectable by regex. Only the
// noun-anchored frame ("the line/quote/bit ... I keep coming back to")
// fires, which is the shape that introduces a subject rather than
// asserting something about it. The bare form stays a skill-prose
// judgment call. See SKILL.md §Lingering-attention claims carve-out.
const LINGERING_ATTENTION = [
// "the line I keep coming back to", "the one quote that I keep coming back to"
// Noun-anchored frame only. "can't stop thinking about" is deliberately
// left to its own pattern below rather than folded into this alternation,
// so "the line I can't stop thinking about" scores once, not twice.
/\b(?:the|that|this)\s+(?:one\s+)?(?:line|quote|bit|part|idea|point|framing|comment|thing)\s+(?:that\s+)?i\s+keep\s+(?:coming\s+back\s+to|thinking\s+about)\b/gi,
/\bi\s+can'?t\s+stop\s+thinking\s+about\b/gi,
/\bstill\s+thinking\s+about\s+(?:this|that)\s+one\b/gi,
/\b(?:been|be)\s+rattling\s+around\s+(?:in\s+)?my\s+(?:head|brain)\b/gi,
/\bi'?ve\s+been\s+chewing\s+on\s+(?:this|that)\b/gi,
];
// ─── Novelty inflation ─────────────────────────────────────────────
const NOVELTY_INFLATION = [
/\bthe\s+failure\s+mode\s+nobody'?s?\s+naming\b/gi,
/\ba\s+problem\s+nobody\s+talks\s+about\b/gi,
/\bthe\s+insight\s+everyone'?s?\s+missing\b/gi,
/\bwhat\s+nobody\s+tells\s+you\b/gi,
];
// ─── Cutoff disclaimers ────────────────────────────────────────────
// Includes the canonical LLM self-identification phrases — these are
// near-dispositive on their own (real humans don't write "as an AI
// language model" in first person). Patterns cover the major model
// families' default disclaimer language.
const CUTOFF_DISCLAIMERS = [
/\bas\s+of\s+my\s+last\s+update\b/gi,
/\bas\s+of\s+my\s+(?:knowledge\s+)?(?:cut-?off|last\s+training)\b/gi,
/\bi\s+don'?t\s+have\s+access\s+to\s+real-?time\s+(?:data|information)\b/gi,
/\bbased\s+on\s+available\s+information\b/gi,
/\bas\s+an?\s+(?:ai|artificial\s+intelligence|large\s+language|ai\s+language)\s+(?:language\s+)?model\b/gi,
/\bi\s+(?:am|'m)\s+an?\s+(?:ai|artificial\s+intelligence|large\s+language)\s+(?:assistant|model)?\b/gi,
/\bi\s+cannot\s+(?:provide|give|offer)\s+(?:legal|medical|financial|professional)\s+advice\b/gi,
/\bmy\s+training\s+data\s+(?:only\s+)?(?:goes\s+up\s+to|extends\s+to|ends\s+(?:in|at))\b/gi,
];
// ─── AI-tool fingerprints ──────────────────────────────────────────
// Three near-definitive AI-origin signals adapted from
// Aboudjem/humanizer-skill P33-P35 (see docs/competitive/audits/
// 2026-05-17-aboudjem-humanizer-skill.md). Unlike the statistical
// patterns above, single hit on any of these is strong evidence —
// the AI tool literally left its fingerprint in the text.
// Unfilled slot-fill placeholders. Catches the canonical "[Your Name]"
// family plus dated stubs and HTML/MD comments with placeholder verbs.
const AI_PLACEHOLDERS = [
// Directive stubs ("[Your Name]", "[INSERT SOURCE URL]",
// "[Describe the specific section]") — verb-led, the user was
// told to fill in.
/\[(?:Your|Insert|Add|Enter|Describe|Specify|Choose|Pick)[^\]\n]{1,80}\]/gi,
// Noun-only template variables common in AI-generated email or
// letter boilerplate. Match the bare noun OR noun + qualifier.
// Conservative list: only nouns that almost never appear as
// bracketed real content (citation refs, code identifiers, etc.
// are excluded because they typically contain dots, slashes,
// hyphens, or version numbers).
/\[(?:Recipient|Sender|Topic|Subject|Salutation|Closing|Position|Department|Project Name|Company Name|Date)(?:\s+[^\]\n]{0,60})?\]/gi,
// All-caps directive forms ("[INSERT X]", "[FILL IN]") — the
// uppercase tells you it's a slot, not real content.
/\[(?:INSERT|FILL\s+IN|ADD|TODO|TBD|PLACEHOLDER)[^\]\n]{0,80}\]/g,
// Date stubs.
/\b(?:19|20)\d{2}-XX-XX\b/g,
/\bXX\/XX\/(?:19|20)\d{2}\b/g,
// HTML/Markdown comment placeholders with placeholder verbs.
/<!--\s*(?:add|fill\s+in|insert|todo|placeholder)[^>]{0,120}-->/gi,
];
// Chatbot citation/markup tokens that leak through copy-paste.
// `citeturn0search0` / `citeturn0news5` from ChatGPT, contentReference
// tokens, oai_citation, attached_file references, grok_card markers.
// Each is a near-definitive signature of a specific tool.
const AI_CITATION_MARKUP = [
/\bcite(?:turn|news|search|navigation)\d+(?:search|turn|news|navigation)\d+/gi,
/contentReference\s*\[oaicite:[^\]]+\]\s*\{[^}]*\}/gi,
/\boai_citation\b/gi,
/\[attached_file:\d+\]/gi,
/\bgrok_card\b/gi,
];
// UTM/tracking parameters auto-appended by AI tools to URLs they
// generate. Survives copy-paste even when nothing else does.
const AI_UTM_SOURCE = [
/[?&]utm_source=(?:chatgpt|openai|copilot|claude|grok|gemini|perplexity)(?:\.com|\.ai)?\b/gi,
/[?&]referrer=(?:chatgpt|copilot|grok|claude|gemini|perplexity)\.(?:com|ai)\b/gi,
];
// ─── Template phrases ──────────────────────────────────────────────
const TEMPLATE_PHRASES = [
/\ba\s+\w+\s+step\s+(?:towards?|forward\s+for)\b/gi,
/\bwhether\s+you'?re\s+\w+\s+or\s+\w+/gi,
/\bi\s+recently\s+had\s+the\s+pleasure\s+of\b/gi,
];
// ─── False concession ──────────────────────────────────────────────
const FALSE_CONCESSION = [
/\bwhile\s+\w+\s+is\s+impressive\b/gi,
/\balthough\s+\w+\s+has\s+made\s+strides\b/gi,
/\bdespite\s+\w+\s+challenges?\b/gi,
];
// ─── Rhetorical question openers ───────────────────────────────────
const RHETORICAL_QUESTIONS = [
/\bbut\s+what\s+does\s+this\s+mean\s+for\b/gi,
/\bso\s+why\s+should\s+you\s+care\b/gi,
/\bwhat'?s\s+next\?\s*/gi,
];
// ─── Hedge-stacked predictions ─────────────────────────────────────
// Stacks a modal with a hedge adverb: "could potentially create new
// opportunities", "may eventually unlock value." Either word alone is
// fine; the stack is the tell.
const HEDGE_STACK = [
// At most one intervening word, and never a negator. The old {0,2} gap
// matched ordinary English: "could not possibly" (plain emphatic
// negation) and inverted questions like "could a savage possibly" both
// fired. Measured on the human-control corpus, 3 of 4 hedge-stack flags
// were this over-match. See issue #69.
/\b(?:could|may|might)\s+(?:(?!not\b|never\b|hardly\b|scarcely\b|barely\b)\w+\s+)?(?:potentially|eventually|ultimately|possibly|conceivably)\b/gi,
/\b(?:potentially|eventually|ultimately)\s+(?:could|may|might)\b/gi,
];
// ─── Generic future-narrative closers ──────────────────────────────
// The "may become one of the most important narratives" template — vague
// future significance with no falsifiable claim. Covers narratives /
// stories / trends / themes / chapters / movements.
const FUTURE_NARRATIVE = [
/\b(?:may|could|will|is\s+(?:poised|set)\s+to)\s+become\s+(?:one\s+of\s+)?(?:the\s+)?(?:most\s+)?\w+\s+(?:narratives?|stories|developments?|trends?|movements?|chapters?|themes?|forces?)\b/gi,
/\bone\s+of\s+the\s+most\s+important\s+(?:narratives?|stories|trends?|themes?)\s+of\s+the\s+(?:next|coming)\s+\w+\b/gi,
];
// ─── "Real/actual" adjective inflation ─────────────────────────────
// "Real on-chain tokenomics", "actual reward sustainability" — using
// real/actual/genuine/true as an empty intensifier on an abstract noun
// to imply the rest of the field is fake/superficial.
const REAL_ACTUAL_INFLATION = [
/\b(?:real|actual|genuine|true)\s+(?:on-?chain\s+)?(?:tokenomics|economics|utility|adoption|sustainability|impact|revenue|fundamentals|demand|value|innovation|traction)\b/gi,
];
// ─── Formulaic openers ─────────────────────────────────────────────
// The "In the rapidly evolving world of X, Y has emerged as..." family
// — LLM-default essay openers.
const FORMULAIC_OPENERS = [
/\bin\s+the\s+(?:rapidly\s+|ever-?\s*)?(?:evolving|changing|expanding|growing|shifting)\s+(?:world|landscape|realm|space|field|domain|era)\s+of\b/gi,
/\bin\s+(?:an?|the)\s+(?:digital\s+)?age\s+(?:where|of)\b/gi,
/\bas\s+(?:we|the\s+world|society|industries?)\s+(?:continue|move|navigate|enter)\s+(?:to\s+)?(?:evolve|forward|into|through)\b/gi,
// "has emerged as a leader/force/category" — gated to the inflated
// nouns that signal pseudo-significance, since bare "has emerged as
// a" matches normal English ("Rust has emerged as a serious systems
// language"). Same gating for "has become increasingly".
/\bhas\s+emerged\s+as\s+(?:a|the|one\s+of)\s+(?:leading|key|major|critical|essential|fundamental|pivotal|prominent|dominant|important)\s+\w+/gi,
/\bhas\s+become\s+increasingly\s+(?:important|critical|popular|relevant|prominent|essential)\b/gi,
];
// ─── Speculative scenario openers ──────────────────────────────────
// "Imagine a world where...", "Picture a future in which...", "Envision
// a world where..." — the LLM habit of opening an argument with a
// hypothetical that lists desirable outcomes instead of making a claim.
// Gated to the world/future/reality object plus where/in-which so it
// stays off instructional "imagine you have an array" (a teaching device
// pointing at a concrete example, not a speculative world) and bare
// "imagine that" asides. "consider a scenario where…" is deliberately
// excluded: that is analytical framing common in technical reasoning,
// not the marketing-opener tell. An optional short comma interrupter
// catches the equally common "Imagine, for a moment, a world where…"
// cadence. Known accepted false positive: fiction openings and staged
// thought experiments match too — the engine has no fiction context
// mode, so that call is left to the skill's carve-out (highlight-only;
// a lone hit cannot flip a document's classification).
const SPECULATIVE_OPENERS = [
/\b(?:imagine|picture|envision)(?:\s*,[^,\n]{1,30},)?\s+a\s+(?:world|future|reality)\s+(?:where|in\s+which)\b/gi,
];
// ─── Launch-copy dramatic introductions ────────────────────────────
// "Meet Flowdesk, your new favorite treasury dashboard" / "Think
// Notion meets Figma" — the LLM-default product-introduction move
// in launch and announcement copy. Both surfaces are gated to the
// sentence-initial imperative followed by ONE capitalized token of 2
// to 30 characters, which is a recall limit: a two-token product name
// ("Meet North Star", "Think Google Docs meets Microsoft Word") is a
// deliberate miss. The Meet surface additionally requires one of four
// launch-copy heads — "your new favorite", "your new go-to", and
// "the new home/way/standard", the last three only when followed by
// "of" / "to" / "in|for" or by end-of-clause punctuation. Without
// that tail the head noun swallows a compound noun and ordinary prose
// fires: "Meet Rosa, the new home secretary" and "Meet Emma, the new
// way station manager" both matched before the tail was required.
// Bare "Meet Sarah, your new account manager" is how humans introduce
// colleagues, pets, and babies, so that form stays with the skill's
// judgment side. Two surfaces from
// the same family are deliberately NOT detected. "Say hello to X",
// because "Say hello to Grandma." is ordinary human prose. And bare
// "Enter X.", because the sentence-initial capitalized-noun form is
// how UI and doc instructions are written: "Enter Password.", "Enter
// Amount.", "Enter Username — your work email." Dropping the dash
// terminator does not reach the period-terminated class, and neither
// does a field-name denylist, so that surface stays with the skill's
// judgment side and the UI forms are pinned as must-not-fire
// fixtures. The anchors are lookbehinds so adjacent intros each
// count and the reported span starts at the tell itself.
const LAUNCH_INTROS = [
/(?<=^|[.!?]\s|\n)Meet\s+[A-Z][\w'-]{1,29}\s*,\s*(?:your\s+new\s+(?:favorite|go-to)\b|the\s+new\s+(?:home\s+of\b|way\s+to\b|standard\s+(?:in|for)\b|(?:standard|way|home)(?=\s*(?:[.!?,;:\u2013\u2014]|$))))/g,
/(?<=^|[.!?]\s|\n)[Tt]hink\s+[A-Z][\w'-]{1,29}\s+meets\s+[A-Z][\w'-]{1,29}\b/g,
];
// ─── Dramatized contrast against the crowd ─────────────────────────
// "shipped it in 2022, while everyone else was still debating
// timelines" — a claim propped on an implied lagging crowd. The gate
// is a dismissive verb PLUS the "was still" dramatization marker,
// because bare "while everyone else" is ordinary simultaneity ("she
// read while everyone else watched the movie") and even the
// dismissive verbs are ordinary English in literal use ("others
// debated the amendment" in wire copy). That gate is on this FIRST
// branch only. Its stems are restricted to the -ing form, so the
// adjective ("was still deliberate about the tradeoff"), the passive
// ("was still debated by pundits") and the bare present ("was still
// debates timelines") all stay clean — allowing e/es/ed let all
// three through. The other two branches carry no "was still"
// requirement: they match their own stereotyped wording ("writing
// think-pieces", "playing catch-up"), and the skill entry scopes the
// claim the same way. Verb stems carry explicit inflection tails so
// agent nouns ("the market speculators") and adverbs ("deliberately
// ignored") never match. Measured residue, all accepted: branch one
// fires on ANY literal progressive use of its verbs ("while the
// market was still speculating about the price"), not only on "was
// still debating"; branches two and three fire on literal contrasts
// of their own ("while everyone else wrote think-pieces from
// Washington", "while everyone else played catch-up in the spring").
// Recall is deliberately sacrificed: "was busy debating" without
// "still" stays a miss, per precision-over-recall.
const CROWD_CONTRAST = [
/\bwhile\s+(?:everyone\s+else|the\s+(?:industry|market|competition)|others)\s+(?:was|were|is|are)\s+still\s+(?:busy\s+)?(?:(?:debat|deliberat|hesitat|theoriz|philosophiz|pontificat|speculat|argu)ing|(?:dither|bicker)ing)\b/gi,
/\bwhile\s+(?:everyone\s+else|the\s+(?:industry|market|competition)|others)\s+(?:was\s+|were\s+)?(?:busy\s+)?(?:writing|wrote)\s+think-?\s?pieces\b/gi,
/\bwhile\s+(?:everyone\s+else|the\s+(?:industry|market|competition)|others)\s+(?:was\s+|were\s+|is\s+|are\s+)?(?:still\s+)?play(?:ed|ing|s)?\s+catch[-\s]?up\b/gi,
];
// ─── Fake-casual props (stage directions and wink asides) ──────────
// The regexable props from the fake-casual register: theatrical
// asterisk stage directions ("*checks notes*", "*chef's kiss*",
// "*mic drop*") and wink asides. Both lists are closed and short, and
// that is a recall limit: exactly six stage directions ("checks
// notes", "chef's kiss", "mic drop", "takes a deep breath", "sips
// coffee|tea", "nervous laughter") and exactly four parentheticals,
// the full (yes|no) x (really|seriously) grid. Neighbours in the same
// register are deliberate misses: "*checks calendar*" and "(yes,
// honestly)" do not fire. The kiss pattern requires the apostrophe —
// making it optional matched the ordinary sentence "At midnight,
// *chefs kiss* their spouses goodbye".
// The rest of the register (one-word verdict closers, label-prefix
// openers, the self-QA volley) needs register judgment and stays
// skill-only — "wild." is a word, not a regex target. "because of
// course …" joins them: a tense gate does not separate the wink from
// the ordinary human grumble, because "The build failed because of
// course it did." is that grumble in the same present-tense-plus-did
// form the wink uses. Under precision-over-recall the surface is
// judgment-only, and the grumble is pinned as a fixture. The kiss
// pattern accepts the curly apostrophe (U+2019) — the form smart
// punctuation and LLMs actually emit. Known accepted false positive:
// a human writer using a wink aside on purpose; the props are
// weighted as a strong single hit, not a classification by
// themselves.
const FAKE_CASUAL_PROPS = [
/\*\s?(?:checks\s+notes|chef['\u2019]s\s+kiss|mic\s+drop|takes\s+a\s+deep\s+breath|sips\s+(?:coffee|tea)|nervous\s+laughter)\s?\*/gi,
/\(\s?(?:yes|no)\s?,\s?(?:really|seriously)\s?\)/gi,
];
// ─── Performed-insight phrases ─────────────────────────────────────
// Essayist tics that announce profundity instead of delivering it.
// Curated noun/complement lists keep precision high: "the whole family"
// and "sit with him" are ordinary English and must not fire. Adapted
// from Simon Willison's LLM cliché highlighter
// (tools.simonwillison.net/llm-cliche-highlighter).
const PERFORMED_INSIGHT = [
/\bsit(?:s|ting)?\s+with\s+(?:that|this)(?=\s*(?:[.!?,;:)\u2013\u2014\u2019"']|for\s+a\s+(?:moment|minute|second|beat)\b|$))(?:\s+for\s+a\s+(?:moment|minute|second|beat))?/gi,
/\bsit(?:s|ting)?\s+with\s+(?:the|your)\s+(?:discomfort|tension|uncertainty|ambiguity|grief|unease)\b/gi,
/\b(?:that|this|it|which)(?:['\u2019]s|\s+(?:is|was))\s+not\s+nothing\b/gi,
/\byou\s+already\s+know\s+the\s+answer\b/gi,
/\b(?:do\s+not|don['\u2019]t)\s+(?:have\s+to\s+)?take\s+my\s+word\s+for\s+it\b/gi,
/(?<=^|[.!?]\s|\n)Turns\s+out\b/g,
/(?:['\u2019]s|\b(?:is|was|are|were))\s+the\s+(?:whole|entire)\s+(?:point|game|ballgame|trick|pitch|idea|play|business\s+model|value\s+proposition)\b/gi,
/\b(?:that|this)(?:['\u2019]s|\s+(?:is|was))\s+the\s+part\s+(?:that|I|you|we|nobody|no\s+one|most\s+people)\b/gi,
/\bthe\s+only\s+[\w'\u2019-]+\s+that\s+(?:matters|counts)\b/gi,
/\bis\s+dead\s*[.;,:\u2013\u2014]\s*long\s+live\b/gi,
/\b(?:that|this)(?:['\u2019]s|\s+(?:is|was))\s+why\s+[^.!?\n]{0,60}\s+mattered\b/gi,
];
// ─── Negation chains ───────────────────────────────────────────────
// "No fluff, no filler, no jargon" / "It didn't ask, didn't wait" /
// "Don't call it X. Call it Y." Precision guards, in order: the
// "no …" chain must open its sentence (mid-sentence inventories like
// "takes no arguments, no headers, and no body" are factual, not
// rhetorical); the "did not" chain must be comma-joined with the
// subject elided ("I did not sleep. I did not eat" is ordinary
// narration and stays clean); the stop-list keeps idiomatic pairs
// ("no more, no less", "no matter what") from firing. Adapted from
// Simon Willison's LLM cliché highlighter.
const NO_ITEM_STOP = "(?!matter\\b|one\\b|doubt\\b|longer\\b|way\\b|less\\b|more\\b|such\\b|other\\b|means\\b)";
const NO_ITEM_SECOND_STOP = "(?!(?:in|on|at|of|to|for|with|from|by|is|are|was|were|be|been|being|will|would|can|could|should|shall|may|might|must|have|has|had|do|does|did)\\b)";
const NEGATION_CHAIN = [
new RegExp(
"(?<=^|[.!?]\\s|\\n|[:\\u2013\\u2014]\\s)No\\s+" + NO_ITEM_STOP + "[a-z'\u2019-]+(?:\\s+" + NO_ITEM_SECOND_STOP + "[a-z'\u2019-]+)?" +
"(?:\\s*,\\s*(?:and\\s+|or\\s+|just\\s+)?no\\s+" + NO_ITEM_STOP + "[a-z'\u2019-]+(?:\\s+" + NO_ITEM_SECOND_STOP + "[a-z'\u2019-]+)?){2,}",
'gm'
),
/\b(?:did\s+not|didn['\u2019]t)\s+[a-z]+[^,.;!?\n]{0,20},\s*(?:did\s+not|didn['\u2019]t)\s+[a-z]+/gi,
/\b(?:do\s+not|don['\u2019]t)\s+(?:just\s+)?(\w+)\s+it\b[^.!?\n]{0,60}[.!?;:,][\s'"\u201d\u2019]*(?:just\s+)?\1\s+it\b/gi,
];
// ─── Dev-blog boilerplate ──────────────────────────────────────────
// Stock simplicity slogans from developer marketing. Adapted from
// Simon Willison's LLM cliché highlighter.
const DEV_BLOG_BOILERPLATE = [
/\bit\s+just\s+works\b(?!\s+out\b(?![-\s]+of[-\s]+the[-\s]+box\b))/gi,
/\bzero[-\s]config(?:uration)?\b/gi,
/\bsane\s+defaults\b/gi,
/\b(?:hold|fit|fits|holds)\s+in\s+your\s+head\b/gi,
];
// Function words whose presence MID-title marks the AI section-header shape.
// Word-anchored: without \b the "A" alternative matches inside any word and
// the guard silently degrades to "four tokens".
const FUNCTION_WORD = /\b(?:And|Or|Of|The|In|For|To|A|An)\b/;
// Must accept exactly what TITLE_CASE_HEADER accepts, or the prefix survives
// into the token count and reintroduces the ##-as-token bug.
const MD_HEADING_PREFIX = /^#{1,6}[ \t]+/;
/** Byte ranges covered by fenced code blocks, computed once per scan.
*
* A document that documents Markdown is the normal case for this rule -- a
* fenced `## Heading` example is illustration, not the author's own section
* header, and flagging it makes every docs page flag itself.
*
* This tracks the opening delimiter instead of counting them, because a
* parity count is wrong on the very case the rule exists for. CommonMark
* closes a fence only on the same character at the same length or longer, so
* a four-backtick fence wrapping a three-backtick example -- exactly how you
* document fences -- nests in practice, and counting delimiters inverts on
* it. Up to three spaces of indent are legal. An unclosed fence runs to end
* of document, matching how renderers treat it.
*
* Computed once per scan rather than rescanned per hit: the previous version
* sliced the whole document for every candidate, which is quadratic on a
* heading-dense file. */
function fenceRanges(text) {
const re = /^[ \t]{0,3}(`{3,}|~{3,})([^\n]*)$/gm;
const ranges = [];
let open = null;
let m;
while ((m = re.exec(text)) !== null) {
const marker = m[1];
if (!open) {
open = { char: marker[0], len: marker.length, start: m.index };
} else if (
marker[0] === open.char &&
marker.length >= open.len &&
/^[ \t]*\r?$/.test(m[2])
) {
ranges.push([open.start, m.index + m[0].length]);
open = null;
}
}
if (open) ranges.push([open.start, text.length]);
return ranges;
}
function inFenceRange(ranges, index) {
return typeof index === 'number' && ranges.some(([a, b]) => index >= a && index < b);
}
function blankRange(chars, start, end) {
for (let i = start; i < end && i < chars.length; i += 1) {
if (chars[i] !== '\n') chars[i] = ' ';
}
}
// Copy of the text with fenced blocks and inline code spans blanked out.
// Index-preserving: each masked character becomes a space and newlines are
// kept, so offsets into the result still address the same position in the
// original. For rules where a character inside code is something the author
// is quoting rather than using — a `#fff` in a CSS sample is not a tag.
// Fences are blanked first so their backticks cannot pair with a later
// inline span and swallow the prose between them.
function maskCode(text) {
const chars = text.split('');
for (const [a, b] of fenceRanges(text)) blankRange(chars, a, b);
// Indented code blocks are deliberately NOT masked. Four spaces is a code
// block only at top level; under a list marker it is a paragraph
// continuation, so blanking it silences real tag blocks. #90 reports
// fences and inline spans, and those are what this masks.
const withoutFences = chars.join('');
const inlineRe = /(`+)(?:(?!\1)[^\n])+\1/g;
let m;
while ((m = inlineRe.exec(withoutFences)) !== null) blankRange(chars, m.index, m.index + m[0].length);
return chars.join('');
}
function initialFrontmatterRange(text) {
const lines = [];
const lineRe = /[^\r\n]*(?:\r\n|\n|\r|$)/g;
let match;
while ((match = lineRe.exec(text)) !== null && match[0]) {
const body = match[0].replace(/(?:\r\n|\n|\r)$/, '');
lines.push({ body, start: match.index, end: match.index + body.length });
}
if (lines.length < 3 || !/^---[ \t]*$/.test(lines[0].body.replace(/^\uFEFF/, ''))) return null;
let closingLine = -1;
for (let i = 1; i < lines.length; i += 1) {
if (/^---[ \t]*$/.test(lines[i].body)) {
closingLine = i;
break;
}
}
if (closingLine === -1) return null;
// A pair of thematic breaks can also surround ordinary Markdown prose.
// Require the first substantive line to begin like a YAML mapping entry
// before treating the delimited block as frontmatter. Leading blank lines
// and YAML comments are valid, and the line parser accepts LF, CRLF, or CR.
const yamlKey = /^[ \t]*(?:[A-Za-z0-9_.-]+|"[^"\r\n]+"|'[^'\r\n]+')[ \t]*:/;
const firstContent = lines
.slice(1, closingLine)
.find((line) => line.body.trim() && !/^[ \t]*#/.test(line.body));
if (!firstContent || !yamlKey.test(firstContent.body)) return null;
return { start: 0, end: lines[closingLine].end };
}
// Mask HTML comments in source order while tracking the Markdown constructs
// that protect a literal `<!--`. A comment wins over code delimiters that
// occur inside it; a fence, code span, or top-level indented block that
// starts first wins over comment-looking text inside that code. Each source
// character participates in a bounded number of forward scans.
function maskHtmlCommentsOutsideCode(chars) {
const source = chars.join('');
const lines = source.split('\n');
let offset = 0;
let openFence = null;
let inIndentedBlock = false;
let previousBlank = true;
let listContext = false;
let maskedHtmlComments = 0;
const commentClosings = [];
let closingCursor = 0;
for (let i = 0; i <= source.length - 3; i += 1) {
if (source[i] === '-' && source[i + 1] === '-' && source[i + 2] === '>') {
commentClosings.push(i);
}
}
const backtickRuns = (line) => {
const runs = [];
for (let i = 0; i < line.length;) {
if (line[i] !== '`') {
i += 1;
continue;
}
const start = i;
while (i < line.length && line[i] === '`') i += 1;
runs.push({ start, end: i, length: i - start, next: -1 });
}
const nextByLength = new Map();
for (let i = runs.length - 1; i >= 0; i -= 1) {
runs[i].next = nextByLength.get(runs[i].length) ?? -1;
nextByLength.set(runs[i].length, i);
}
return runs;
};
for (const originalLine of lines) {
const lineEnd = offset + originalLine.length;
let visibleLine = chars.slice(offset, lineEnd).join('');
const fenceMatch = /^[ \t]{0,3}(`{3,}|~{3,})([^\n]*)$/.exec(visibleLine);
let fencedLine = false;
if (openFence) {
fencedLine = true;
if (
fenceMatch
&& fenceMatch[1][0] === openFence.char
&& fenceMatch[1].length >= openFence.length
&& /^[ \t]*\r?$/.test(fenceMatch[2])
) openFence = null;
} else if (fenceMatch) {
fencedLine = true;
openFence = { char: fenceMatch[1][0], length: fenceMatch[1].length };
}
const indented = /^(?: {4}|\t)\S/.test(visibleLine);
const indentedCode = !fencedLine
&& indented
&& (inIndentedBlock || (previousBlank && !listContext));
if (!fencedLine && !indentedCode) {
const runs = backtickRuns(visibleLine);
let runIndex = 0;
let cursor = 0;
while (cursor < visibleLine.length) {
while (runIndex < runs.length && runs[runIndex].start < cursor) runIndex += 1;
const commentIndex = visibleLine.indexOf('<!--', cursor);
const run = runs[runIndex];
if (run && (commentIndex === -1 || run.start < commentIndex)) {
if (run.next !== -1) {
cursor = runs[run.next].end;
runIndex = run.next + 1;
} else {
cursor = run.end;
runIndex += 1;
}
continue;
}
if (commentIndex === -1) break;
const openingIndex = offset + commentIndex;
while (
closingCursor < commentClosings.length
&& commentClosings[closingCursor] < openingIndex + 2
) closingCursor += 1;
const closingIndex = commentClosings[closingCursor] ?? -1;
const end = closingIndex === -1 ? source.length : closingIndex + 3;
if (closingIndex !== -1) closingCursor += 1;
blankRange(chars, openingIndex, end);
maskedHtmlComments += 1;
cursor = Math.min(visibleLine.length, end - offset);
}
}
visibleLine = chars.slice(offset, lineEnd).join('');
const layoutChars = visibleLine.split('');
if (fencedLine) {
blankRange(layoutChars, 0, layoutChars.length);
} else {
const inlineRe = /(`+)(?:(?!\1)[^\n])+\1/g;
let inlineMatch;
while ((inlineMatch = inlineRe.exec(visibleLine)) !== null) {
blankRange(layoutChars, inlineMatch.index, inlineMatch.index + inlineMatch[0].length);
}
}
const layoutLine = layoutChars.join('');
const blank = layoutLine.trim() === '';
if (indentedCode) inIndentedBlock = true;
else if (!blank) inIndentedBlock = false;
if (!blank && !indentedCode && !fencedLine) {
if (/^ {0,3}(?:[-*+]|\d{1,9}[.)])(?:\s|$)/.test(layoutLine)) listContext = true;
else if (/^\S/.test(layoutLine)) listContext = false;
}
previousBlank = blank;
offset = lineEnd + 1;
}
return maskedHtmlComments;
}
// Mask source-only Markdown spans while preserving source offsets. The
// detector can then score what a reader sees without making later issue
// indexes or sentence highlights point at the wrong source location.
function maskRenderedMarkdown(text) {
const chars = text.split('');
let maskedFrontmatter = 0;
const frontmatter = initialFrontmatterRange(text);
if (frontmatter) {
blankRange(chars, frontmatter.start, frontmatter.end);
maskedFrontmatter = 1;
}
const maskedHtmlComments = maskHtmlCommentsOutsideCode(chars);
return { text: chars.join(''), maskedFrontmatter, maskedHtmlComments };
}
function maskMultilineBlockquotes(text) {
const chars = text.split('');
const lines = [];
const lineRe = /[^\r\n]*(?:\r\n|\n|\r|$)/g;
let match;
while ((match = lineRe.exec(text)) !== null && match[0]) {
const body = match[0].replace(/[\r\n]+$/, '');
lines.push({ text: body, start: match.index, end: match.index + body.length });
}
let quotedLines = 0;
const isQuote = lines.map((line) => /^\s*>\s/.test(line.text));
for (let i = 0; i < lines.length; i += 1) {
if (isQuote[i] && ((isQuote[i - 1] && i > 0) || isQuote[i + 1])) {
blankRange(chars, lines[i].start, lines[i].end);
quotedLines += 1;
}
}
return { text: chars.join(''), quotedLines };
}
// Keep the historical deletion behavior for default plain mode. Paragraph-
// scoped rules depend on the surrounding lines being rejoined exactly this
// way, so changing this prepass would change scores for existing callers.
function stripMultilineBlockquotes(text, sourceMap) {
const rawLines = text.split(/\r?\n/);
const isQuote = rawLines.map((line) => /^\s*>\s/.test(line));
const stripIndexes = new Set();
for (let i = 0; i < rawLines.length; i += 1) {
if (isQuote[i] && ((isQuote[i - 1] && i > 0) || isQuote[i + 1])) stripIndexes.add(i);
}
const kept = rawLines
.map((_, index) => index)
.filter((index) => !stripIndexes.has(index));
const result = {
text: kept.map((index) => rawLines[index]).join('\n'),
quotedLines: stripIndexes.size,
};
if (!Array.isArray(sourceMap)) return result;
const lineStarts = [];
let offset = 0;
for (let i = 0; i < rawLines.length; i += 1) {
lineStarts.push(offset);
offset += rawLines[i].length;
if (i < rawLines.length - 1) {
if (text[offset] === '\r') offset += 1;
if (text[offset] === '\n') offset += 1;
}
}
const mapped = [];
for (let i = 0; i < kept.length; i += 1) {
const lineIndex = kept[i];
const start = lineStarts[lineIndex];
appendMapRange(mapped, sourceMap, start, start + rawLines[lineIndex].length);
if (i < kept.length - 1) {
const separatorStart = start + rawLines[lineIndex].length;
const newlineIndex = text[separatorStart] === '\r' ? separatorStart + 1 : separatorStart;
mapped.push(sourceMap[newlineIndex]);
}
}
return { ...result, sourceMap: mapped };
}
function maskTopLevelIndentedCode(chars, { listAware = false } = {}) {
const lines = chars.join('').split('\n');
let offset = 0;
let inBlock = false;
let previousBlank = true;
let listContext = false;
for (let i = 0; i < lines.length; i += 1) {
const line = lines[i];
const indented = /^(?: {4}|\t)\S/.test(line);
const blank = line.trim() === '';
const isCode = indented && (inBlock || (previousBlank && (!listAware || !listContext)));
if (isCode) {
blankRange(chars, offset, offset + line.length);
inBlock = true;
} else if (!blank) {
inBlock = false;
}
if (listAware && !blank && !isCode) {
if (/^ {0,3}(?:[-*+]|\d{1,9}[.)])(?:\s|$)/.test(line)) listContext = true;
else if (/^\S/.test(line)) listContext = false;
}
previousBlank = blank;
offset += line.length + 1;
}
}
function maskYamlFrontmatter(chars) {
const lines = chars.join('').split('\n');
const bare = (line) => line.replace(/\r$/, '');
const first = bare(lines[0]).replace(/^\uFEFF/, '');
if (first !== '---' || lines.length < 2 || /^\s*$/.test(bare(lines[1]))) return;
let closingLine = -1;
for (let i = 1; i < lines.length; i += 1) {
if (bare(lines[i]) === '---') {
closingLine = i;
break;
}
}
if (closingLine === -1) return;
let end = 0;
for (let i = 0; i <= closingLine; i += 1) {
end += lines[i].length;
if (i < lines.length - 1) end += 1;
}
blankRange(chars, 0, end);
}
function maskYamlMetadata(chars) {
const lines = chars.join('').split('\n');
let offset = 0;
let nestedAfterIndent = null;
for (const line of lines) {
const bare = line.replace(/\r$/, '');
// Lowercase keys are the common unfenced-YAML shape. Keeping this
// case-sensitive prevents prose labels such as "Note: ..." from being
// mistaken for metadata and silencing a real copyedit on the line.
const key = bare.match(/^([ \t]*)(?:-[ \t]+)?[a-z_][a-z0-9_.-]*[ \t]*:(.*)$/);
const indentation = (bare.match(/^[ \t]*/) || [''])[0].length;
let shouldMask = false;
if (key) {
shouldMask = true;
nestedAfterIndent = key[2].trim() === '' ? key[1].length : null;
} else if (nestedAfterIndent !== null && bare.trim() !== '' && indentation > nestedAfterIndent) {
shouldMask = true;
} else if (bare.trim() === '') {
nestedAfterIndent = null;
} else {
nestedAfterIndent = null;
}
if (shouldMask) blankRange(chars, offset, offset + line.length);
offset += line.length + 1;
}
}
function maskMarkdownTables(chars) {
const lines = chars.join('').split('\n');
// Tested against the trimmed line: the pattern already allows surrounding
// whitespace, and its adjacent `\s*` groups backtrack quadratically on a
// long whitespace run, so a line of masked comments or blank padding
// cost seconds before it was rejected (#235).
const delimiter = /^\|?\s*:?-{3,}:?\s*(?:\|\s*:?-{3,}:?\s*)+\|?$/;
const rows = new Set();
for (let i = 0; i < lines.length; i += 1) {
const candidate = lines[i].trim();
if (!candidate.includes('---') || !delimiter.test(candidate)) continue;
if (i > 0 && lines[i - 1].includes('|')) rows.add(i - 1);
rows.add(i);
for (let j = i + 1; j < lines.length && lines[j].includes('|'); j += 1) rows.add(j);
}
let offset = 0;
for (let i = 0; i < lines.length; i += 1) {
if (rows.has(i)) blankRange(chars, offset, offset + lines[i].length);
offset += lines[i].length + 1;
}
}
function maskDelimitedQuotes(chars, open, close, apostropheAware = false) {
const isWord = (char) => char !== undefined && /[a-z0-9]/i.test(char);
const isEscaped = (index) => {
let slashes = 0;
for (let i = index - 1; i >= 0 && chars[i] === '\\'; i -= 1) slashes += 1;
return slashes % 2 === 1;
};
let start = -1;
for (let i = 0; i < chars.length; i += 1) {
if (start === -1) {
if (chars[i] === open && !isEscaped(i) && (!apostropheAware || !isWord(chars[i - 1]))) start = i;
} else if (chars[i] === close && !isEscaped(i) && (!apostropheAware || !isWord(chars[i + 1]))) {
blankRange(chars, start, i + 1);
start = -1;
}
}
}
// Additional protected spans for the hyphenation copyedit. Unlike general
// AI-tell matching, this rule must not "correct" a literal spelling in a
// quote, URL, path, filename, command flag, or Markdown blockquote.
function maskHyphenationProtected(text) {
const chars = maskCode(text).split('');
maskTopLevelIndentedCode(chars);
maskYamlFrontmatter(chars);
maskYamlMetadata(chars);
maskMarkdownTables(chars);
maskDelimitedQuotes(chars, '"', '"');
maskDelimitedQuotes(chars, '“', '”');
maskDelimitedQuotes(chars, "'", "'", true);
maskDelimitedQuotes(chars, '‘', '’', true);
const maskMatches = (regex) => {
const source = chars.join('');
let match;
while ((match = regex.exec(source)) !== null) {
blankRange(chars, match.index, match.index + match[0].length);
}
};
maskMatches(/^[ \t]*>[^\n]*$/gm);
maskMatches(/\b(?:https?:\/\/|www\.)[^\s<>]+/gi);
maskMatches(/<[!?/]?[a-z][^>\n]*>/gi);
maskMatches(/(?<![a-z0-9_-])--?[a-z0-9][a-z0-9-]{0,127}/gi);
// Paths and filenames use bounded components. Besides preventing
// superlinear backtracking on long kebab blobs, the explicit prefix and
// trailing-slash forms cover single-component paths such as C:\\code-base,
// ~/code-base, /code-base, and code-base/.
maskMatches(/(?:[a-z]:[\\/]|\.{1,2}[\\/]|~[\\/]|[\\/])[a-z0-9_.-]{1,255}/gi);
maskMatches(/(?:[a-z]:[\\/]|(?:\.\.?[\\/])?)(?:[a-z0-9_.-]{1,64}[\\/]){1,128}[a-z0-9_.-]{1,64}/gi);
maskMatches(/\b[a-z0-9_.]{0,63}-[a-z0-9_.-]{1,64}[\\/]/gi);
maskMatches(/\b[a-z0-9_.-]{1,64}-[a-z0-9_.-]{1,64}\.[a-z0-9]{1,16}\b/gi);
// Technical identifiers that are distinguishable from ordinary prose:
// selectors, scoped packages, versioned tokens, assignments, and kebab
// names next to an explicit identifier cue. Plain lowercase compounds
// remain visible to the curated copyedit patterns below.
maskMatches(/[.#][a-z_][a-z0-9_.-]{0,63}-[a-z0-9_.-]{1,64}\b/gi);
maskMatches(/@[a-z0-9_.-]{1,64}\/[a-z0-9_.-]{1,64}-[a-z0-9_.-]{1,64}(?:@[^\s,;)\]}]{1,32})?/gi);
maskMatches(/\b[a-z0-9_.-]{1,64}-[a-z0-9_.-]{1,64}@[~^]?v?\d[a-z0-9*_.+-]{0,31}\b/gi);
maskMatches(/\b(?:[a-z0-9_.-]{0,64}\d[a-z0-9_.-]{0,64}-[a-z0-9_.-]{1,64}|[a-z0-9_.-]{1,64}-[a-z0-9_.-]{0,64}\d[a-z0-9_.-]{0,64})\b/gi);
maskMatches(/\b[a-z_][a-z0-9_.]{0,63}(?:-[a-z0-9_.]{1,64}){1,8}(?=[ \t]*[=:])/gi);
maskMatches(/\b[a-z_][a-z0-9_.]{0,63}(?:-[a-z0-9_.]{1,64}){1,8}(?=[ \t]+(?:npm[ \t]+)?(?:package|module|class|selector|config(?:uration)?[ \t]+key|key|identifier|property|setting|token|slug|command|option)\b)/gi);
maskMatches(/\b(?:(?:file(?:name)?|directory|folder|package|module|class|selector|config(?:uration)?[ \t]+key|identifier|property|setting|token|slug|command|option)(?:[ \t]+(?:named|called|is|was))?|key[ \t]+(?:named|called|is|was))[ \t]+(?:@[a-z0-9_.-]{1,64}\/)?[a-z_][a-z0-9_.]{0,63}(?:-[a-z0-9_.]{1,64}){1,8}\b/gi);
maskMatches(/\b(?:npm|pnpm|yarn)[ \t]+(?:add|install)[ \t]+(?:@[a-z0-9_.-]{1,64}\/)?[a-z0-9_.]{1,64}(?:-[a-z0-9_.]{1,64}){1,8}/gi);
return chars.join('');
}
function findUnnecessaryHyphenation(text) {
const scanText = maskHyphenationProtected(text);
const issues = [];
for (const entry of UNNECESSARY_HYPHENATION) {
const regex = new RegExp(entry.pattern.source, entry.pattern.flags);
let match;
while ((match = regex.exec(scanText)) !== null) {
issues.push({
type: 'unnecessary-hyphenation',
text: match[0],
severity: 'medium',
suggestion: typeof entry.suggestion === 'function'
? entry.suggestion(match[0])
: entry.suggestion,
});
}
}
return issues;
}
// ─── Forms that open with `#` but are not social tags ──────────────
// `#` is overloaded in technical prose and the hashtag rule counts every
// `#word` it sees, so these are subtracted before the threshold applies:
// #88, #1234 issue and PR references
// #1a2b3c CSS hex colours, 6 or 8 chars AND containing a digit
// #include, #ifndef C preprocessor directives
// Deliberately NOT carving out 3- and 4-digit hex: #dad, #cafe, #b2b, #e2e,
// #ace, #face and #bad are real tags, and subtracting them cost true
// positives on exactly the stuffed-block shape this rule exists to catch.
// Real palettes are dominated by 6-digit values, so a CSS paragraph still
// lands under the threshold without the short forms.
// `owner/repo#88` and URL fragments need no carve-out: the char before `#`
// is a word char, so the rule's own anchor already rejects them.
// Ambiguous word tags stay counted on purpose. `#general` as a channel and
// `#general` as a tag are the same token, and separating them needs a guess
// that costs more precision on real tag blocks than the carve-out buys.
// Requires at least one digit: #decade, #facade, #deadbeef are a-f words and
// real tags. Every actual palette value in the wild carries a digit.
const HEX_COLOUR = /^(?=[0-9a-f]*\d)(?:[0-9a-f]{6}|[0-9a-f]{8})$/i;
const CPP_DIRECTIVE = /^(?:include|define|undef|if|ifdef|ifndef|elif|else|endif|pragma|error|warning|line)$/;
function isSocialTag(tag) {
return !/^\d+$/.test(tag) && !HEX_COLOUR.test(tag) && !CPP_DIRECTIVE.test(tag);
}
// ─── Title Case Section Headers in non-technical prose ─────────────
// "Strategic Negotiations And Key Partnerships" — every content word
// capitalized. Acceptable in API docs, ML papers, news headlines. Tell
// in marketing/personal/blog prose. Skipped when contextMode is
// 'technical'; runs for general, marketing, and personal.
//
// The optional `#{1,6}` prefix is load-bearing (#62): without it the `^[A-Z]`
// anchor required the line to START with a capital, so `## Benefits And
// Strategic Considerations` never matched — the first character is `#`. The
// rule missed the single most common way a heading is actually written, while
// catching the bare-line form it is usually converted from. Reported by a
// downstream vendoring the detector.
//
// Setext headings (`Title`/`=====`) need no prefix: their text line is bare
// and already matched by this same pattern.
const TITLE_CASE_HEADER = /^(?:#{1,6}[ \t]+)?([A-Z][a-z]+(?:\s+(?:[A-Z][a-z]+|and|or|of|the|in|for|to|a|an))+\s+[A-Z][a-z]+)\s*$/gm;
// ─── Parenthetical hedging asides ──────────────────────────────────
// "(and increasingly, X)", "(or more precisely, Y)", "(though to be
// fair, Z)" — pseudo-aside that adds no information but performs
// thoughtfulness. Different from genuine human parentheticals which
// tend to be tangents or clarifications, not hedges.
const PARENTHETICAL_HEDGE = [
/\(\s*(?:and\s+)?(?:increasingly|notably|importantly|crucially|interestingly|perhaps)[,]?\s+[^)]{3,60}\)/gi,
/\(\s*or\s+more\s+(?:precisely|accurately|specifically)[,]?\s+[^)]{3,60}\)/gi,
/\(\s*though\s+to\s+be\s+fair[,]?\s+[^)]{3,60}\)/gi,
/\(\s*at\s+least\s+(?:in\s+)?(?:theory|principle|part)[,]?\s+[^)]{0,60}\)/gi,
];
// ─── Confidence calibration ────────────────────────────────────────
const CONFIDENCE_CALIBRATION = [
/\binterestingly\b/gi,
/\bsurprisingly\b/gi,
/\bimportantly\b/gi,
/\bsignificantly\b/gi,
/\bcertainly\b/gi,
/\bundoubtedly\b/gi,
/\bwithout\s+a\s+doubt\b/gi,
];
// ─── Social endorsement / CTA closers ──────────────────────────────
// The curatorial sign-off LLMs append to LinkedIn / X posts that share
// or recommend something — usually a colon teeing up a link. Distinct
// from the bare "worth reading" word-table entry (a single weak word)
// and from infomercial hooks (mid-flow teasers): this is the
// demonstrative-anchored endorsement — "THIS one is worth your time:",
// "do yourself a favor and read this", "thank me later" — that performs
// a recommendation without giving the reader a reason to click.
//
// Precision-first: each pattern carries an anchor so it stays off the
// literal-verb prose a human writes. The demonstrative object ("read
// THIS", not "read the runbook"), the trailing-terminal lookahead on
// "miss this" / "bookmark this" (the closing-line shape, not "miss this
// meeting" / "bookmark this page"), and the sentence-initial lookbehind
// on "thank me later" / "save this for later" (the imperative CTA, not
// "she will thank me later") all exist to suppress false positives on
// ordinary instructional/conversational text. Apostrophe classes admit
// the curly ' (U+2019) because LinkedIn / Word / macOS auto-curl it —
// the straight-only form would miss the canonical "you won't" closer.
const SOCIAL_CTA_CLOSER = [
/\bthis\s+one['’]?s?\s+(?:is\s+)?(?:well\s+|totally\s+|absolutely\s+|definitely\s+|really\s+|truly\s+|easily\s+|more\s+than\s+)?worth\s+(?:your\s+time|the\s+read|a\s+read|every\s+(?:minute|second)|reading|watching|a\s+listen|a\s+watch|a\s+look|it)\b/gi,
/\bthis\s+one['’]?s?\s+(?:is\s+)?a\s+must[-\s]?(?:read|watch|listen|see)\b/gi,
/\b(?:highly|strongly|can['’]?t|cannot)\s+recommend\w*\s+(?:giving\s+)?(?:this|it)\s+(?:one\s+)?a\s+(?:read|listen|watch|look|go)\b/gi,
/\bdo\s+yourself\s+a\s+favou?r\s+and\s+(?:read|watch|check\s+out)\s+(?:this|it)\b/gi,
/\byou\s+(?:really\s+)?(?:won['’]?t|do\s*n['’]?t|will\s+not|do\s+not)\s+want\s+to\s+miss\s+this(?:\s+one)?(?=\s*(?:[:.!\n]|$))/gi,
/(?<=^|[,.!?:\n]\s{0,4})(?:you\s+can\s+)?thank\s+me\s+later\b/gim,
/(?<=^|[.!?:\n]\s{0,4})save\s+this\s+(?:one\s+)?for\s+later\b/gim,
/\bbookmark\s+this(?:\s+(?:one|post|thread))?(?=\s*(?:[:.!\n]|$))/gi,
/\bdo\s*n['’]?t\s+sleep\s+on\s+this\b/gi,
/\btrust\s+me,?\s+(?:on\s+this|you['’]?ll)\b/gi,
];
// ─── Unnecessary hyphenation (#107) ───────────────────────────────
// Precision-first subclasses only. The general question of whether a
// compound modifier is established English needs editorial judgment and
// remains in SKILL.md; the engine covers only curated open/closed forms and
// compounds whose surrounding syntax makes the unhyphenated form clear.
const UNNECESSARY_HYPHENATION = [
// Welded open noun phrases reported in #107. Match the complete phrase so
// a project-specific spelling of the pair in another role is not swept in.
{ pattern: /\bresearch-impact\s+aggregat(?:or|ion)s?\b/g, suggestion: (match) => match.replace('research-impact', 'research impact') },
{ pattern: /\bdata-source\s+strateg(?:y|ies)\b/g, suggestion: (match) => match.replace('data-source', 'data source') },
{ pattern: /\bPython-package\s+usage\b/g, suggestion: (match) => match.replace('Python-package', 'Python package') },
{ pattern: /\bRust-crate\s+usage\b/g, suggestion: (match) => match.replace('Rust-crate', 'Rust crate') },
{ pattern: /\bsingle-Project\s+Manifest\b/g, suggestion: (match) => match.replace('single-Project', 'single Project') },
{ pattern: /\btotal-downloads\s+figures?\b/g, suggestion: (match) => match.replace('total-downloads', 'total downloads') },
{ pattern: /\blife-sciences-native\s+citation\s+count\b/g, suggestion: 'citation count from a life sciences source' },
// Compounds whose standard spelling is closed. Kept as a small curated
// list rather than guessing that every noun-noun pair should close up.
{ pattern: /\bcode-base\b/g, suggestion: (match) => match.replace('-', '') },
{ pattern: /\bdata-set\b/g, suggestion: (match) => match.replace('-', '') },
{ pattern: /\btime-frame\b/g, suggestion: (match) => match.replace('-', '') },
{ pattern: /\broad-map\b/g, suggestion: (match) => match.replace('-', '') },
// Attributive-only forms used adverbially or as nouns. The boundary after
// real-time / long-term is intentionally narrow: "real-time analytics"
// and "long-term plan" must not fire.
{
pattern: /\bin\s+real-time(?=\s*(?:[,.!?;:]|$)|\s+(?:(?:across|as|automatically|because|but|continuously|during|dynamically|every|for|from|immediately|instantly|on|simultaneously|through|throughout|until|via|when|while|with|without)\b))/gi,
suggestion: 'in real time',
},
{
pattern: /\b(?:for|over)\s+the\s+long-term(?=\s*(?:[,.!?;:]|$)|\s+(?:across|because|but|by|during|for|from|on|through|throughout|until|via|when|while|with|without)\b)/gi,
suggestion: (match) => match.replace(/long-term/i, 'long term'),
},
{
pattern: /\b(?:functions?|functioned|functioning|operates?|operated|operating|runs?|ran|running|works?|worked|working)\s+out-of-the-box\b/gi,
suggestion: (match) => match.replace(/out-of-the-box/i, 'out of the box'),
},
];
// ═══ Helpers ═══════════════════════════════════════════════════════
function tokenize(text) {
return text.toLowerCase().match(/[\w'-]+/g) || [];
}
function countWords(text) {
return (text.match(/\S+/g) || []).length;
}
function getParagraphs(text) {
return text.split(/\n\s*\n/).filter(p => p.trim().length > 0);
}
function getSentences(text) {
return text.split(/[.!?]+/).filter(s => s.trim().length > 5);
}
function matchPatterns(text, patterns, category, severity) {
const issues = [];
for (const pat of patterns) {
const regex = new RegExp(pat.source, pat.flags);
let match;
while ((match = regex.exec(text)) !== null) {
issues.push({
type: category,
text: match[0],
index: match.index,
severity,
suggestion: null,
});
}
}
return issues;
}
// ═══ Main analysis ═════════════════════════════════════════════════
// Upper bound for one scan. Above this we bail rather than running all
// regex passes over a huge buffer — protects page perf on pasted novels.
const MAX_WORDS = 10000;
// V2 contract defaults so early-exit paths (Empty/tooShort/tooLong)
// still return the same field shape a v2 consumer expects. Without
// this, `result.document_classification === 'AI_ONLY'` is `undefined`
// on edge inputs and fails open.
// UNSCORED is returned on empty / too-short / too-long inputs where
// we declined to score. Distinct from HUMAN_ONLY (which is a positive
// classification) so a caller can't mistake a refused scan for a
// confident human verdict — a 50k-word LLM-generated document is
// not "human", it's just outside our scoring window.
function buildV2Defaults(classification, confidence) {
const probs = classification === 'HUMAN_ONLY'
? { human: 1, mixed: 0, ai: 0 }
: classification === 'AI_ONLY'
? { human: 0, mixed: 0, ai: 1 }
: { human: 0.333, mixed: 0.334, ai: 0.333 };
return {
document_classification: classification,
class_probabilities: probs,
confidence_category: confidence,
highlight_sentence_for_ai: [],
};
}
function analyzeText(text, options = {}) {
if (!text || text.trim().length === 0) {
return { ...buildV2Defaults('UNSCORED', 'low'), score: 0, label: 'Empty', issues: [], stats: {}, tooShort: true };
}
// Map each working-string code unit back to the caller's source. Every
// length-changing preprocessing stage composes this map as it removes
// characters, and results are translated before they leave the function.
let sourceMap = identitySourceMap(text.length);
// Context mode selects context-appropriate flagging. Accepted values:
// 'general' (default) — full ruleset
// 'technical' — skip title-case headers; individual prose-only rules
// apply their own technical-context gates (only mode
// that currently changes scoring)
// 'marketing' — accepted; recorded in stats; scores same as general
// 'personal' — accepted; recorded in stats; scores same as general
// Invalid values fall back to 'general' with stats.contextModeFallback set.
// Mode validation: an unknown string (e.g. typo "tecnical") would
// otherwise silently downgrade to general-mode behavior. Coerce to
// 'general' and surface the original value in stats for traceability.
const VALID_CONTEXT_MODES = new Set(['general', 'technical', 'marketing', 'personal']);
const requestedMode = options.contextMode || 'general';
const contextMode = VALID_CONTEXT_MODES.has(requestedMode) ? requestedMode : 'general';
const contextModeFallback = requestedMode !== contextMode ? requestedMode : null;
// Source mode controls which parts of a Markdown file count as prose.
// Plain remains the compatibility default. Rendered Markdown masks only
// initial YAML frontmatter and HTML comments; source-hygiene checks for
// hidden TODO/placeholder comments remain available through plain mode.
const VALID_SOURCE_MODES = new Set(['plain', 'rendered-markdown']);
const requestedSourceMode = options.sourceMode === undefined ? 'plain' : options.sourceMode;
const sourceMode = VALID_SOURCE_MODES.has(requestedSourceMode) ? requestedSourceMode : 'plain';
const sourceModeFallback = requestedSourceMode !== sourceMode ? requestedSourceMode : undefined;
let maskedFrontmatter = 0;
let maskedHtmlComments = 0;
if (sourceMode === 'rendered-markdown') {
const rendered = maskRenderedMarkdown(text);
text = rendered.text;
maskedFrontmatter = rendered.maskedFrontmatter;
maskedHtmlComments = rendered.maskedHtmlComments;
}
// Pre-pass: mask Markdown blockquotes before scoring. A human
// reacting to AI text by quoting it shouldn't have the quoted block
// counted against their own writing. Requires ≥2 consecutive `> `
// lines to count as a blockquote — single-line `> ls -la` shell
// prompts in technical docs stay in the text. Masking instead of deleting
// keeps later issue and highlight offsets aligned with the source file.
const blockquotes = sourceMode === 'rendered-markdown'
? maskMultilineBlockquotes(text)
: stripMultilineBlockquotes(text, sourceMap);
text = blockquotes.text;
if (blockquotes.sourceMap) sourceMap = blockquotes.sourceMap;
const { quotedLines } = blockquotes;
// Pre-pass: strip bypass-trick chars before pattern matching so
// "delve" with a Cyrillic 'е' still hits Tier 1. Compose the map while
// deleting characters so later offsets still address the source.
const norm = normalizeText(text, sourceMap);
text = norm.text;
sourceMap = norm.sourceMap;
const wordCount = countWords(text);
if (wordCount < 10) {
return {
...buildV2Defaults('UNSCORED', 'low'),
score: 0,
label: 'Too short',
issues: [],
stats: { wordCount, contextMode, contextModeFallback, sourceMode, sourceModeFallback, maskedFrontmatter, maskedHtmlComments },
tooShort: true,
};
}
if (wordCount > MAX_WORDS) {
return {
...buildV2Defaults('UNSCORED', 'low'),
score: 0,
label: 'Text too long',
issues: [],
stats: { wordCount, contextMode, contextModeFallback, sourceMode, sourceModeFallback, maskedFrontmatter, maskedHtmlComments },
tooLong: true,
};
}
const tokens = tokenize(text);
const paragraphs = getParagraphs(text);
const sentences = getSentences(text);
const issues = [];
let rawScore = 0;
// ── 1. Tier 1 words ──────────────────────────────────────────
const tier1Found = new Set();
for (const token of tokens) {
if (Object.hasOwn(TIER1, token) && !tier1Found.has(token)) {
tier1Found.add(token);
issues.push({
type: 'tier1',
text: token,
severity: 'high',
suggestion: TIER1[token],
});
}
}
// Tier 1 multi-word phrases. Adds each distinct phrase (lowercased) to
// `tier1Found` so the same phrase hit multiple times only produces one
// issue — matches the downstream dedup behavior.
for (const phrase of TIER1_PHRASES) {
const regex = new RegExp(phrase.pattern.source, phrase.pattern.flags);
let match;
while ((match = regex.exec(text)) !== null) {
const lower = match[0].toLowerCase();
if (tier1Found.has(lower)) continue;
tier1Found.add(lower);
issues.push({
// Clarity-band entries are wordiness edits, not frequency evidence.
// Same fix, weaker claim — see the Tier 1A/1B split in SKILL.md.
type: phrase.clarity ? 'tier1-clarity' : 'tier1',
text: match[0],
severity: phrase.clarity ? 'medium' : 'high',
suggestion: phrase.replace,
});
}
}
// ── 2. Tier 2 clusters ───────────────────────────────────────
let tier2Clusters = 0;
for (const para of paragraphs) {
const paraTokens = tokenize(para);
const found = [];
const suggestions = {};
for (const token of paraTokens) {
if (Object.hasOwn(TIER2, token) && !found.includes(token)) {
found.push(token);
suggestions[token] = TIER2[token];
}
}
for (const cond of TIER2_CONDITIONAL) {
if (!found.includes(cond.word) && cond.pattern.test(para)) {
found.push(cond.word);
suggestions[cond.word] = cond.suggestion;
}
}
if (found.length >= 2) {
tier2Clusters++;
for (const word of found) {
issues.push({
type: 'tier2',
text: word,
severity: 'medium',
suggestion: suggestions[word],
});
}
}
}
// ── 3. Tier 3 density ────────────────────────────────────────
const tier3Counts = {};
for (const token of tokens) {
const canonical = TIER3_LOOKUP.get(token);
if (canonical) tier3Counts[canonical] = (tier3Counts[canonical] || 0) + 1;
}
// Flag at 3% of word count, but never below 3 occurrences. Previous
// floor of 1 meant a 50-word text with one "significant" got flagged
// as Tier 3 overuse, which was noise.
const densityThreshold = Math.max(3, Math.floor(wordCount * 0.03));
let tier3Flags = 0;
for (const [word, count] of Object.entries(tier3Counts)) {
if (count >= densityThreshold) {
tier3Flags++;
issues.push({
type: 'tier3',
text: `"${word}" x${count}`,
severity: 'low',
suggestion: `Overused (${count} times in ${wordCount} words)`,
});
}
}
// ── 4–21. Pattern categories ─────────────────────────────────
issues.push(...matchPatterns(text, TRANSITIONS, 'transition', 'medium'));
issues.push(...matchPatterns(text, CHATBOT_ARTIFACTS, 'chatbot', 'critical'));
issues.push(...matchPatterns(text, SYCOPHANTIC, 'sycophantic', 'critical'));
issues.push(...matchPatterns(text, FILLERS, 'filler', 'medium'));
issues.push(...matchPatterns(text, GENERIC_CONCLUSIONS, 'generic-conclusion', 'medium'));
issues.push(...matchPatterns(text, LETS_PATTERNS, 'lets-construction', 'medium'));
issues.push(...matchPatterns(text, REASONING_ARTIFACTS, 'reasoning-artifact', 'critical'));
issues.push(...matchPatterns(text, ACKNOWLEDGMENT_LOOPS, 'acknowledgment-loop', 'medium'));
issues.push(...matchPatterns(text, SIGNIFICANCE_INFLATION, 'significance-inflation', 'high'));
issues.push(...matchPatterns(text, VAGUE_ATTRIBUTIONS, 'vague-attribution', 'critical'));
issues.push(...matchPatterns(text, HOLLOW_INTENSIFIERS, 'hollow-intensifier', 'medium'));
issues.push(...matchPatterns(text, EMOTIONAL_FLATLINE, 'emotional-flatline', 'low'));
issues.push(...matchPatterns(text, LINGERING_ATTENTION, 'lingering-attention', 'medium'));
issues.push(...matchPatterns(text, NOVELTY_INFLATION, 'novelty-inflation', 'medium'));
issues.push(...matchPatterns(text, CUTOFF_DISCLAIMERS, 'cutoff-disclaimer', 'critical'));
issues.push(...matchPatterns(text, AI_PLACEHOLDERS, 'ai-placeholder', 'critical'));
issues.push(...matchPatterns(text, AI_CITATION_MARKUP, 'ai-citation-markup', 'critical'));
issues.push(...matchPatterns(text, AI_UTM_SOURCE, 'ai-utm-source', 'critical'));
issues.push(...matchPatterns(text, TEMPLATE_PHRASES, 'template-phrase', 'high'));
issues.push(...matchPatterns(text, FALSE_CONCESSION, 'false-concession', 'medium'));
issues.push(...matchPatterns(text, RHETORICAL_QUESTIONS, 'rhetorical-question', 'medium'));
issues.push(...matchPatterns(text, HEDGE_STACK, 'hedge-stack', 'high'));
issues.push(...matchPatterns(text, FUTURE_NARRATIVE, 'future-narrative', 'high'));
issues.push(...matchPatterns(text, REAL_ACTUAL_INFLATION, 'real-actual-inflation', 'medium'));
issues.push(...matchPatterns(text, SOCIAL_CTA_CLOSER, 'social-cta-closer', 'high'));
issues.push(...matchPatterns(text, PERFORMED_INSIGHT, 'performed-insight', 'medium'));
issues.push(...matchPatterns(text, NEGATION_CHAIN, 'negation-chain', 'high'));
issues.push(...matchPatterns(text, DEV_BLOG_BOILERPLATE, 'dev-blog-boilerplate', 'medium'));
issues.push(...findUnnecessaryHyphenation(text));
// ── Tier 1 v2: formulaic openers + parenthetical hedges ──────────
issues.push(...matchPatterns(text, FORMULAIC_OPENERS, 'formulaic-opener', 'high'));
issues.push(...matchPatterns(text, SPECULATIVE_OPENERS, 'speculative-opener', 'high'));
issues.push(...matchPatterns(text, LAUNCH_INTROS, 'launch-intro', 'high'));
issues.push(...matchPatterns(text, CROWD_CONTRAST, 'crowd-contrast', 'medium'));
issues.push(...matchPatterns(text, FAKE_CASUAL_PROPS, 'fake-casual-prop', 'high'));
issues.push(...matchPatterns(text, PARENTHETICAL_HEDGE, 'parenthetical-hedge', 'medium'));
// Title-case headers — gated to marketing/personal/general modes
// (technical mode legitimately uses Title Case section headers).
if (contextMode !== 'technical') {
const titleHits = matchPatterns(text, [TITLE_CASE_HEADER], 'title-case-header', 'medium');
// Drop matches that look like proper-noun titles (single line, all
// tokens capitalized incl. function words) — that's headline style,
// not the AI-section-header tell which has mid-sentence "And".
//
// The prefix strip is load-bearing. matchPatterns reports match[0], so a
// Markdown hit arrives as "## Terms Of Service" and `##` counts as a
// token — silently lowering this guard from four content words to three
// for headings only, which is exactly the class it exists to protect.
// "## Terms Of Service", "## Bank Of America" and "## Table Of Contents"
// all flagged as a result: ordinary human headings, on a detector whose
// stated first priority is not firing on human writing.
const filtered = titleHits.filter((h) => {
const title = h.text.replace(MD_HEADING_PREFIX, '');
const tokens = title.trim().split(/\s+/);
if (tokens.length < 4) return false;
// The function word must be MID-title, which is what the comment above
// has always said and what the test never enforced. A leading "The"
// satisfied a bare /\bThe\b/, so ordinary human headings flagged:
// "## The New Security Landscape", "## The Microsoft Approach to
// Identity", "### The Four Keys to a Successful and Secure Modern
// Workplace". Measured across 81 files that provably predate LLMs
// (2018-19 eBooks, 2020 posts): 13 false positives, every one opening
// with "The", against zero on main.
//
// "## Benefits And Strategic Considerations" -- the actual tell, and
// this rule's own fixture -- is untouched: its "And" is interior.
return FUNCTION_WORD.test(tokens.slice(1).join(' '));
});
const fences = filtered.length ? fenceRanges(text) : [];
issues.push(...filtered.filter((h) => !inFenceRange(fences, h.index)));
}
// ── Normalization-trigger flag ───────────────────────────────────
// ZWSPs or homoglyphs in pasted prose are near-dispositive: humans
// don't insert these into their own writing. Single roleplay marker
// can be a false positive on Markdown emphasis (filtered to multi-
// word inner already), so requires ≥2.
if (norm.flags.zeroWidth > 0 || norm.flags.homoglyph >= 2) {
issues.push({
type: 'normalization-flag',
text: `${norm.flags.zeroWidth} zero-width + ${norm.flags.homoglyph} homoglyph swap${norm.flags.homoglyph === 1 ? '' : 's'}`,
severity: 'critical',
suggestion: 'Text contains invisible/lookalike chars typical of AI-humanizer bypass tools. Re-type from your own keyboard.',
});
}
if (norm.flags.roleplay >= 2) {
issues.push({
type: 'normalization-flag',
text: `${norm.flags.roleplay} *roleplay-action* markers stripped`,
severity: 'high',
suggestion: 'Paired *action* markers are a chat-model artifact.',
});
}
// Em dashes in list-item separator position — a bulleted or numbered
// list item opening with a bolded lead term or markdown link, then the
// dash ("- **Term** — desc", "- [label](url) — desc") — are
// definition-list typography, not prose punctuation. Shared by the
// smart-punct signature below and the em-dash frequency check (§22).
// An optional parenthetical or inline-code span may sit between the bold
// lead term and the dash — "- **Lingering-attention claims**
// (`lingering-attention`) — the share-post frame…" is the same definition
// typography as the bare form. Found by the self-scan (see PROOF.md, #67).
const SEPARATOR_DASH_RE = /^\s*(?:[-*+]|\d+[.)])\s+(?:\*\*[^*\n]+\*\*|\[[^\]\n]+\]\([^)\n]*\))(?:[ \t]*(?:\([^)\n]*\)|`[^`\n]+`))?[ \t]*—/gm;
// Keep-a-Changelog version headings (`## [3.21.0] — 2026-07-30`) join a
// label to a value exactly as a list separator does. Deliberately narrow:
// a bracketed or bare semver token, then a dash, then an ISO date, and
// nothing else on the line. Ordinary prose dashes in headings still count,
// because SKILL.md applies the em-dash rule to headings too.
const VERSION_HEADING_DASH_RE = /^#{1,6}[ \t]+\[?v?\d+\.\d+\.\d+[^\]\n]*\]?[ \t]*—[ \t]*\d{4}-\d{2}-\d{2}[ \t]*$/gm;
// ── Smart-punctuation co-occurrence signature ────────────────────
// Curly quotes + em-dash + Oxford comma all present + zero typos
// (no double-spaces, no missing apostrophes in common contractions)
// is a near-dispositive paste-from-LLM signature: humans typing
// directly into a textarea don't produce all four. Standalone any of
// these is meaningless — co-occurrence is the signal. Separator-position
// dashes are typography and don't corroborate it.
{
const hasCurly = /[“”‘’]/.test(text);
const totalEmDashes = (text.match(/—/g) || []).length;
const separatorEmDashes = (text.match(SEPARATOR_DASH_RE) || []).length
+ (text.match(VERSION_HEADING_DASH_RE) || []).length;
const hasEmDash = totalEmDashes > separatorEmDashes;
const oxfordHit = text.match(/\b\w+,\s+\w+,\s+and\s+\w+/g);
const hasOxford = (oxfordHit?.length || 0) >= 1;
const doubleSpaces = (text.match(/[^.!?] +/g) || []).length;
const missingApos = /\b(?:dont|wont|cant|isnt|wasnt|shouldnt|wouldnt|couldnt|youre|theyre|its\s+a\s+\w+ing)\b/i.test(text);
const clean = doubleSpaces === 0 && !missingApos;
const signals = [hasCurly, hasEmDash, hasOxford, clean].filter(Boolean).length;
if (signals >= 4 && wordCount >= 80) {
issues.push({
type: 'smart-punct-signature',
text: 'curly-quotes + em-dash + Oxford comma + zero typos',
severity: 'high',
suggestion: 'Smart-punctuation signature consistent with LLM output. Humans typing into textareas rarely produce all four.',
});
}
}
// ── Punctuation distribution mode ────────────────────────────────
// Humans cluster trimodal across paragraphs (some paras heavy, some
// light, some none). AI converges on a normal distribution. We can't
// run a real modality test client-side, but we can flag the AI
// signature: low variance of per-paragraph punctuation density.
// Requires ≥4 paragraphs to be meaningful.
if (paragraphs.length >= 4) {
const densities = paragraphs.map((p) => {
const words = (p.match(/\S+/g) || []).length;
if (words < 5) return null;
const puncts = (p.match(/[,;:—()]/g) || []).length;
return puncts / words;
}).filter((d) => d !== null);
if (densities.length >= 4) {
const mean = densities.reduce((a, b) => a + b, 0) / densities.length;
const variance = densities.reduce((s, d) => s + (d - mean) ** 2, 0) / densities.length;
const cv = mean > 0 ? Math.sqrt(variance) / mean : 0;
// CV < 0.25 across paragraphs means each paragraph has the same
// punctuation density — the AI signature. Humans usually swing
// wider. Threshold derived from stylometry papers (arxiv 2507.00838).
if (cv < 0.25 && mean >= 0.04) {
issues.push({
type: 'punct-distribution',
text: `Punctuation density uniform across paragraphs (CV=${cv.toFixed(2)})`,
severity: 'medium',
suggestion: 'AI text holds punctuation density steady; human writers swing between dense and sparse paragraphs.',
});
}
}
}
// ── Function-word trigram entropy ────────────────────────────────
// POS-trigram entropy is the academic signal; function-word trigram
// entropy approximates it without a tagger (function words ARE the
// closed-class POS classes). AI text has lower entropy because LLM
// sampling collapses onto a narrower set of grammatical templates.
//
// Method: extract function-word indicators per sentence, build
// trigrams over the sequence, compute Shannon entropy. Bins below
// threshold flag.
if (wordCount >= 150) {
const FUNC_WORDS = new Set([
'the','a','an','and','or','but','of','to','in','on','at','by','for','with',
'from','as','is','was','are','were','be','been','being','have','has','had',
'do','does','did','will','would','should','could','may','might','must','can',
'this','that','these','those','it','its','they','them','their','there','here',
'we','our','us','i','you','your','he','she','his','her','him','not','no','so',
'if','then','than','when','where','which','who','what','how','why','because',
]);
const seq = tokens.map((t) => FUNC_WORDS.has(t) ? t : '_').filter((_, i, arr) => arr[i] !== '_' || (i > 0 && arr[i - 1] !== '_'));
if (seq.length >= 50) {
const trigrams = {};
for (let i = 0; i < seq.length - 2; i++) {
const tg = `${seq[i]}|${seq[i + 1]}|${seq[i + 2]}`;
trigrams[tg] = (trigrams[tg] || 0) + 1;
}
const total = seq.length - 2;
let entropy = 0;
for (const c of Object.values(trigrams)) {
const p = c / total;
entropy -= p * Math.log2(p);
}
// Normalize by log2(distinct trigrams) so entropy ranges roughly
// 0..1 and threshold is interpretable. Empirical threshold: human
// prose ~0.85-0.95 normalized, AI prose ~0.70-0.82.
const distinctCount = Object.keys(trigrams).length;
const normalized = distinctCount > 1 ? entropy / Math.log2(distinctCount) : 1;
if (normalized < 0.82 && total >= 50) {
issues.push({
type: 'fnword-trigram-entropy',
text: `Function-word trigram entropy ${normalized.toFixed(2)} (low)`,
severity: 'medium',
suggestion: 'Grammatical structure is unusually repetitive. AI sampling collapses onto narrower templates than human writing.',
});
}
// Degenerate case: single distinct trigram repeated across the
// whole document is the strongest possible AI signal but the
// normalized fallback returns 1.0 (= "fully human"), inverting
// the signal. Catch it explicitly.
if (distinctCount === 1 && total >= 50) {
issues.push({
type: 'fnword-trigram-entropy',
text: 'Single function-word trigram repeated across document',
severity: 'high',
suggestion: 'Grammatical structure is fully degenerate — every clause uses the same function-word skeleton.',
});
}
}
}
// ── Cross-paragraph burstiness ───────────────────────────────────
// We already check within-paragraph sentence-length uniformity. AI
// is also flat ACROSS paragraphs — every paragraph has roughly the
// same sentence-length variance. Humans vary: terse paras next to
// discursive paras. Measure variance of CV across paragraphs.
if (paragraphs.length >= 4) {
const cvs = paragraphs.map((p) => {
const sents = getSentences(p);
if (sents.length < 3) return null;
const lens = sents.map(countWords);
const m = lens.reduce((a, b) => a + b, 0) / lens.length;
if (m === 0) return null;
const v = lens.reduce((s, l) => s + (l - m) ** 2, 0) / lens.length;
return Math.sqrt(v) / m;
}).filter((c) => c !== null);
if (cvs.length >= 4) {
const cvMean = cvs.reduce((a, b) => a + b, 0) / cvs.length;
const cvVar = cvs.reduce((s, c) => s + (c - cvMean) ** 2, 0) / cvs.length;
const cvStd = Math.sqrt(cvVar);
// Std-of-CV below 0.08 means every paragraph has roughly the same
// internal rhythm — AI signature. Human prose typically swings
// 0.15-0.40 across paragraphs of mixed purpose.
if (cvStd < 0.08 && cvMean < 0.45) {
issues.push({
type: 'cross-para-burstiness',
text: `Sentence-rhythm uniform across paragraphs (σCV=${cvStd.toFixed(2)})`,
severity: 'medium',
suggestion: 'Every paragraph has the same internal rhythm. Humans vary cadence between terse and discursive paragraphs.',
});
}
}
}
// ── Tier 3 multi-word phrase density ─────────────────────────
// Two complementary rules:
// (a) Per-phrase density — same gating as single-word Tier 3: each
// phrase fine alone, repetition is the tell. Threshold = 2.
// (b) Cross-phrase clustering — ≥3 *distinct* boilerplate phrases
// in one piece. LLMs varying their own boilerplate often use
// each phrase only once but stack 5-10 across the text. The
// per-phrase rule misses this; the cluster rule catches it.
// Track non-overlapping match spans so a longer phrase swallowing a
// shorter one (e.g., "designed for long-term sustainability" matches
// both "designed for long-term" AND "long-term sustainability") only
// contributes one distinct hit. Without dedup the cluster threshold
// can be reached by a single sentence stacking overlapping regexes.
const claimedSpans = [];
function spanOverlaps(start, end) {
for (const [s, e] of claimedSpans) {
if (start < e && end > s) return true;
}
return false;
}
let distinctPhrasesHit = 0;
for (const phrase of TIER3_PHRASES) {
const regex = new RegExp(phrase.source, phrase.flags);
const phraseSpans = [];
let phraseMatch;
while ((phraseMatch = regex.exec(text)) !== null) {
const start = phraseMatch.index;
const end = start + phraseMatch[0].length;
if (!spanOverlaps(start, end)) {
phraseSpans.push([start, end, phraseMatch[0]]);
}
}
if (phraseSpans.length === 0) continue;
for (const [s, e] of phraseSpans) claimedSpans.push([s, e]);
distinctPhrasesHit++;
if (phraseSpans.length >= 2) {
issues.push({
type: 'tier3-phrase',
text: `"${phraseSpans[0][2].toLowerCase()}" x${phraseSpans.length}`,
severity: 'medium',
suggestion: `Boilerplate phrase repeated ${phraseSpans.length}× — replace at least one with specifics`,
});
}
}
if (distinctPhrasesHit >= 3) {
issues.push({
type: 'tier3-phrase-cluster',
text: `${distinctPhrasesHit} distinct boilerplate phrases`,
severity: 'high',
suggestion: 'Several stock crypto/web3 phrases stacked in one piece. Rewrite around one specific claim or observation.',
});
}
// ── Hashtag stuffing ─────────────────────────────────────────
// 6+ hashtags in a single post is rare for thoughtful humans and
// near-universal for LLM-generated social posts. Counted globally,
// not per-paragraph, since the trailing hashtag block is the shape
// we care about.
// Match #tag at start of text or after any non-word char (whitespace,
// punctuation, line breaks). URL fragments are already excluded
// because the char immediately before `#` in a URL path is always
// a word char (e.g. `example.com/page#section` — `e` before `#`).
// Earlier char class `[\s\\]` had a literal backslash and silently
// missed hashtags after sentence punctuation; an interim `[\s]` fix
// on origin only caught whitespace-preceded tags.
// Code is masked and non-tag `#` forms are subtracted first — see maskCode
// and isSocialTag. Without them a changelog paragraph citing six issue
// numbers, or a palette listing six hex colours, scored as a tag block.
const hashtagMatches = [...maskCode(text).matchAll(/(?:^|\W)#(\w[\w-]*)/g)]
.filter((m) => isSocialTag(m[1]));
if (hashtagMatches.length >= 6) {
issues.push({
type: 'hashtag-stuff',
text: `${hashtagMatches.length} hashtags`,
severity: 'medium',
suggestion: 'Cut to 2-3 specific tags or none. Long hashtag blocks read as bot output.',
});
}
// ── Bullet list of bare noun phrases ─────────────────────────
// ≥5 consecutive bullet items that are short (≤6 words) and contain
// no finite-verb / modal token. Catches the "Stable mining efficiency
// / Reliable pool connectivity / Optimized RandomX performance ..."
// shape LLMs default to. Markdown bullets, escaped Markdown bullets,
// unicode bullets, and dashes are all matched. Numbered lists are
// excluded — those have a separate "numbered list inflation" rule.
//
// Note: verbRe covers auxiliaries and modals ("was", "will", "can",
// etc.). Regular past-tense verbs ("fixed", "removed") are not
// matched here; instead, the ≤6-word length gate excludes most
// real-world changelog lines, which tend to read "fixed the X that
// was doing Y" (>6 words). Short two-word action items ("* fixed
// bug") would pass both gates — an acceptable trade-off to avoid
// false-negative risk from adjectives ending in -ed ("skilled",
// "advanced") that share the same surface form.
const lines = text.split(/\r?\n/);
const bulletRe = /^\s*(?:\*|-|•|\+)\s+(.+)$/;
const verbRe = /\b(?:is|are|was|were|has|have|had|will|would|should|must|do|does|did|can|could|may|might|am|been|being)\b/i;
const fenceRe = /^\s*(?:```|~~~)/;
let run = [];
let blankStreak = 0;
let inFence = false;
function flushRun() {
if (run.length >= 5) {
const bareNP = run.filter((it) => {
const wc = (it.match(/\S+/g) || []).length;
return wc > 0 && wc <= 6 && !verbRe.test(it);
});
if (bareNP.length >= 5 && bareNP.length / run.length >= 0.75) {
issues.push({
type: 'bullet-np-list',
text: `${run.length}-item bullet list of bare noun phrases`,
severity: 'high',
suggestion: 'Convert to a prose paragraph or merge items. Long lists of bare adj+noun pairs read as AI scaffolding.',
});
}
}
run = [];
blankStreak = 0;
}
for (const line of lines) {
if (fenceRe.test(line)) {
// Code-fence toggle. Bullets inside fences are CLI flag docs or
// option dumps, not prose AI scaffolding — flush any prose run
// we were tracking and skip until the fence closes.
flushRun();
inFence = !inFence;
continue;
}
if (inFence) continue;
const m = line.match(bulletRe);
if (m) {
run.push(m[1].trim());
blankStreak = 0;
} else if (line.trim() === '') {
// A single blank line inside a list is normal Markdown spacing;
// two or more blank lines break the run, since visually-disjoint
// bullet sections shouldn't merge into one logical list.
blankStreak++;
if (blankStreak >= 2) flushRun();
} else {
flushRun();
}
}
flushRun();
// NOTE: "Wall-of-text replies" (SKILL.md) is deliberately NOT a
// detector rule here. A first pass tried "reply-length text, >=4
// sentences, zero newlines" as a structural gate — it broke the
// "repeated Tier 1 phrase does not inflate score linearly" fixture
// and, on reflection, would fire on any ordinary short paragraph
// (a blog intro, a single-paragraph email) since "one paragraph with
// no internal line break" is simply what continuous prose looks
// like, not an AI-specific shape. The tell in SKILL.md depends on
// knowing the text is conversational-reply register in the first
// place, which the engine can't reliably infer from the bytes alone
// (CONTRIBUTING.md: "a signal that fires on most normal prose is not
// worth adding"). Left as an LLM-judgment rule; see CATEGORIES.md §C.
// Confidence calibration is only flagged when it stacks (3+ instances).
// Gating happens pre-dedup on raw match count, since that signals actual
// stacking, not just vocabulary use.
const confIssues = matchPatterns(text, CONFIDENCE_CALIBRATION, 'confidence-calibration', 'low');
if (confIssues.length >= 3) issues.push(...confIssues);
// ── 22. Em dash frequency ────────────────────────────────────
// Match real em dashes, plus `--` only when surrounded by whitespace on at
// least one side (skips CLI flags like --save-dev and YAML `---` blocks).
// Separator-position em dashes (SEPARATOR_DASH_RE above) are excluded
// from the rate. The list marker is required on purpose: a line-initial
// "**Bold lead** — full sentence" outside a list is itself an AI tell
// and still counts, as does a mid-sentence "**bold** — like this"
// splice. Em dash only — the `--` substitute is never carved out.
const rawEmDashCount = (text.match(/—|(?<=\s)--(?=\s|$)|(?<=^|\s)--(?=\s)/gm) || []).length;
const separatorDashCount = (text.match(SEPARATOR_DASH_RE) || []).length
+ (text.match(VERSION_HEADING_DASH_RE) || []).length;
const emDashCount = rawEmDashCount - separatorDashCount;
const emDashRate = emDashCount / (wordCount / 1000);
if (emDashRate > 1) {
issues.push({
type: 'em-dash',
text: `${emDashCount} em dashes in ${wordCount} words`,
severity: 'medium',
suggestion: 'Replace with commas, periods, or rewrite',
});
}
// ── 23. Sentence length uniformity ───────────────────────────
if (sentences.length >= 5) {
const lengths = sentences.map(s => countWords(s));
const avg = lengths.reduce((a, b) => a + b, 0) / lengths.length;
const variance = lengths.reduce((sum, l) => sum + Math.pow(l - avg, 2), 0) / lengths.length;
const stdDev = Math.sqrt(variance);
const cv = avg > 0 ? stdDev / avg : 0;
if (cv < 0.25 && avg > 10) {
issues.push({
type: 'uniformity',
text: `Sentence lengths cluster around ${Math.round(avg)} words (low variation)`,
severity: 'medium',
suggestion: 'Mix short punchy sentences with longer flowing ones',
});
}
}
// ── Type-token ratio (stylometric — vocabulary diversity) ────
// TTR = distinct word types / total tokens. Human prose at 200+
// words typically sits around 0.50–0.65 for English; AI prose
// tends flatter (0.55–0.75 looks normal, but the lower end of the
// *too-flat* tail at >=200 words is where the signal lives — too
// FEW unique words for the length). This is the simplest of the
// four stylometric signals identified in the May 2026 detection-
// research review (docs/competitive/detection-research.md): no
// POS tagger required, no model, pure JS.
//
// Threshold tuning: flag only when the sample is large enough
// that low TTR is meaningfully suspicious (>=200 tokens) AND TTR
// is below 0.40 (very vocabulary-poor). Conservative on purpose;
// false positives on short or topic-narrow human prose are easy
// to trigger and would drown out other signals. The detector-
// research lens flagged TTR as one of four stylometric add-ons;
// POS-bigram log-odds, function-word z-scores, and sentence-
// length burstiness are still TODO.
if (tokens.length >= 200) {
const unique = new Set(tokens).size;
const ttr = unique / tokens.length;
if (ttr < 0.4) {
issues.push({
type: 'low-ttr',
text: `Vocabulary diversity ${(ttr * 100).toFixed(1)}% (${unique} unique / ${tokens.length} tokens)`,
severity: 'low',
suggestion: 'Text reuses a narrow word set. Vary nouns and verbs deliberately, or check if the topic genuinely warrants the repetition.',
});
}
}
// ── 24. Paragraph length uniformity ──────────────────────────
if (paragraphs.length >= 4) {
const paraLengths = paragraphs.map(p => getSentences(p).length);
const avg = paraLengths.reduce((a, b) => a + b, 0) / paraLengths.length;
const allSimilar = paraLengths.every(l => Math.abs(l - avg) <= 1);
if (allSimilar && avg >= 3) {
issues.push({
type: 'uniformity',
text: `All paragraphs are ~${Math.round(avg)} sentences`,
severity: 'low',
suggestion: 'Vary paragraph length deliberately',
});
}
}
// ── 25. Bold overuse ─────────────────────────────────────────
const boldMatches = text.match(/\*\*[^*]+\*\*/g) || [];
if (boldMatches.length > 3) {
issues.push({
type: 'formatting',
text: `${boldMatches.length} bold phrases`,
severity: 'medium',
suggestion: 'Strip bold from most; restructure to lead with key info',
});
}
// ── Score from the deduped issue list ───────────────────────
// Previously rawScore was accumulated inline per pattern hit, so
// repeated hits of the same phrase (or overlapping matches) inflated
// the score while the displayed issue list was deduplicated. That
// produced the UX regression where a "heavy AI patterns" label sat
// above a list of two items. Now the dedup runs first, then each
// distinct issue contributes its category weight — so the number
// reflects the same signals the user actually sees.
const deduped = deduplicateIssues(issues);
for (const issue of deduped) {
rawScore += ISSUE_WEIGHTS[issue.type] ?? 2;
}
// Scale by text length: longer text gets more chances to trigger.
const lengthFactor = Math.max(1, Math.log2(wordCount / 50));
const normalizedScore = Math.min(100, Math.round(rawScore / lengthFactor));
const label = getLabel(normalizedScore);
// ── Sentence-region smoothing (HMM-style without an HMM) ─────────
// Map each text-bearing issue back to its sentence indexes, then
// merge adjacent flagged sentences into contiguous regions for UI
// highlighting. Borrowed from GPTZero's sentence-highlighting model
// — gives users "this paragraph is AI" rather than scattered hits.
const regions = buildSentenceRegions(text, deduped, sourceMode === 'rendered-markdown');
// Stats derived from the same deduped list so tier counts + patternCount
// sum to `deduped.length`. Previously patternCount subtracted
// `tier2Clusters` (a paragraph count) which produced inconsistent totals.
const tier1Count = deduped.filter((i) => i.type === 'tier1').length;
const tier2Count = deduped.filter((i) => i.type === 'tier2').length;
const tier3Count = deduped.filter((i) => i.type === 'tier3').length;
// ── Trinary classification (GPTZero-shaped) ──────────────────────
// Decouples confidence from AI-proportion. Maps the 0-100 score plus
// structural signals into HUMAN_ONLY / MIXED / AI_ONLY with a
// confidence band. Thresholds are FN-biased: ambiguity routes to
// MIXED, never AI_ONLY. Quote from GPTZero's design principle:
// "biases the detector to prefer making less-harmful false-negative
// errors over false-positive errors."
// Dense-AI-vocab trifecta: ≥5 distinct tier1 hits + ≥2 tier2 cluster
// paragraphs + ≥1 transition phrase, AND ≥150 words. Catches
// saturated ChatGPT prose without firing on dense-jargon human
// technical writing where the tier1 vocabulary (robust,
// comprehensive, leverage, ecosystem) legitimately overlaps with
// systems-programming idiom. Word-count gate prevents short ESL or
// contrived adversarial sentences from tripping the corroborator.
const tier1Distinct = new Set(deduped.filter((i) => i.type === 'tier1').map((i) => (i.text || '').toLowerCase())).size;
const hasTier2Cluster = tier2Clusters >= 2;
const hasTransition = deduped.some((i) => i.type === 'transition');
const denseAIVocab = wordCount >= 150 && tier1Distinct >= 5 && hasTier2Cluster && hasTransition;
const trinary = classifyTrinary({
score: normalizedScore,
issues: deduped,
regions,
normFlags: norm.flags,
wordCount,
denseAIVocab,
});
// Translate both dense issue indexes and half-open highlight boundaries.
// Mapping the region end from its final retained code unit avoids pulling
// a later removed roleplay marker into the highlighted source slice.
remapFindingsToSource(deduped, regions, sourceMap);
return {
// Legacy fields preserved for existing callers.
score: normalizedScore,
label,
issues: deduped,
stats: {
wordCount,
tier1Count,
tier2Count,
tier2Clusters,
tier3Count,
tier3Flags,
patternCount: deduped.length - tier1Count - tier2Count - tier3Count,
contextMode,
contextModeFallback,
sourceMode,
sourceModeFallback,
maskedFrontmatter,
maskedHtmlComments,
normalization: norm.flags,
quotedLines,
unmappedHighlights: regions._unmapped ?? 0,
denseAIVocab,
tier1Distinct,
},
// Trinary API — shape mirrors GPTZero so integrators can swap.
document_classification: trinary.classification,
class_probabilities: trinary.probabilities,
confidence_category: trinary.confidence,
highlight_sentence_for_ai: regions,
};
}
// ═══ Sentence regions + trinary classifier ═════════════════════════
// Coarse sentence spans over the whole text, as [start, end) offsets.
// Produces the same spans as the former /[^.!?]+[.!?]+|\S[^.!?]*$/g scan:
// each span runs from the end of the previous one through the next run of
// terminators, and a trailing fragment with no terminator starts at its
// first non-space character. The regex version backtracked to the end of
// the input at every position of a long terminator-free run, so a document
// that ended in blank lines, or whose masked comments became whitespace,
// cost O(n^2) (#235). This scan touches each character a bounded number
// of times.
const SENTENCE_TERMINATOR_RUN = /[.!?]+/g;
const FIRST_NON_SPACE = /\S/g;
function splitSentenceSpans(text) {
const spans = [];
const length = text.length;
let pos = 0;
while (pos < length) {
SENTENCE_TERMINATOR_RUN.lastIndex = pos;
const run = SENTENCE_TERMINATOR_RUN.exec(text);
if (run === null) {
FIRST_NON_SPACE.lastIndex = pos;
const head = FIRST_NON_SPACE.exec(text);
if (head !== null) spans.push([head.index, length]);
break;
}
if (run.index === pos) {
// Skip the entire bodyless run, not one character at a time: matching
// every remaining suffix would make a long punctuation run quadratic.
// If no later sentence terminator exists, the former regex's trailing
// alternative starts at the LAST terminator of this run.
const runEnd = pos + run[0].length;
SENTENCE_TERMINATOR_RUN.lastIndex = runEnd;
if (SENTENCE_TERMINATOR_RUN.exec(text) === null) {
spans.push([runEnd - 1, length]);
break;
}
pos = runEnd;
continue;
}
const end = run.index + run[0].length;
spans.push([pos, end]);
pos = end;
}
return spans;
}
function buildSentenceRegions(text, issues, trimBoundaryWhitespace = false) {
// Split text into sentences with source offsets preserved so the UI
// can highlight spans accurately. Sentence boundaries are coarse
// (.!?) — fine for highlighting, not for linguistic correctness.
const sentences = [];
for (const [spanStart, spanEnd] of splitSentenceSpans(text)) {
const raw = text.slice(spanStart, spanEnd);
const sentenceText = raw.trim();
if (sentenceText.length < 4) continue;
let start = spanStart;
let end = spanEnd;
if (trimBoundaryWhitespace) {
// trimStart/trimEnd, not `\s*$`: that regex retries from every
// position of a leading whitespace run and was quadratic on a span
// that began with a masked comment block (#235).
start += raw.length - raw.trimStart().length;
end -= raw.length - raw.trimEnd().length;
}
sentences.push({ start, end, text: sentenceText });
}
if (sentences.length === 0) return [];
// Map issue.text back to sentence indexes via substring search. Two
// kinds of issue stay out of the AI-highlight regions. Summary signals
// like "Punctuation density uniform across paragraphs" have no sentence
// anchor — they contribute to the document-level signal but not to
// highlights. Zero-weight style copyedits (unnecessary-hyphenation) do
// have an anchor, but they are P2 grammar cleanup rather than evidence
// of machine authorship, so they belong in issues[] and nowhere near a
// field reserved for AI sentence highlights.
// Filter by issue TYPE not text-regex: text-based filtering used to
// drop legitimate phrase issues containing "across" / "density".
const NON_HIGHLIGHT_TYPES = new Set([
'punct-distribution',
'cross-para-burstiness',
'fnword-trigram-entropy',
'smart-punct-signature',
'normalization-flag',
'uniformity',
'em-dash',
'formatting',
'tier3',
'tier3-phrase',
'tier3-phrase-cluster',
'hashtag-stuff',
'bullet-np-list',
'unnecessary-hyphenation',
]);
const hits = sentences.map(() => ({ count: 0, weight: 0 }));
const lowerText = text.toLowerCase();
let unmappedHighlights = 0;
for (const issue of issues) {
if (!issue.text || issue.text.length > 200) continue;
if (NON_HIGHLIGHT_TYPES.has(issue.type)) continue;
const needle = issue.text.toLowerCase();
let idx = 0;
let matched = false;
while ((idx = lowerText.indexOf(needle, idx)) !== -1) {
matched = true;
for (let i = 0; i < sentences.length; i++) {
if (idx >= sentences[i].start && idx < sentences[i].end) {
hits[i].count++;
hits[i].weight += ISSUE_WEIGHTS[issue.type] ?? 2;
break;
}
}
idx += needle.length;
}
if (!matched) unmappedHighlights++;
}
// Window-merge contiguous flagged sentences. Allow 1 unflagged
// sentence gap between two flagged ones (the "smoothing" — keeps
// a single boring sentence from breaking what's clearly an AI
// passage). A sentence is "flagged" if it has ≥1 hit.
const regions = [];
let cur = null;
for (let i = 0; i < sentences.length; i++) {
if (hits[i].count > 0) {
if (cur === null) {
cur = { startSentence: i, endSentence: i, start: sentences[i].start, end: sentences[i].end, hitCount: hits[i].count, weight: hits[i].weight };
} else {
cur.endSentence = i;
cur.end = sentences[i].end;
cur.hitCount += hits[i].count;
cur.weight += hits[i].weight;
}
} else if (cur !== null) {
// Allow one-sentence gap.
const next = hits[i + 1];
if (next && next.count > 0) {
cur.endSentence = i;
cur.end = sentences[i].end;
continue;
}
regions.push(finalizeRegion(cur));
cur = null;
}
}
if (cur !== null) regions.push(finalizeRegion(cur));
// Expose the unmapped-highlight count via a non-enumerable property
// so the array length still reads naturally for consumers; the
// analyzer pulls it into stats.unmappedHighlights for diagnostics.
Object.defineProperty(regions, '_unmapped', { value: unmappedHighlights, enumerable: false });
return regions;
}
function finalizeRegion(r) {
// Map cumulative weight inside the region to a 0-1 score. Cap at 20
// weight = 1.0 (matches Heavy threshold density).
const score = Math.min(1, r.weight / 20);
return {
startSentence: r.startSentence,
endSentence: r.endSentence,
start: r.start,
end: r.end,
hitCount: r.hitCount,
score: Math.round(score * 100) / 100,
};
}
// FN-biased: false positives damage trust more than false negatives,
// so MIXED is wide and AI_ONLY requires multiple signals. Quote from
// GPTZero: "biases the detector to prefer making less-harmful
// false-negative errors over false-positive errors."
function classifyTrinary({ score, issues, regions, normFlags, wordCount, denseAIVocab }) {
// Strong corroborators — each is near-dispositive on its own:
// - cutoff-disclaimer (LLM self-identifies as an AI)
// - reasoning-artifact + chatbot-artifact co-occurrence
// - normalization-flag at threshold (≥2 ZWSP or homoglyphs).
// Threshold parity prevents a single stray ZWSP in copy-paste
// from Word/Notion from flipping to AI_ONLY at score 0.
// - denseAIVocab: ≥4 distinct tier1 hits AND ≥1 tier2 cluster AND
// ≥1 transition phrase — the trifecta that saturated ChatGPT
// prose triggers without needing whitelisted stylometric hits.
const hasCutoff = issues.some((i) => i.type === 'cutoff-disclaimer');
const hasNormFlag = normFlags.zeroWidth >= 2 || normFlags.homoglyph >= 2;
const hasReasoning = issues.some((i) => i.type === 'reasoning-artifact');
const hasChatbot = issues.some((i) => i.type === 'chatbot');
const strongCorrob =
(hasCutoff ? 1 : 0) +
(hasNormFlag ? 1 : 0) +
(hasReasoning && hasChatbot ? 1 : 0) +
(denseAIVocab ? 1 : 0);
// Weak (stylometric) corroborators — suggestive on their own,
// dispositive in combination. Smart-punct-signature matches
// Word-edited human prose so doesn't count without other support.
const stylometricHits = ['punct-distribution', 'cross-para-burstiness', 'fnword-trigram-entropy']
.filter((t) => issues.some((i) => i.type === t)).length;
const hasSmartPunct = issues.some((i) => i.type === 'smart-punct-signature');
const weakCorrob = (stylometricHits >= 2 ? 1 : 0) + (hasSmartPunct ? 1 : 0);
// Thresholds:
// score < 15 with no strong → HUMAN_ONLY
// strong ≥ 1 OR score ≥ 70 → AI_ONLY (lowered from 80; high
// density of AI vocab is sufficient evidence)
// score ≥ 40 with any corroborator → AI_ONLY
// everything else with score ≥ 15 → MIXED
const totalCorrob = strongCorrob + weakCorrob;
let classification;
if (score < 15 && strongCorrob === 0) classification = 'HUMAN_ONLY';
else if (strongCorrob >= 1 || score >= 70) classification = 'AI_ONLY';
else if (score >= 40 && totalCorrob >= 1) classification = 'AI_ONLY';
else classification = 'MIXED';
// Humanizer-flag escalation: presence of bypass-trick chars is
// adversarial signal. If a normalization-flag fired we already
// counted it in strongCorrob → AI_ONLY. Confidence also gets a
// floor of 'medium' in that case (an adversary actively evading
// detection should never read as low-confidence noise).
// Soft probability distribution. Not calibrated against a labeled
// corpus yet (TODO when corpus exists — see roadmap.md). Largest
// class is computed as `1 - others` after rounding to guarantee
// sum=1 exactly. Sub-1% drift would otherwise hide in toFixed.
const aiSoft = Math.min(0.97, score / 100 + totalCorrob * 0.06 + strongCorrob * 0.08);
let p;
if (classification === 'HUMAN_ONLY') p = { human: Math.max(0.6, 1 - aiSoft), mixed: Math.min(0.35, aiSoft * 0.8), ai: Math.min(0.1, aiSoft * 0.3) };
else if (classification === 'AI_ONLY') p = { human: Math.max(0.02, 1 - aiSoft - 0.05), mixed: 0.1, ai: aiSoft };
else p = { human: Math.max(0.15, 0.6 - aiSoft * 0.5), mixed: 0.5, ai: aiSoft * 0.7 };
const rawSum = p.human + p.mixed + p.ai;
p.human = +(p.human / rawSum).toFixed(3);
p.mixed = +(p.mixed / rawSum).toFixed(3);
// Assign ai as the remainder so the three values sum to exactly 1.
// Clamp to >= 0 in case rounding pushes human+mixed above 1 (the
// remainder would otherwise show as -0 or -0.001 — surfaces as a
// negative percentage in any UI doing Math.round(p.ai * 100)).
p.ai = Math.max(0, +(1 - p.human - p.mixed).toFixed(3));
const probabilities = p;
// Confidence band:
// high — strongCorrob ≥ 2, OR cutoff-disclaimer, OR score < 8 (clean long doc)
// medium — strongCorrob ≥ 1, OR score ≥ 45 with weak corroborator, OR score < 20
// low — everything else
let confidence;
if (strongCorrob >= 2 || hasCutoff || (score < 8 && wordCount >= 100)) confidence = 'high';
else if (strongCorrob >= 1 || (score >= 45 && weakCorrob >= 1) || score < 20) confidence = 'medium';
else confidence = 'low';
return { classification, probabilities, confidence };
}
function getLabel(score) {
if (score === 0) return 'Clean';
if (score <= 15) return 'Minimal AI signals';
if (score <= 35) return 'Some AI patterns';
if (score <= 60) return 'Moderate AI signals';
if (score <= 80) return 'Strong AI signals';
return 'Heavy AI patterns';
}
function getColor(score) {
if (score <= 15) return '#44bb66';
if (score <= 35) return '#88bb44';
if (score <= 60) return '#ddaa00';
if (score <= 80) return '#ff8833';
return '#ff4444';
}
function deduplicateIssues(issues) {
const seen = new Set();
return issues.filter(issue => {
const key = `${issue.type}:${issue.text.toLowerCase()}`;
if (seen.has(key)) return false;
seen.add(key);
return true;
});
}
// ─── Severity labels ──────────────────────────────────────────
const SEVERITY_LABELS = {
critical: 'P0',
high: 'P1',
medium: 'P2',
low: 'P3',
};
const TYPE_LABELS = {
'tier1': 'AI vocabulary',
'tier1-clarity': 'Wordiness',
'tier2': 'Word cluster',
'tier3': 'Overused word',
'transition': 'AI transition',
'chatbot': 'Chatbot artifact',
'sycophantic': 'Sycophantic tone',
'filler': 'Filler phrase',
'generic-conclusion': 'Generic conclusion',
'lets-construction': '"Let\'s" opener',
'reasoning-artifact': 'Reasoning artifact',
'acknowledgment-loop': 'Acknowledgment loop',
'significance-inflation': 'Significance inflation',
'vague-attribution': 'Vague attribution',
'hollow-intensifier': 'Hollow intensifier',
'emotional-flatline': 'Emotional flatline',
'lingering-attention': 'Lingering-attention claim',
'novelty-inflation': 'Novelty inflation',
'cutoff-disclaimer': 'Cutoff disclaimer',
'template-phrase': 'Template phrase',
'false-concession': 'False concession',
'rhetorical-question': 'Rhetorical question',
'confidence-calibration': 'Confidence stacking',
'em-dash': 'Em dash overuse',
'uniformity': 'Rhythm uniformity',
'formatting': 'Formatting',
'tier3-phrase': 'Boilerplate phrase',
'tier3-phrase-cluster': 'Boilerplate cluster',
'hashtag-stuff': 'Hashtag stuffing',
'bullet-np-list': 'Bullet-NP list',
'hedge-stack': 'Hedge-stacked prediction',
'future-narrative': 'Generic future narrative',
'real-actual-inflation': '"Real/actual" inflation',
'social-cta-closer': 'Engagement-bait closer',
'formulaic-opener': 'Formulaic opener',
'speculative-opener': 'Speculative scenario opener',
'launch-intro': 'Launch-copy introduction',
'crowd-contrast': 'Dramatized crowd contrast',
'fake-casual-prop': 'Fake-casual prop',
'title-case-header': 'Title Case header',
'parenthetical-hedge': 'Parenthetical hedge',
'smart-punct-signature': 'Smart-punct signature',
'punct-distribution': 'Punctuation distribution',
'fnword-trigram-entropy': 'Grammar repetition',
'cross-para-burstiness': 'Cross-paragraph rhythm',
'normalization-flag': 'Bypass-trick chars',
'low-ttr': 'Low vocabulary diversity',
'ai-placeholder': 'Unfilled placeholder',
'ai-citation-markup': 'Chatbot citation markup leak',
'ai-utm-source': 'AI-tool URL parameter',
'unnecessary-hyphenation': 'Unnecessary hyphenation',
'performed-insight': 'Performed-insight phrase',
'negation-chain': 'Negation chain',
'dev-blog-boilerplate': 'Dev-blog boilerplate',
};
return {
analyzeText,
normalizeText,
getLabel,
getColor,
SEVERITY_LABELS,
TYPE_LABELS,
};
})();
if (typeof module !== 'undefined' && module.exports) {
module.exports = AIDetector;
}