humanizer-pro 4.15.0__py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
humanizer_audit.py ADDED
@@ -0,0 +1,2041 @@
1
+ #!/usr/bin/env python3
2
+ """Deterministic audit CLI for Humanizer Pro."""
3
+
4
+ from __future__ import annotations
5
+
6
+ import argparse
7
+ import difflib
8
+ import hashlib
9
+ import json
10
+ import math
11
+ import re
12
+ import sys
13
+ from collections import Counter
14
+ from dataclasses import dataclass
15
+ from pathlib import Path
16
+ from typing import Iterable
17
+ from urllib.parse import parse_qsl, quote, urlencode, urlsplit, urlunsplit
18
+
19
+
20
+ __version__ = "4.15.0"
21
+ SCHEMA = "humanizer-audit.v1"
22
+ SEVERITY_ORDER = {"info": 0, "warning": 1, "error": 2}
23
+
24
+ AI_REFERRER_HOSTS = {
25
+ "chatgpt.com",
26
+ "claude.ai",
27
+ "copilot.com",
28
+ "grok.com",
29
+ "openai",
30
+ "perplexity.ai",
31
+ }
32
+
33
+ HOMOGLYPH_LATIN = {
34
+ # Cyrillic lookalikes
35
+ "а": "a", "е": "e", "о": "o", "р": "p", "с": "c", "х": "x",
36
+ "у": "y", "к": "k", "м": "m", "н": "h", "в": "b", "т": "t",
37
+ "А": "A", "Е": "E", "О": "O", "Р": "P", "С": "C", "Х": "X",
38
+ "У": "Y", "К": "K", "М": "M", "Н": "H", "В": "B", "Т": "T",
39
+ # Greek lookalikes
40
+ "ο": "o", "Ο": "O", "α": "a", "Α": "A", "ρ": "p", "Ρ": "P",
41
+ }
42
+ WORD_WITH_HOMOGLYPH_RE = re.compile(
43
+ "[A-Za-z{h}]*[A-Za-z][A-Za-z{h}]*".format(h="".join(HOMOGLYPH_LATIN))
44
+ )
45
+
46
+ # Invisible characters split into two policies. The always-strip set has no
47
+ # legitimate use in the registers this skill meets (prose, wiki, chat), so a
48
+ # hit is stray junk wherever it lands: zero-width space, word joiner, the
49
+ # invisible math operators, a non-leading byte-order mark, and the deprecated
50
+ # Mongolian vowel separator. Unicode tag characters (U+E0000-E007F) and
51
+ # noncharacters carry the same verdict and are matched by range below — the tag
52
+ # block is the newest text-hiding trick and has no visible rendering at all.
53
+ ALWAYS_STRIP_INVISIBLES = frozenset(
54
+ {0x200B, 0x2060, 0x2061, 0x2062, 0x2063, 0x2064, 0xFEFF, 0x180E}
55
+ )
56
+ # The conditional set is legitimate in the right script and corrupts text if
57
+ # stripped blindly: the zero-width joiner binds emoji sequences and shapes
58
+ # Indic/Arabic ligatures, the non-joiner separates Persian/Devanagari letters,
59
+ # and the variation selectors choose an emoji or text glyph. Each is kept when
60
+ # its neighbours prove the legitimate context and stripped otherwise.
61
+ CONDITIONAL_INVISIBLES = frozenset({0x200C, 0x200D, 0xFE0E, 0xFE0F})
62
+
63
+
64
+ def _is_tag_char(cp: int) -> bool:
65
+ return 0xE0000 <= cp <= 0xE007F
66
+
67
+
68
+ def _is_noncharacter(cp: int) -> bool:
69
+ return 0xFDD0 <= cp <= 0xFDEF or (cp & 0xFFFF) in (0xFFFE, 0xFFFF)
70
+
71
+
72
+ def _is_managed_invisible(cp: int) -> bool:
73
+ """True for any codepoint the bypass pass may remove."""
74
+ return (
75
+ cp in ALWAYS_STRIP_INVISIBLES
76
+ or cp in CONDITIONAL_INVISIBLES
77
+ or _is_tag_char(cp)
78
+ or _is_noncharacter(cp)
79
+ )
80
+
81
+
82
+ def _is_emoji_related(char: str) -> bool:
83
+ """A base, modifier, or selector that can appear in an emoji sequence."""
84
+ if not char:
85
+ return False
86
+ cp = ord(char)
87
+ return (
88
+ # The emoji/pictograph block already subsumes the regional-indicator
89
+ # (U+1F1E6-1F1FF) and skin-tone (U+1F3FB-1F3FF) ranges, so they need no
90
+ # separate clause.
91
+ 0x1F000 <= cp <= 0x1FAFF # emoji and pictograph blocks
92
+ or 0x2600 <= cp <= 0x27BF # misc symbols and dingbats
93
+ or 0x2B00 <= cp <= 0x2BFF # misc symbols and arrows
94
+ or 0xFE00 <= cp <= 0xFE0F # variation selectors
95
+ or cp == 0x20E3 # combining enclosing keycap
96
+ or cp
97
+ in {0x203C, 0x2049, 0x2122, 0x2139, 0x2194, 0x21A9, 0x231A, 0x24C2, 0x25AA, 0x3030, 0x303D, 0x3297, 0x3299}
98
+ )
99
+
100
+
101
+ def _is_emoji_base(char: str) -> bool:
102
+ """Emoji context that can legitimately anchor a variation selector.
103
+
104
+ This is ``_is_emoji_related`` minus the variation-selector block itself. A
105
+ variation selector is only meaningful directly after a real base glyph, so
106
+ one selector must not treat an adjacent selector as its context -- otherwise
107
+ two injected selectors between plain letters would each vouch for the other
108
+ and both survive the bypass pass.
109
+ """
110
+ if not char:
111
+ return False
112
+ if 0xFE00 <= ord(char) <= 0xFE0F:
113
+ return False
114
+ return _is_emoji_related(char)
115
+
116
+
117
+ def _in_joining_script(char: str) -> bool:
118
+ """A letter from a script that uses the zero-width (non-)joiner for shaping."""
119
+ if not char:
120
+ return False
121
+ cp = ord(char)
122
+ return (
123
+ 0x0590 <= cp <= 0x074F # Hebrew, Arabic, Syriac
124
+ or 0x0750 <= cp <= 0x07BF # Arabic Supplement, Thaana
125
+ or 0x08A0 <= cp <= 0x08FF # Arabic Extended-A
126
+ or 0x0900 <= cp <= 0x0DFF # Devanagari through Sinhala
127
+ or 0x1780 <= cp <= 0x17FF # Khmer
128
+ or 0xA8E0 <= cp <= 0xA8FF # Devanagari Extended
129
+ or 0xFB1D <= cp <= 0xFB4F # Hebrew presentation forms
130
+ or 0xFB50 <= cp <= 0xFDCF # Arabic Presentation Forms-A (below noncharacters)
131
+ or 0xFDF0 <= cp <= 0xFDFF # Arabic Presentation Forms-A (above noncharacters)
132
+ or 0xFE70 <= cp <= 0xFEFC # Arabic Presentation Forms-B
133
+ )
134
+
135
+
136
+ def _strip_invisible(cp: int, prev_char: str, next_char: str) -> bool:
137
+ """Decide whether a managed invisible character is stray and should go.
138
+
139
+ The conditional joiners and variation selectors are kept when a neighbour
140
+ supplies the legitimate context (an emoji run, or a joining-script letter)
141
+ and removed otherwise. Every other managed codepoint is always removed.
142
+ """
143
+ if cp == 0x200D: # zero-width joiner
144
+ return not (
145
+ _is_emoji_related(prev_char)
146
+ or _is_emoji_related(next_char)
147
+ or _in_joining_script(prev_char)
148
+ or _in_joining_script(next_char)
149
+ )
150
+ if cp == 0x200C: # zero-width non-joiner
151
+ return not (_in_joining_script(prev_char) or _in_joining_script(next_char))
152
+ if cp in (0xFE0E, 0xFE0F): # variation selectors 15 and 16
153
+ return not (_is_emoji_base(prev_char) or _is_emoji_base(next_char))
154
+ return True
155
+
156
+
157
+ def _strip_invisibles(body: str, offset_base: int) -> tuple[str, int, int]:
158
+ """Remove stray invisibles from ``body``, keeping legitimate joiners.
159
+
160
+ Returns (cleaned_text, removed_count, first_removed_offset). Neighbour
161
+ lookups use the original text so an emoji or Arabic run is judged before any
162
+ character is dropped.
163
+ """
164
+ out: list[str] = []
165
+ removed = 0
166
+ first_offset = -1
167
+ length = len(body)
168
+ for index, char in enumerate(body):
169
+ if _is_managed_invisible(ord(char)):
170
+ prev_char = body[index - 1] if index > 0 else ""
171
+ next_char = body[index + 1] if index + 1 < length else ""
172
+ if _strip_invisible(ord(char), prev_char, next_char):
173
+ removed += 1
174
+ if first_offset < 0:
175
+ first_offset = index + offset_base
176
+ continue
177
+ out.append(char)
178
+ return "".join(out), removed, first_offset
179
+
180
+
181
+ @dataclass(frozen=True)
182
+ class Rule:
183
+ id: str
184
+ family: int | None
185
+ regex: re.Pattern[str]
186
+ message: str
187
+ severity: str = "warning"
188
+ source_risk: bool = False
189
+ once_per_doc: bool = False
190
+
191
+
192
+ @dataclass(frozen=True)
193
+ class ProtectedToken:
194
+ kind: str
195
+ value: str
196
+ evidence: str
197
+ offset: int
198
+
199
+
200
+ ARTIFACT_RULES = [
201
+ Rule(
202
+ "artifact.chatgpt_citation_stub",
203
+ 9,
204
+ re.compile(r"(?:cite)?turn\d+(?:search|image|news|file)\d+", re.I),
205
+ "ChatGPT citation stub leaked into text",
206
+ "error",
207
+ True,
208
+ ),
209
+ Rule(
210
+ "artifact.content_reference",
211
+ 9,
212
+ re.compile(r":?contentReference\[oaicite:\d+\]\{index=\d+\}|contentReference", re.I),
213
+ "ChatGPT contentReference citation residue",
214
+ "error",
215
+ True,
216
+ ),
217
+ Rule(
218
+ "artifact.oai_citation",
219
+ 9,
220
+ re.compile(r"oaicite|oai_citation(?::\d+)?", re.I),
221
+ "OpenAI citation residue",
222
+ "error",
223
+ True,
224
+ ),
225
+ Rule(
226
+ "artifact.attribution_json",
227
+ 9,
228
+ re.compile(r'attributableIndex|\(\{"attribution":\{"attributableIndex":"\d+-\d+"\}\}\)|\^\[[^\]]+\]\('),
229
+ "JSON attribution block leaked into prose",
230
+ "error",
231
+ True,
232
+ ),
233
+ Rule(
234
+ "artifact.perplexity_tag",
235
+ 9,
236
+ re.compile(r"\[(?:attached_file|web):\d+\]", re.I),
237
+ "Perplexity source tag leaked into text",
238
+ "error",
239
+ True,
240
+ ),
241
+ Rule(
242
+ "artifact.grok_card",
243
+ 9,
244
+ re.compile(r"grok[_-]card|<grok-card[^>]*>|grok_render_citation_card_json=\{[^}]*\}", re.I),
245
+ "Grok citation card leaked into text",
246
+ "error",
247
+ True,
248
+ ),
249
+ Rule(
250
+ "artifact.lenticular_reference",
251
+ 9,
252
+ re.compile(r"【\d+†[^】]*】"),
253
+ "Line-reference citation marker leaked into prose",
254
+ "error",
255
+ True,
256
+ ),
257
+ Rule(
258
+ "artifact.ai_tracking_url",
259
+ 9,
260
+ re.compile(
261
+ r"[?&](?:utm_source=(?:chatgpt\.com|claude\.ai|copilot\.com|openai|perplexity\.ai)"
262
+ r"|referrer=grok\.com)",
263
+ re.I,
264
+ ),
265
+ "AI-tool tracking parameter in URL",
266
+ "error",
267
+ True,
268
+ ),
269
+ Rule(
270
+ "artifact.roleplay_marker",
271
+ 9,
272
+ re.compile(
273
+ r"(?<!\*)\*(?:nods|sighs|laughs|smiles|frowns|shrugs|grins|winks|chuckles|gasps|"
274
+ r"pauses|whispers|shouts|glances|smirks|blinks)(?:[^*\n]{0,60})?\*(?!\*)",
275
+ re.I,
276
+ ),
277
+ "Chat roleplay action marker leaked into text",
278
+ "error",
279
+ False,
280
+ ),
281
+ Rule(
282
+ "artifact.assistant_self_disclosure",
283
+ 9,
284
+ re.compile(
285
+ # The assistant speaking as itself: "As an AI language model, I".
286
+ # Anchored on the first person so a sentence *about* a language
287
+ # model ("the film is about an AI language model") stays clear.
288
+ r"\b(?:as an? (?:ai|artificial intelligence|large language model|llm)(?: (?:language )?(?:model|assistant|chatbot|system))?,? i\b|"
289
+ r"i(?:'m| am) (?:just |only )?an? (?:ai|artificial intelligence|large language model|llm)(?: (?:language )?(?:model|assistant|chatbot))?\b|"
290
+ r"i (?:do not|don't) have (?:access to )?real-time (?:data|information|access)|"
291
+ r"i (?:cannot|can't|am unable to) browse the internet)",
292
+ re.I,
293
+ ),
294
+ "Assistant self-disclosure leaked from a chat reply",
295
+ "error",
296
+ False,
297
+ ),
298
+ Rule(
299
+ "artifact.humanizer_whitespace",
300
+ 9,
301
+ re.compile(
302
+ # Thin, hair, six-per-em, figure, punctuation, and medium math
303
+ # spaces between letters. No keyboard produces them; "humanizer"
304
+ # tools swap them for ordinary spaces to break exact matching. The
305
+ # non-breaking space is left alone: editors and web copy use it.
306
+ r"(?<=[A-Za-z,.;:!?])[      ](?=[A-Za-z])",
307
+ ),
308
+ "Typographic space character where a keyboard space belongs",
309
+ "warning",
310
+ False,
311
+ True,
312
+ ),
313
+ Rule(
314
+ "artifact.bracket_placeholder",
315
+ 9,
316
+ re.compile(
317
+ # Generic fill-in blanks ([Recipient Name], [Company], [Date]) as
318
+ # well as the on-wiki forms. A Markdown link or reference
319
+ # ("[Your account](url)", "[Company][1]", "[1]: url") and a wiki
320
+ # link ("[[Date]]") are not placeholders, so an opening bracket
321
+ # before, or a "(" ":" "[" after, cancels the match.
322
+ r"(?<!\[)\[(?:Your Name|Entertainer's Name|insert[^\]]*|describe[^\]]*|link to[^\]]*|"
323
+ r"(?:Your|Insert|Recipient|Client|Candidate|Author|Employer|Manager|Company|Product|"
324
+ r"Organization|Organisation|Address|City|State|Country|Email|Phone|Date|Name|Title|"
325
+ r"Position|Job Title|Signature|Website|URL|Number|Amount|Quantity|Reason|Topic|Subject)"
326
+ r"(?: [A-Za-z]+){0,3})\](?!\s*[\(\[:])",
327
+ re.I,
328
+ ),
329
+ "Unfilled bracket placeholder",
330
+ "error",
331
+ True,
332
+ ),
333
+ Rule(
334
+ "artifact.insert_placeholder",
335
+ 9,
336
+ re.compile(r"\b(?:INSERT|PASTE|SOURCE)_[A-Z0-9_]+(?:_HERE)?\b"),
337
+ "Unfilled all-caps placeholder",
338
+ "error",
339
+ True,
340
+ ),
341
+ Rule(
342
+ "artifact.placeholder_comment",
343
+ 9,
344
+ re.compile(r"<!--\s*Add (?:if available|[^>]*)-->", re.I),
345
+ "Unfilled HTML placeholder comment",
346
+ "error",
347
+ True,
348
+ ),
349
+ Rule(
350
+ "artifact.placeholder_date",
351
+ 9,
352
+ re.compile(r"20\d{2}-(?:XX|\d{2})-XX|\b20XX\b", re.I),
353
+ "Placeholder date",
354
+ "error",
355
+ True,
356
+ ),
357
+ ]
358
+
359
+ FAMILY_RULES = [
360
+ Rule(
361
+ "family1.significance_inflation",
362
+ 1,
363
+ re.compile(
364
+ r"\b(?:stands as|serves as|pivotal role|pivotal moment|enduring commitment|lasting importance|"
365
+ r"scientific landscape|rich heritage|renowned|groundbreaking|vibrant|breathtaking|boasts)\b",
366
+ re.I,
367
+ ),
368
+ "Significance or promotional inflation",
369
+ ),
370
+ Rule(
371
+ "family2.vague_attribution",
372
+ 2,
373
+ re.compile(
374
+ r"\b(?:experts (?:argue|believe)|observers note|studies show|researchers (?:say|believe)|"
375
+ r"gained recognition|featured in|cited in|national media|praised by critics|active social media)\b",
376
+ re.I,
377
+ ),
378
+ "Vague attribution or notability padding",
379
+ source_risk=True,
380
+ ),
381
+ Rule(
382
+ "family2.speculative_gap_filling",
383
+ 2,
384
+ re.compile(
385
+ r"\b(?:is|was|are|were)\s+(?:widely\s+)?believed\s+to\s+have\b|"
386
+ r"\blikely\s+(?:began|started|joined|studied|served|worked)\b|"
387
+ r"\bappears?\s+to\s+have\s+(?:been|begun|studied|worked|served)\b|"
388
+ r"\bmaintains\s+a\s+relatively\s+low\s+(?:public\s+)?profile\b",
389
+ re.I,
390
+ ),
391
+ "Speculative gap-filling presented as fact",
392
+ source_risk=True,
393
+ ),
394
+ Rule(
395
+ "family2.vague_validation",
396
+ 2,
397
+ re.compile(
398
+ r"\b(?:independent\s+(?:testing|tests|analysis|audits?)\s+(?:confirm|confirms|show|shows|validate|validates)|"
399
+ r"third-party\s+benchmarks?\s+(?:show|confirm|place|put)|"
400
+ r"analysts\s+(?:agree|say|note|believe)|"
401
+ r"studies\s+consistently\s+show)\b",
402
+ re.I,
403
+ ),
404
+ "Unnamed third-party validation",
405
+ source_risk=True,
406
+ ),
407
+ Rule(
408
+ "family3.filler_framing",
409
+ 3,
410
+ re.compile(
411
+ # "it is important to note" is family5-only (anticipatory "it",
412
+ # catalog 5.1); listing it here too scored one phrase twice.
413
+ r"\b(?:in today's|in conclusion|in order to|due to the fact that|"
414
+ r"at its core|when it comes to|future belongs|exciting journey|journey toward excellence|"
415
+ r"highlighting|underscoring|showcasing|fostering)\b",
416
+ re.I,
417
+ ),
418
+ "Filler framing or superficial analysis",
419
+ ),
420
+ Rule(
421
+ "family5.syntactic_tell",
422
+ 5,
423
+ re.compile(
424
+ r"\b(?:it is important to note|there are several|there exists|it can be argued|it has been shown|"
425
+ r"it should be emphasized|what matters is|not insignificant)\b",
426
+ re.I,
427
+ ),
428
+ "Syntactic tell or hedged construction",
429
+ ),
430
+ Rule(
431
+ "family6.verbosity_padding",
432
+ 6,
433
+ re.compile(
434
+ r"\b(?:made the decision|has the ability to|is able to|general feeling|primary reason was the fact|"
435
+ r"communication with customers should be improved|at this point in time|provide an explanation|"
436
+ r"conduct an analysis)\b",
437
+ re.I,
438
+ ),
439
+ "Verbosity, nominalization, or padding",
440
+ ),
441
+ Rule(
442
+ "family7.rhetorical_formula",
443
+ 7,
444
+ re.compile(
445
+ # The paired forms stay inside one sentence. With DOTALL and a
446
+ # greedy ".+", "not just" in one paragraph matched the last "but"
447
+ # anywhere later in the document.
448
+ r"\b(?:not just\b[^.!?]{0,150}?\bbut\b|not only\b[^.!?]{0,150}?\bbut also\b|"
449
+ # "not just X; it's Y" / "not just X. It's Y": the contrast lands in
450
+ # the next clause or sentence without a "but". Anchored on a
451
+ # preceding copula ("is/it's not just") so "had not just arrived"
452
+ # stays clear.
453
+ r"(?:is|'s|are|was|were)\s+not just\b[^.!?;,]{0,120}?[.;,:]\s*(?:it'?s|it is|that'?s|this is|they'?re)\b|"
454
+ r"here's the thing|you know what|watch this|"
455
+ r"what if i told you|let that sink in|plot twist|full stop|see what i did there)\b",
456
+ re.I,
457
+ ),
458
+ "Rhetorical formula or forced cadence",
459
+ ),
460
+ # The naive two-word triplet pattern ("\w+ \w+, \w+ \w+, and \w+ \w+") was
461
+ # removed from family7 after corpus measurement (corpus/RESULTS.md): it fired
462
+ # on 33% of human wiki documents (180/548) — ordinary enumerations of names,
463
+ # offices, and places — and three hits force-floor the risk score to review.
464
+ # Rule-of-three judgment is model-side (catalog §7.3, coverage-map).
465
+ Rule(
466
+ "family8.markdown_structure",
467
+ 8,
468
+ re.compile(r"^\s*(?:---\s*$|#{3,}\s+|\|\s*[^|\n]+\s*\||[-*+]\s+\*\*[^*\n]+:\*\*)", re.M),
469
+ "Mechanical structure or Markdown formatting tell",
470
+ once_per_doc=True,
471
+ # once_per_doc calibrated against the human-control corpus: table-heavy
472
+ # human documents matched ~29 lines each, so this one rule alone maxed
473
+ # the family score cap and tripped the >=10-findings review floor.
474
+ # Whether mechanical structure is present is a document-level fact; one
475
+ # finding carries it.
476
+ ),
477
+ Rule(
478
+ "family8.list_label_period",
479
+ 8,
480
+ re.compile(r"^\s*[-*+]\s+\*\*[^*\n]{1,40}?\.\*\*\s+\S", re.M),
481
+ "List label ends with a period instead of a colon",
482
+ ),
483
+ Rule(
484
+ "family9.humanizer_spacing",
485
+ 9,
486
+ re.compile(
487
+ # "end.Next" — a sentence boundary with the space removed, the most
488
+ # common damage from rule-based humanizers (Pangram, Aug 2025).
489
+ # Two lowercase letters before the stop skip abbreviations (e.g.)
490
+ # and the lowercase after the capital skips file names and acronyms.
491
+ r"(?<=[a-z][a-z])[.!?](?=[A-Z][a-z]{2})"
492
+ ),
493
+ "Missing space after a sentence end",
494
+ "warning",
495
+ False,
496
+ ),
497
+ Rule(
498
+ "family9.tortured_phrase",
499
+ 9,
500
+ re.compile(
501
+ # Known one-for-one synonym swaps that no writer chooses: the
502
+ # Problematic Paper Screener's classics plus a few stilted verbs.
503
+ # Also common in non-native English, so the message says "check".
504
+ r"\b(?:counterfeit consciousness|computerized reasoning|profound (?:learning|neural organi[sz]ation)|"
505
+ r"(?:irregular|arbitrary) (?:timberland|woodland)|bosom (?:peril|malignancy|malignant growth)|"
506
+ r"(?:colossal|huge) information|signal to commotion|mean square blunder|"
507
+ r"normal language handling|choice tree|(?:picture|discourse|face) acknowledgment|"
508
+ r"backing vector machine|fake neural organi[sz]ation|worldwide temperature alteration|"
509
+ r"require to (?:obtain|acquire|get)|creating a peculiar)\b",
510
+ re.I,
511
+ ),
512
+ "Phrase reads like a mechanical synonym swap; check the intended term",
513
+ "warning",
514
+ False,
515
+ ),
516
+ Rule(
517
+ "family9.chatbot_residue",
518
+ 9,
519
+ re.compile(
520
+ # "of course!" sits outside the group: it ends in punctuation, so a
521
+ # closing \b only matched when a letter followed with no space.
522
+ r"\bof course!|"
523
+ r"\b(?:great question|here's a polished|i hope this helps|certainly|best regards|"
524
+ r"let me know if|would you like me|let me walk you through|here's how i'd think about|"
525
+ r"while i understand the appeal|"
526
+ r"(?:as of|up to|based on) my (?:last |most recent |latest )?(?:knowledge |training )?(?:update|cutoff|data)|"
527
+ r"my (?:knowledge|training) (?:cutoff|data)(?: only)? (?:goes|extends|runs) (?:up )?to|my knowledge cutoff|"
528
+ r"on the one hand\b.{0,120}\bon the other)\b",
529
+ re.I,
530
+ ),
531
+ "Chatbot wrapper or collaborative residue",
532
+ ),
533
+ ]
534
+
535
+ # Tier 1A: words reported far more frequent in machine text than human text. The
536
+ # frequency claim is inherited from the source catalogs, not measured here; a
537
+ # cluster of these is still the strongest vocabulary evidence this CLI has.
538
+ AI_VOCAB_TIER1 = {
539
+ "delve",
540
+ "emphasizing",
541
+ "enduring",
542
+ "garner",
543
+ "interplay",
544
+ "intricate",
545
+ "landscape",
546
+ "meticulous",
547
+ "pivotal",
548
+ "robust",
549
+ "showcase",
550
+ "showcasing",
551
+ "tapestry",
552
+ "testament",
553
+ "underscore",
554
+ "vibrant",
555
+ }
556
+
557
+ # Tier 2: legitimate on their own; evidence only in clusters.
558
+ AI_VOCAB_TIER2 = {
559
+ "additionally",
560
+ "align",
561
+ "boasts",
562
+ "bolstered",
563
+ "cornerstone",
564
+ "crucial",
565
+ "enhance",
566
+ "foster",
567
+ "fostering",
568
+ "highlight",
569
+ "highlighting",
570
+ "leverage",
571
+ "unlock",
572
+ "valuable",
573
+ }
574
+
575
+ AI_VOCAB = AI_VOCAB_TIER1 | AI_VOCAB_TIER2
576
+
577
+ # Tier 1B: wordiness and register inflation. Replacing these is good writing
578
+ # regardless of who wrote the sentence, so they are reported as clarity edits,
579
+ # carry no tell family, and contribute nothing to the risk score — a wordiness
580
+ # fix must never push a document toward an AI classification.
581
+ CLARITY_RULES = [
582
+ Rule(
583
+ "clarity.wordiness",
584
+ None,
585
+ re.compile(
586
+ r"\b(?:utiliz(?:e|es|ed|ing|ation)|commenc(?:e|es|ed|ing)|"
587
+ r"facilitat(?:e|es|ed|ing)|endeavor(?:s|ed|ing)?|ascertain(?:s|ed|ing)?|"
588
+ # Multi-word wrappers around a one-word meaning (HH). "due to the
589
+ # fact that" stays family3-only; listing it here too would
590
+ # double-count one phrase across tiers. "has the ability to" stays
591
+ # family6-only for the same reason.
592
+ r"in the event that|for the purpose of|"
593
+ r"with regard to|with respect to|in light of the fact that|"
594
+ r"despite the fact that)\b",
595
+ re.I,
596
+ ),
597
+ "Inflated register (a clarity edit, not authorship evidence)",
598
+ "info",
599
+ ),
600
+ ]
601
+
602
+ SOURCE_RISK_RULES = [
603
+ Rule(
604
+ "source_risk.citation_markup",
605
+ None,
606
+ re.compile(r"<ref\b|</ref>|{{\s*cite\b|\[\^[^\]]+\]|\[[^\]]+\]\(https?://[^)]+\)", re.I),
607
+ "Citation markup or linked claim needs source verification",
608
+ source_risk=True,
609
+ ),
610
+ Rule(
611
+ "source_risk.bare_url",
612
+ None,
613
+ re.compile(r"https?://[^\s)>\]]+", re.I),
614
+ "Bare URL or linked source needs verification",
615
+ source_risk=True,
616
+ ),
617
+ Rule(
618
+ "source_risk.source_dependent_statement",
619
+ None,
620
+ re.compile(
621
+ r"\b(?:according to|reported|reports|study|studies|survey|records|cited|praised by|critics|"
622
+ r"observers note|experts|researchers|national media|available sources)\b",
623
+ re.I,
624
+ ),
625
+ "Source-dependent statement needs verification",
626
+ source_risk=True,
627
+ ),
628
+ ]
629
+
630
+ # Backtick and tilde fences (CommonMark allows both). Opener and closer each
631
+ # start a line and the closer matches the opener, so a sentence that mentions
632
+ # ``` in passing cannot pair with a later fence and swallow the prose between.
633
+ CODE_BLOCK_RE = re.compile(r"^[ \t]*(```|~~~)[^\n`~]*\n(?:.*?\n)?[ \t]*\1[ \t]*$", re.M | re.S)
634
+ URL_RE = re.compile(r"https?://[^\s<>\]\)\"'|{}]+", re.I)
635
+ MARKDOWN_LINK_RE = re.compile(r"(?<!!)\[([^\]\n]+)\]\(([^)\s]+(?:\s+\"[^\"]*\")?)\)")
636
+ FOOTNOTE_REF_RE = re.compile(r"\[\^[^\]\n]+\]")
637
+ FOOTNOTE_DEF_RE = re.compile(r"^\[\^[^\]\n]+\]:.*$", re.M)
638
+ REF_TAG_RE = re.compile(r"<ref\b[^>]*(?:/>|>.*?</ref>)", re.I | re.S)
639
+ CITE_TEMPLATE_RE = re.compile(r"{{\s*cite\b.*?}}", re.I | re.S)
640
+ QUOTE_RE = re.compile(r'"[^"\n]{3,}"|“[^”\n]{3,}”')
641
+ BLOCKQUOTE_RE = re.compile(r"^(?:>\s?.+(?:\n|$))+", re.M)
642
+ DATE_RE = re.compile(
643
+ r"\b(?:20XX|\d{4}-(?:\d{2}|XX)-(?:\d{2}|XX)|"
644
+ r"(?:Jan(?:uary)?|Feb(?:ruary)?|Mar(?:ch)?|Apr(?:il)?|May|Jun(?:e)?|Jul(?:y)?|"
645
+ r"Aug(?:ust)?|Sep(?:tember)?|Oct(?:ober)?|Nov(?:ember)?|Dec(?:ember)?)\.?\s+"
646
+ r"\d{1,2},?\s+\d{4}|"
647
+ r"\d{1,2}\s+(?:Jan(?:uary)?|Feb(?:ruary)?|Mar(?:ch)?|Apr(?:il)?|May|Jun(?:e)?|"
648
+ r"Jul(?:y)?|Aug(?:ust)?|Sep(?:tember)?|Oct(?:ober)?|Nov(?:ember)?|Dec(?:ember)?)\.?\s+"
649
+ r"\d{4}|"
650
+ r"(?:Jan(?:uary)?|Feb(?:ruary)?|Mar(?:ch)?|Apr(?:il)?|May|Jun(?:e)?|Jul(?:y)?|"
651
+ r"Aug(?:ust)?|Sep(?:tember)?|Oct(?:ober)?|Nov(?:ember)?|Dec(?:ember)?)\.?\s+\d{4}|"
652
+ r"(?:1[6-9]\d{2}|20\d{2}|21\d{2}))\b",
653
+ re.I,
654
+ )
655
+ CURRENCY_RE = re.compile(
656
+ r"(?:[$€£]|USD|EUR|GBP)\s?\d[\d,]*(?:\.\d+)?"
657
+ r"(?:\s?(?:million|billion|trillion|thousand|m|bn|k))?",
658
+ re.I,
659
+ )
660
+ PERCENT_RE = re.compile(r"\b\d[\d,]*(?:\.\d+)?\s?%")
661
+ RANGE_RE = re.compile(r"\b\d[\d,]*(?:\.\d+)?\s*(?:-|–|—|to)\s*\d[\d,]*(?:\.\d+)?\b", re.I)
662
+ NUMBER_RE = re.compile(r"\b\d[\d,]*(?:\.\d+)?\b")
663
+ NAME_RE = re.compile(
664
+ r"\b[A-Z][A-Za-z0-9'.&-]+(?:\s+(?:of|the|and|for|de|del|la|le|du|van|von|&|"
665
+ r"[A-Z][A-Za-z0-9'.&-]+)){1,}",
666
+ )
667
+ ORG_HINT_RE = re.compile(
668
+ r"\b(?:Agency|Association|Bureau|Committee|Commission|Company|Corporation|Council|Department|"
669
+ r"Foundation|Group|Institute|LLC|Ltd|Ministry|Office|Organization|Research|University)\b"
670
+ )
671
+ SOURCE_MARKER_RE = re.compile(
672
+ r"https?://|<ref\b|{{\s*cite\b|\[\^[^\]]+\]|\[[^\]]+\]\(|"
673
+ r"\b(?:according to|reported|reports|study|studies|survey|records|cited|evidence|data|"
674
+ r"researchers|observers|experts|said|announced|published|filed)\b",
675
+ re.I,
676
+ )
677
+ REPORTING_LANGUAGE_RE = re.compile(
678
+ r"\b(?:according to|reported|reports|study|studies|survey|records|cited|evidence|data|"
679
+ r"researchers|observers|experts|said|announced|published|filed)\b",
680
+ re.I,
681
+ )
682
+ NAME_CONNECTORS = {"and", "de", "del", "du", "for", "la", "le", "of", "the", "van", "von"}
683
+ NAME_EXCLUDE_START = {"A", "An", "As", "At", "By", "For", "From", "If", "In", "It", "On", "Or", "The", "This", "To"}
684
+ # A capital is evidence of a proper noun only when the author chose it. At the
685
+ # head of a sentence the capital is grammar, so a run that opens a sentence has
686
+ # its head token demoted when there is positive evidence the head is an ordinary
687
+ # word: it appears lowercase somewhere in the compared texts, or it is a known
688
+ # function word or sentence opener. Without this, ordinary copy edits reported
689
+ # protected-name drift -- deleting a leading connective recapitalizes the next
690
+ # word ("Additionally, comedian Dana Whitfield" -> "Comedian Dana Whitfield"), and
691
+ # a determiner fix rewrites the head ("These UCBSD" -> "The UCBSD"). Only the
692
+ # head is dropped, so the real name inside the run still compares. A head with
693
+ # no such evidence is kept, so the check still fails closed on a real name.
694
+ NAME_SENTENCE_END = ".!?"
695
+ NAME_RUN_OPENERS = " \t\r\n\f\v'\"“”‘’([{*#:;|=<>"
696
+ NAME_HEAD_RE = re.compile(r"\S+\s+")
697
+ LOWERCASE_WORD_RE = re.compile(r"\b[a-z][A-Za-z0-9'.&-]*")
698
+ # NAME_RE token characters include ".", so a greedy run swallows the period that
699
+ # ends a sentence and joins the next sentence's first word ("... School
700
+ # District. These UCBSD"). A boundary is a word of at least three letters ending
701
+ # in a period, followed by a capital -- which leaves "St.", "U.S." and the named
702
+ # abbreviations below joined to what follows them.
703
+ NAME_SENTENCE_SPLIT_RE = re.compile(r"(?<=[A-Za-z]{3}\.)\s+(?=[A-Z])")
704
+ NAME_ABBREVIATIONS = frozenset(
705
+ """
706
+ adm amb approx asst assoc bros capt col corp dept esq fig gen gov hon inc
707
+ jr ltd maj mrs prof pres rep rev sec sen sgt sr univ vol
708
+ """.split()
709
+ )
710
+ # Ordinary words that routinely open a sentence. A backstop for the lowercase
711
+ # evidence above, which a short text may not supply. Grouped by part of speech
712
+ # so later additions stay coherent rather than fitted to one failing case.
713
+ NAME_SENTENCE_OPENERS = frozenset(
714
+ """
715
+ a an the this that these those each every either neither both all any some
716
+ many most much few several other another such various same
717
+ he she it they we you his her its their our your my him them us
718
+ who whom whose which what where when why how there here
719
+ and but or nor for yet so as if because although though while whereas
720
+ since until unless before after during despite besides beyond within
721
+ across against among between beneath under over above about into onto
722
+ from with without through throughout toward towards upon per via
723
+ is are was were be been being am has have had do does did
724
+ can could might must shall should would
725
+ not only also just even still again once then now today later
726
+ however meanwhile therefore thus hence instead rather nevertheless
727
+ nonetheless likewise similarly conversely accordingly consequently
728
+ additionally furthermore moreover subsequently ultimately alternatively
729
+ central main major minor primary secondary key local national regional
730
+ modern early earlier late recent current former original general public
731
+ private official large small new old further overall total annual daily
732
+ initial final following historically together part parts
733
+ one two three first second third last next
734
+ """.split()
735
+ )
736
+
737
+
738
+ def name_run_is_sentence_initial(text: str, start: int) -> bool:
739
+ """True when ``start`` opens a sentence, a heading, or a list item."""
740
+ index = start - 1
741
+ while index >= 0 and text[index] in NAME_RUN_OPENERS:
742
+ if text[index] in "\r\n":
743
+ return True
744
+ index -= 1
745
+ return index < 0 or text[index] in NAME_SENTENCE_END
746
+
747
+
748
+ def name_vocabulary(*texts: str) -> set[str]:
749
+ """Words the compared texts show in lowercase at least once."""
750
+ words: set[str] = set()
751
+ for text in texts:
752
+ words.update(word.lower() for word in LOWERCASE_WORD_RE.findall(text))
753
+ return words
754
+
755
+
756
+ def head_is_ordinary_word(head: str, ordinary: set[str]) -> bool:
757
+ lowered = head.lower().strip(".,:;")
758
+ return (
759
+ lowered in ordinary
760
+ or lowered in STOPWORDS
761
+ or lowered in NAME_SENTENCE_OPENERS
762
+ or head in NAME_EXCLUDE_START
763
+ )
764
+ STOPWORDS = {
765
+ "a",
766
+ "an",
767
+ "and",
768
+ "are",
769
+ "as",
770
+ "at",
771
+ "be",
772
+ "by",
773
+ "for",
774
+ "from",
775
+ "has",
776
+ "have",
777
+ "in",
778
+ "is",
779
+ "it",
780
+ "of",
781
+ "on",
782
+ "or",
783
+ "that",
784
+ "the",
785
+ "their",
786
+ "this",
787
+ "to",
788
+ "was",
789
+ "were",
790
+ "with",
791
+ }
792
+
793
+
794
+ def line_starts(text: str) -> list[int]:
795
+ starts = [0]
796
+ for match in re.finditer(r"\n", text):
797
+ starts.append(match.end())
798
+ return starts
799
+
800
+
801
+ def line_column(starts: list[int], offset: int) -> tuple[int, int]:
802
+ low = 0
803
+ high = len(starts) - 1
804
+ while low <= high:
805
+ mid = (low + high) // 2
806
+ if starts[mid] <= offset:
807
+ low = mid + 1
808
+ else:
809
+ high = mid - 1
810
+ line_index = max(0, high)
811
+ return line_index + 1, offset - starts[line_index] + 1
812
+
813
+
814
+ def evidence(value: str) -> str:
815
+ collapsed = re.sub(r"\s+", " ", value).strip()
816
+ if len(collapsed) <= 120:
817
+ return collapsed
818
+ return collapsed[:117] + "..."
819
+
820
+
821
+ def regex_spans(text: str, regex: re.Pattern[str]) -> list[tuple[int, int]]:
822
+ return [(match.start(), match.end()) for match in regex.finditer(text)]
823
+
824
+
825
+ def blank_spans(text: str, spans: Iterable[tuple[int, int]]) -> str:
826
+ chars = list(text)
827
+ for start, end in spans:
828
+ for index in range(start, min(end, len(chars))):
829
+ if chars[index] != "\n":
830
+ chars[index] = " "
831
+ return "".join(chars)
832
+
833
+
834
+ def normalize_space(value: str) -> str:
835
+ return re.sub(r"\s+", " ", value).strip()
836
+
837
+
838
+ def normalize_url(raw_url: str) -> str:
839
+ trimmed = raw_url.strip().rstrip(").,;:!?")
840
+ parsed = urlsplit(trimmed)
841
+ query = urlencode(
842
+ [
843
+ (key, value)
844
+ for key, value in parse_qsl(parsed.query, keep_blank_values=True)
845
+ if not key.lower().startswith("utm_")
846
+ and not (key.lower() == "referrer" and value.lower() in AI_REFERRER_HOSTS)
847
+ ],
848
+ doseq=True,
849
+ )
850
+ return urlunsplit((parsed.scheme.lower(), parsed.netloc.lower(), parsed.path, query, parsed.fragment))
851
+
852
+
853
+ def normalize_bypass_text(text: str) -> tuple[str, dict[str, int], int]:
854
+ """Strip hidden and lookalike characters before pattern matching.
855
+
856
+ Returns (normalized_text, counts, first_offset). Invisible bypass characters
857
+ are removed -- a single leading BOM is ordinary file encoding, not a trick,
858
+ and is exempt; the zero-width joiner/non-joiner and the variation selectors
859
+ are kept where their neighbours prove a legitimate emoji or joining-script
860
+ context, so multilingual text is never corrupted, while Unicode tag
861
+ characters and noncharacters are always removed. Cyrillic/Greek
862
+ Latin-lookalike characters are mapped back to Latin only inside mixed-script
863
+ words, so genuine Cyrillic or Greek prose is never rewritten. Findings that
864
+ follow are located in the normalized text: line numbers are unaffected,
865
+ columns can shift only on lines that contained the removed characters.
866
+ """
867
+ counts = {"invisible": 0, "homoglyph": 0}
868
+ homoglyph_offset = -1
869
+ lead_bom = text.startswith("")
870
+ body = text[1:] if lead_bom else text
871
+ offset_base = 1 if lead_bom else 0
872
+
873
+ def swap_word(match: re.Match[str]) -> str:
874
+ nonlocal homoglyph_offset
875
+ word = match.group(0)
876
+ if not any(char in HOMOGLYPH_LATIN for char in word):
877
+ return word
878
+ counts["homoglyph"] += sum(1 for char in word if char in HOMOGLYPH_LATIN)
879
+ if homoglyph_offset < 0:
880
+ homoglyph_offset = match.start() + offset_base
881
+ return "".join(HOMOGLYPH_LATIN.get(char, char) for char in word)
882
+
883
+ # The homoglyph swap is length-preserving, so offsets into ``swapped`` still
884
+ # index the original body and stay comparable with the leading-BOM base.
885
+ swapped = WORD_WITH_HOMOGLYPH_RE.sub(swap_word, body)
886
+ normalized, counts["invisible"], invisible_offset = _strip_invisibles(swapped, offset_base)
887
+ offsets = [value for value in (homoglyph_offset, invisible_offset) if value >= 0]
888
+ first_offset = min(offsets) if offsets else -1
889
+ if lead_bom:
890
+ normalized = "" + normalized
891
+ return normalized, counts, first_offset
892
+
893
+
894
+ def normalize_citation(value: str) -> str:
895
+ normalized = URL_RE.sub(lambda match: normalize_url(match.group(0)), value)
896
+ return normalize_space(normalized).lower()
897
+
898
+
899
+ def normalize_date(value: str) -> str:
900
+ return normalize_space(value.replace(",", "")).lower()
901
+
902
+
903
+ def normalize_number(value: str) -> str:
904
+ normalized = normalize_space(value).lower().replace(",", "")
905
+ return re.sub(r"\s*(?:-|–|—|to)\s*", "-", normalized)
906
+
907
+
908
+ def normalize_name(value: str) -> str:
909
+ return normalize_space(value).strip(".,:;").lower()
910
+
911
+
912
+ def normalize_quote(value: str) -> str:
913
+ return normalize_space(value)
914
+
915
+
916
+ def citation_spans(text: str) -> list[tuple[int, int]]:
917
+ spans = []
918
+ for regex in (MARKDOWN_LINK_RE, FOOTNOTE_DEF_RE, FOOTNOTE_REF_RE, REF_TAG_RE, CITE_TEMPLATE_RE):
919
+ spans.extend(regex_spans(text, regex))
920
+ return spans
921
+
922
+
923
+ def masked_for_prose(text: str) -> str:
924
+ return blank_spans(text, regex_spans(text, CODE_BLOCK_RE) + citation_spans(text) + regex_spans(text, URL_RE))
925
+
926
+
927
+ def token_from_match(kind: str, match: re.Match[str], value: str | None = None) -> ProtectedToken:
928
+ raw = match.group(0)
929
+ return ProtectedToken(kind, value if value is not None else normalize_space(raw), evidence(raw), match.start())
930
+
931
+
932
+ def extract_url_tokens(text: str) -> list[ProtectedToken]:
933
+ return [
934
+ token_from_match("url", match, normalize_url(match.group(0)))
935
+ for match in URL_RE.finditer(blank_spans(text, regex_spans(text, CODE_BLOCK_RE)))
936
+ ]
937
+
938
+
939
+ def extract_date_tokens(text: str) -> list[ProtectedToken]:
940
+ masked = masked_for_prose(text)
941
+ return [token_from_match("date", match, normalize_date(match.group(0))) for match in DATE_RE.finditer(masked)]
942
+
943
+
944
+ def extract_number_tokens(text: str) -> list[ProtectedToken]:
945
+ prose = masked_for_prose(text)
946
+ masked = blank_spans(prose, regex_spans(prose, DATE_RE))
947
+ tokens = []
948
+ occupied: list[tuple[int, int]] = []
949
+ for regex in (CURRENCY_RE, PERCENT_RE, RANGE_RE):
950
+ for match in regex.finditer(blank_spans(masked, occupied)):
951
+ tokens.append(token_from_match("number", match, normalize_number(match.group(0))))
952
+ occupied.append((match.start(), match.end()))
953
+ for match in NUMBER_RE.finditer(blank_spans(masked, occupied)):
954
+ tokens.append(token_from_match("number", match, normalize_number(match.group(0))))
955
+ return tokens
956
+
957
+
958
+ def extract_name_tokens(
959
+ text: str, ordinary: set[str] | None = None
960
+ ) -> list[ProtectedToken]:
961
+ prose = masked_for_prose(text)
962
+ masked_lines = []
963
+ for line in prose.splitlines(keepends=True):
964
+ if not line.lstrip().startswith("#"):
965
+ masked_lines.append(line)
966
+ continue
967
+ newline = "\n" if line.endswith("\n") else ""
968
+ body = line[:-1] if newline else line
969
+ masked_lines.append(" " * len(body) + newline)
970
+ prose = "".join(masked_lines)
971
+ if ordinary is None:
972
+ ordinary = name_vocabulary(prose)
973
+ tokens = []
974
+ for match in NAME_RE.finditer(prose):
975
+ # A greedy run can span a sentence boundary; each side of the boundary
976
+ # is judged on its own, and every piece after the first opens a sentence
977
+ # by construction.
978
+ pieces = []
979
+ position = 0
980
+ span = match.group(0)
981
+ for boundary in NAME_SENTENCE_SPLIT_RE.finditer(span):
982
+ word = re.findall(r"[A-Za-z]+", span[position : boundary.start()])
983
+ if word and word[-1].lower() in NAME_ABBREVIATIONS:
984
+ continue
985
+ pieces.append((match.start() + position, span[position : boundary.start()]))
986
+ position = boundary.end()
987
+ pieces.append((match.start() + position, span[position:]))
988
+ for index, (offset, piece) in enumerate(pieces):
989
+ trimmed = False
990
+ if index > 0 or name_run_is_sentence_initial(prose, offset):
991
+ head_match = NAME_HEAD_RE.match(piece)
992
+ if head_match and head_is_ordinary_word(
993
+ piece[: head_match.end()].strip(), ordinary
994
+ ):
995
+ offset += head_match.end()
996
+ piece = piece[head_match.end() :]
997
+ trimmed = True
998
+ raw = normalize_space(piece)
999
+ words_in_name = re.findall(r"[A-Za-z][A-Za-z0-9'.&-]*", raw)
1000
+ semantic_words = [
1001
+ word for word in words_in_name if word.lower() not in NAME_CONNECTORS
1002
+ ]
1003
+ if len(semantic_words) < 2:
1004
+ continue
1005
+ if (
1006
+ not trimmed
1007
+ and semantic_words[0] in NAME_EXCLUDE_START
1008
+ and not ORG_HINT_RE.search(raw)
1009
+ ):
1010
+ continue
1011
+ tokens.append(ProtectedToken("name", normalize_name(raw), evidence(raw), offset))
1012
+ return tokens
1013
+
1014
+
1015
+ def extract_citation_tokens(text: str) -> list[ProtectedToken]:
1016
+ masked = blank_spans(text, regex_spans(text, CODE_BLOCK_RE))
1017
+ tokens = []
1018
+ for match in MARKDOWN_LINK_RE.finditer(masked):
1019
+ label = normalize_space(match.group(1)).lower()
1020
+ target = normalize_url(match.group(2).split()[0])
1021
+ tokens.append(ProtectedToken("citation", f"markdown:{label}->{target}", evidence(match.group(0)), match.start()))
1022
+ for regex, prefix in (
1023
+ (FOOTNOTE_DEF_RE, "footnote_def"),
1024
+ (FOOTNOTE_REF_RE, "footnote_ref"),
1025
+ (REF_TAG_RE, "ref"),
1026
+ (CITE_TEMPLATE_RE, "cite"),
1027
+ ):
1028
+ for match in regex.finditer(masked):
1029
+ tokens.append(
1030
+ ProtectedToken("citation", f"{prefix}:{normalize_citation(match.group(0))}", evidence(match.group(0)), match.start())
1031
+ )
1032
+ return tokens
1033
+
1034
+
1035
+ def extract_quote_tokens(text: str) -> list[ProtectedToken]:
1036
+ masked = blank_spans(text, regex_spans(text, CODE_BLOCK_RE))
1037
+ tokens = [token_from_match("quote", match, normalize_quote(match.group(0))) for match in QUOTE_RE.finditer(masked)]
1038
+ for match in BLOCKQUOTE_RE.finditer(masked):
1039
+ tokens.append(token_from_match("quote", match, normalize_quote(match.group(0))))
1040
+ return tokens
1041
+
1042
+
1043
+ def extract_code_block_tokens(text: str) -> list[ProtectedToken]:
1044
+ tokens = []
1045
+ for match in CODE_BLOCK_RE.finditer(text):
1046
+ block = match.group(0)
1047
+ digest = hashlib.sha256(block.encode("utf-8")).hexdigest()
1048
+ first_line = block.splitlines()[0] if block.splitlines() else "```"
1049
+ tokens.append(ProtectedToken("code_block", digest, evidence(first_line), match.start()))
1050
+ return tokens
1051
+
1052
+
1053
+ def compare_finding(
1054
+ finding_id: str,
1055
+ message: str,
1056
+ token: ProtectedToken,
1057
+ starts: list[int],
1058
+ side: str,
1059
+ source_risk: bool = False,
1060
+ ) -> dict[str, object]:
1061
+ line, column = line_column(starts, token.offset)
1062
+ return {
1063
+ "id": finding_id,
1064
+ "family": None,
1065
+ "severity": "error",
1066
+ "line": line,
1067
+ "column": column,
1068
+ "evidence": token.evidence,
1069
+ "message": message,
1070
+ "source_risk": source_risk,
1071
+ "side": side,
1072
+ }
1073
+
1074
+
1075
+ def token_buckets(tokens: list[ProtectedToken]) -> dict[str, list[ProtectedToken]]:
1076
+ buckets: dict[str, list[ProtectedToken]] = {}
1077
+ for token in tokens:
1078
+ buckets.setdefault(token.value, []).append(token)
1079
+ return buckets
1080
+
1081
+
1082
+ def compare_token_sets(
1083
+ kind: str,
1084
+ original_tokens: list[ProtectedToken],
1085
+ revised_tokens: list[ProtectedToken],
1086
+ original_starts: list[int],
1087
+ revised_starts: list[int],
1088
+ ) -> list[dict[str, object]]:
1089
+ labels = {
1090
+ "citation": "citation marker",
1091
+ "date": "date",
1092
+ "name": "name",
1093
+ "number": "number",
1094
+ "url": "URL target",
1095
+ }
1096
+ source_kinds = {"citation", "url"}
1097
+ original_counts = Counter(token.value for token in original_tokens)
1098
+ revised_counts = Counter(token.value for token in revised_tokens)
1099
+ if kind == "name":
1100
+ # What a name protects is the REFERENT, not how many times it is
1101
+ # repeated. Trimming one of an article's many mentions of its own
1102
+ # subject is a copy edit, not a dropped name, so a name still present in
1103
+ # the other text raises nothing; only a name that disappears entirely,
1104
+ # or appears from nowhere, is reported.
1105
+ original_counts = Counter(dict.fromkeys(original_counts, 1))
1106
+ revised_counts = Counter(dict.fromkeys(revised_counts, 1))
1107
+ original_buckets = token_buckets(original_tokens)
1108
+ revised_buckets = token_buckets(revised_tokens)
1109
+ findings = []
1110
+ for value, count in (original_counts - revised_counts).items():
1111
+ for token in original_buckets[value][:count]:
1112
+ findings.append(
1113
+ compare_finding(
1114
+ f"compare.{kind}.dropped",
1115
+ f"Dropped protected {labels[kind]}",
1116
+ token,
1117
+ original_starts,
1118
+ "original",
1119
+ kind in source_kinds,
1120
+ )
1121
+ )
1122
+ for value, count in (revised_counts - original_counts).items():
1123
+ for token in revised_buckets[value][:count]:
1124
+ findings.append(
1125
+ compare_finding(
1126
+ f"compare.{kind}.introduced",
1127
+ f"Introduced protected {labels[kind]}",
1128
+ token,
1129
+ revised_starts,
1130
+ "revised",
1131
+ kind in source_kinds,
1132
+ )
1133
+ )
1134
+ return findings
1135
+
1136
+
1137
+ def markdown_link_targets(text: str) -> dict[str, list[ProtectedToken]]:
1138
+ """Link targets per label, in document order (a label can repeat)."""
1139
+ masked = blank_spans(text, regex_spans(text, CODE_BLOCK_RE))
1140
+ targets: dict[str, list[ProtectedToken]] = {}
1141
+ for match in MARKDOWN_LINK_RE.finditer(masked):
1142
+ label = normalize_space(match.group(1)).lower()
1143
+ target = normalize_url(match.group(2).split()[0])
1144
+ targets.setdefault(label, []).append(
1145
+ ProtectedToken("citation", target, evidence(match.group(0)), match.start())
1146
+ )
1147
+ return targets
1148
+
1149
+
1150
+ def compare_markdown_targets(
1151
+ original_text: str,
1152
+ revised_text: str,
1153
+ original_starts: list[int],
1154
+ ) -> list[dict[str, object]]:
1155
+ original_links = markdown_link_targets(original_text)
1156
+ revised_links = markdown_link_targets(revised_text)
1157
+ findings = []
1158
+ for label, original_tokens in original_links.items():
1159
+ # Pair repeated labels by occurrence; a dict keyed on label alone kept
1160
+ # only the last link and missed a retargeted earlier one.
1161
+ pairs = zip(original_tokens, revised_links.get(label, []))
1162
+ for original_token, revised_token in pairs:
1163
+ if revised_token.value == original_token.value:
1164
+ continue
1165
+ findings.append(
1166
+ compare_finding(
1167
+ "compare.citation.changed_target",
1168
+ f"Changed Markdown link target for [{label}]",
1169
+ original_token,
1170
+ original_starts,
1171
+ "original",
1172
+ True,
1173
+ )
1174
+ )
1175
+ return findings
1176
+
1177
+
1178
+ def compare_ordered_tokens(
1179
+ kind: str,
1180
+ original_tokens: list[ProtectedToken],
1181
+ revised_tokens: list[ProtectedToken],
1182
+ original_starts: list[int],
1183
+ revised_starts: list[int],
1184
+ ) -> list[dict[str, object]]:
1185
+ changed_messages = {
1186
+ "code_block": "Changed fenced code block",
1187
+ "quote": "Changed quoted text or blockquote",
1188
+ }
1189
+ label = kind.replace("_", " ")
1190
+
1191
+ def finding(suffix: str, message: str, token: ProtectedToken, side: str) -> dict[str, object]:
1192
+ starts = original_starts if side == "original" else revised_starts
1193
+ return compare_finding(f"compare.{kind}.{suffix}", message, token, starts, side, False)
1194
+
1195
+ # Align the two sequences before comparing, so dropping the first quote
1196
+ # reports one dropped quote instead of shifting every later quote into a
1197
+ # false "changed" pair.
1198
+ matcher = difflib.SequenceMatcher(
1199
+ a=[token.value for token in original_tokens],
1200
+ b=[token.value for token in revised_tokens],
1201
+ autojunk=False,
1202
+ )
1203
+ findings = []
1204
+ for tag, a_start, a_end, b_start, b_end in matcher.get_opcodes():
1205
+ if tag == "equal":
1206
+ continue
1207
+ originals = original_tokens[a_start:a_end]
1208
+ revised = revised_tokens[b_start:b_end]
1209
+ paired = min(len(originals), len(revised)) if tag == "replace" else 0
1210
+ for original_token in originals[:paired]:
1211
+ findings.append(finding("changed", changed_messages[kind], original_token, "original"))
1212
+ for token in originals[paired:]:
1213
+ findings.append(finding("dropped", f"Dropped protected {label}", token, "original"))
1214
+ for token in revised[paired:]:
1215
+ findings.append(finding("introduced", f"Introduced protected {label}", token, "revised"))
1216
+ return findings
1217
+
1218
+
1219
+ def sentence_records(text: str) -> list[dict[str, object]]:
1220
+ records = []
1221
+ pattern = re.compile(r".+?(?:(?<=[.!?])\s+(?=[A-Z0-9\"“'])|\n{2,}|$)", re.S)
1222
+ for match in pattern.finditer(text):
1223
+ sentence = match.group(0).strip()
1224
+ if not sentence or not SOURCE_MARKER_RE.search(sentence):
1225
+ continue
1226
+ records.append(
1227
+ {
1228
+ "text": sentence,
1229
+ "normalized": normalize_sentence(sentence),
1230
+ "terms": meaningful_terms(sentence),
1231
+ "markers": evidence_markers(sentence),
1232
+ "offset": match.start() + len(match.group(0)) - len(match.group(0).lstrip()),
1233
+ }
1234
+ )
1235
+ return records
1236
+
1237
+
1238
+ def normalize_sentence(sentence: str) -> str:
1239
+ normalized = MARKDOWN_LINK_RE.sub(lambda match: match.group(1), sentence)
1240
+ normalized = URL_RE.sub(" ", normalized)
1241
+ normalized = REF_TAG_RE.sub(" ", normalized)
1242
+ normalized = CITE_TEMPLATE_RE.sub(" ", normalized)
1243
+ normalized = FOOTNOTE_REF_RE.sub(" ", normalized)
1244
+ return re.sub(r"[^a-z0-9]+", " ", normalized.lower()).strip()
1245
+
1246
+
1247
+ def meaningful_terms(sentence: str) -> set[str]:
1248
+ return {
1249
+ term
1250
+ for term in re.findall(r"[a-z0-9]+", normalize_sentence(sentence))
1251
+ if len(term) > 2 and term not in STOPWORDS
1252
+ }
1253
+
1254
+
1255
+ def evidence_markers(sentence: str) -> set[str]:
1256
+ markers = set()
1257
+ if URL_RE.search(sentence):
1258
+ markers.add("url")
1259
+ if MARKDOWN_LINK_RE.search(sentence):
1260
+ markers.add("markdown_link")
1261
+ if REF_TAG_RE.search(sentence):
1262
+ markers.add("ref")
1263
+ if CITE_TEMPLATE_RE.search(sentence):
1264
+ markers.add("cite_template")
1265
+ if FOOTNOTE_REF_RE.search(sentence):
1266
+ markers.add("footnote")
1267
+ if REPORTING_LANGUAGE_RE.search(sentence):
1268
+ markers.add("reporting_language")
1269
+ return markers
1270
+
1271
+
1272
+ def overlap_score(left_terms: set[str], right_terms: set[str]) -> float:
1273
+ if not left_terms or not right_terms:
1274
+ return 0.0
1275
+ return len(left_terms & right_terms) / len(left_terms | right_terms)
1276
+
1277
+
1278
+ def best_sentence_match(record: dict[str, object], candidates: list[dict[str, object]]) -> tuple[dict[str, object] | None, float]:
1279
+ best_record = None
1280
+ best_score = 0.0
1281
+ for candidate in candidates:
1282
+ score = overlap_score(set(record["terms"]), set(candidate["terms"]))
1283
+ if score > best_score:
1284
+ best_score = score
1285
+ best_record = candidate
1286
+ return best_record, best_score
1287
+
1288
+
1289
+ def source_statement_token(record: dict[str, object]) -> ProtectedToken:
1290
+ return ProtectedToken("source_statement", str(record["normalized"]), evidence(str(record["text"])), int(record["offset"]))
1291
+
1292
+
1293
+ def compare_source_statements(
1294
+ original_text: str,
1295
+ revised_text: str,
1296
+ original_starts: list[int],
1297
+ revised_starts: list[int],
1298
+ ) -> list[dict[str, object]]:
1299
+ original_records = sentence_records(original_text)
1300
+ revised_records = sentence_records(revised_text)
1301
+ revised_norms = {record["normalized"] for record in revised_records}
1302
+ original_norms = {record["normalized"] for record in original_records}
1303
+ findings = []
1304
+ for record in original_records:
1305
+ if record["normalized"] in revised_norms:
1306
+ continue
1307
+ candidate, score = best_sentence_match(record, revised_records)
1308
+ token = source_statement_token(record)
1309
+ if candidate and score >= 0.4:
1310
+ dropped_markers = set(record["markers"]) - set(candidate["markers"])
1311
+ if dropped_markers:
1312
+ findings.append(
1313
+ compare_finding(
1314
+ "compare.source_statement.evidence_marker_dropped",
1315
+ "Source-dependent sentence changed without preserving its evidence marker",
1316
+ token,
1317
+ original_starts,
1318
+ "original",
1319
+ True,
1320
+ )
1321
+ )
1322
+ else:
1323
+ findings.append(
1324
+ compare_finding(
1325
+ "compare.source_statement.dropped",
1326
+ "Dropped source-dependent statement",
1327
+ token,
1328
+ original_starts,
1329
+ "original",
1330
+ True,
1331
+ )
1332
+ )
1333
+ for record in revised_records:
1334
+ if record["normalized"] in original_norms:
1335
+ continue
1336
+ candidate, score = best_sentence_match(record, original_records)
1337
+ if not candidate or score < 0.4:
1338
+ findings.append(
1339
+ compare_finding(
1340
+ "compare.source_statement.introduced",
1341
+ "Introduced source-dependent statement",
1342
+ source_statement_token(record),
1343
+ revised_starts,
1344
+ "revised",
1345
+ True,
1346
+ )
1347
+ )
1348
+ return findings
1349
+
1350
+
1351
+ def name_extractor_for(original_text: str, revised_text: str):
1352
+ """Name extractor bound to the joint lowercase vocabulary of both texts."""
1353
+ ordinary = name_vocabulary(original_text, revised_text)
1354
+ return lambda text: extract_name_tokens(text, ordinary)
1355
+
1356
+
1357
+ def compare_texts(original_text: str, revised_text: str, original_path: str, revised_path: str) -> dict[str, object]:
1358
+ original_starts = line_starts(original_text)
1359
+ revised_starts = line_starts(revised_text)
1360
+ findings: list[dict[str, object]] = []
1361
+ findings.extend(compare_markdown_targets(original_text, revised_text, original_starts))
1362
+ for kind, extractor in (
1363
+ ("number", extract_number_tokens),
1364
+ ("date", extract_date_tokens),
1365
+ # Both sides share one lowercase vocabulary: a word the ORIGINAL
1366
+ # shows in lowercase still proves it ordinary when the revision
1367
+ # promotes it to a sentence head.
1368
+ ("name", name_extractor_for(original_text, revised_text)),
1369
+ ("url", extract_url_tokens),
1370
+ ("citation", extract_citation_tokens),
1371
+ ):
1372
+ findings.extend(compare_token_sets(kind, extractor(original_text), extractor(revised_text), original_starts, revised_starts))
1373
+ findings.extend(
1374
+ compare_ordered_tokens(
1375
+ "quote",
1376
+ extract_quote_tokens(original_text),
1377
+ extract_quote_tokens(revised_text),
1378
+ original_starts,
1379
+ revised_starts,
1380
+ )
1381
+ )
1382
+ findings.extend(
1383
+ compare_ordered_tokens(
1384
+ "code_block",
1385
+ extract_code_block_tokens(original_text),
1386
+ extract_code_block_tokens(revised_text),
1387
+ original_starts,
1388
+ revised_starts,
1389
+ )
1390
+ )
1391
+ findings.extend(compare_source_statements(original_text, revised_text, original_starts, revised_starts))
1392
+ findings.sort(key=lambda item: (str(item["side"]), int(item["line"]), int(item["column"]), str(item["id"])))
1393
+ return {
1394
+ "original": original_path,
1395
+ "revised": revised_path,
1396
+ "findings": findings,
1397
+ }
1398
+
1399
+
1400
+ def make_finding(rule: Rule, match: re.Match[str], starts: list[int]) -> dict[str, object]:
1401
+ line, column = line_column(starts, match.start())
1402
+ return {
1403
+ "id": rule.id,
1404
+ "family": rule.family,
1405
+ "severity": rule.severity,
1406
+ "line": line,
1407
+ "column": column,
1408
+ "evidence": evidence(match.group(0)),
1409
+ "message": rule.message,
1410
+ "source_risk": rule.source_risk,
1411
+ }
1412
+
1413
+
1414
+ # Letters and digits in any script, joined by inner hyphens and straight or
1415
+ # curly apostrophes. An ASCII-only class split "café" into "caf" and "don’t"
1416
+ # into "don" + "t", which skewed word counts, sentence lengths, and MATTR on
1417
+ # accented or typographically quoted text.
1418
+ WORD_RE = re.compile(r"[^\W_]+(?:[-'’][^\W_]+)*")
1419
+
1420
+
1421
+ def words(text: str) -> list[str]:
1422
+ return WORD_RE.findall(text)
1423
+
1424
+
1425
+ def paragraphs(text: str) -> list[str]:
1426
+ return [block.strip() for block in re.split(r"\n\s*\n", text) if block.strip()]
1427
+
1428
+
1429
+ def sentences(text: str) -> list[str]:
1430
+ body = "\n".join(line for line in text.splitlines() if not line.lstrip().startswith("#"))
1431
+ parts = re.split(r"(?<=[.!?])\s+|\n{2,}", body)
1432
+ return [part.strip() for part in parts if words(part)]
1433
+
1434
+
1435
+ def coefficient_of_variation(lengths: list[int]) -> float:
1436
+ if not lengths:
1437
+ return 0.0
1438
+ mean = sum(lengths) / len(lengths)
1439
+ if mean == 0:
1440
+ return 0.0
1441
+ variance = sum((length - mean) ** 2 for length in lengths) / len(lengths)
1442
+ return math.sqrt(variance) / mean
1443
+
1444
+
1445
+ def moving_avg_type_token_ratio(token_list: list[str], window: int = 50) -> float | None:
1446
+ """Moving-average type-token ratio (MATTR) over ``window``-token windows.
1447
+
1448
+ A length-robust lexical-diversity measure: unlike the plain type-token
1449
+ ratio it does not decay as a text grows longer, because it averages the TTR
1450
+ of a sliding fixed-size window rather than dividing uniques by total. Tokens
1451
+ are lowercased so casing does not inflate the type count. Returned rounded
1452
+ to 2 dp.
1453
+
1454
+ Returns None when the text is shorter than one window. A short text's
1455
+ "MATTR" would just be its plain TTR, which is length-dependent and not
1456
+ comparable with a true windowed value from a longer document.
1457
+
1458
+ Reported as a diagnostic stat only, never as a finding or a score input.
1459
+ See reference/mattr-calibration.md for why.
1460
+ """
1461
+ if window < 1:
1462
+ raise ValueError(f"window must be a positive integer, got {window}")
1463
+ if len(token_list) < window:
1464
+ return None
1465
+ lowered = [token.lower() for token in token_list]
1466
+ # Slide one count table across the text: O(n) instead of a fresh set per
1467
+ # window.
1468
+ counts = Counter(lowered[:window])
1469
+ total_types = len(counts)
1470
+ for index in range(window, len(lowered)):
1471
+ incoming = lowered[index]
1472
+ outgoing = lowered[index - window]
1473
+ counts[outgoing] -= 1
1474
+ if counts[outgoing] == 0:
1475
+ del counts[outgoing]
1476
+ counts[incoming] += 1
1477
+ total_types += len(counts)
1478
+ windows = len(lowered) - window + 1
1479
+ return round(total_types / (windows * window), 2)
1480
+
1481
+
1482
+ def title_case_heading_count(text: str) -> int:
1483
+ count = 0
1484
+ for line in text.splitlines():
1485
+ match = re.match(r"^\s{0,3}#{1,6}\s+(.+)$", line)
1486
+ if not match:
1487
+ continue
1488
+ heading_words = [word for word in re.findall(r"[A-Za-z][A-Za-z'-]*", match.group(1)) if len(word) > 2]
1489
+ if heading_words and sum(word[:1].isupper() for word in heading_words) / len(heading_words) >= 0.6:
1490
+ count += 1
1491
+ return count
1492
+
1493
+
1494
+ def max_uniform_run(sentence_lengths: list[int]) -> int:
1495
+ """Longest run of consecutive sentences whose lengths stay within 5 words."""
1496
+ best = run_start = 0
1497
+ for index in range(len(sentence_lengths)):
1498
+ window = sentence_lengths[run_start : index + 1]
1499
+ while max(window) - min(window) > 5:
1500
+ run_start += 1
1501
+ window = sentence_lengths[run_start : index + 1]
1502
+ best = max(best, index - run_start + 1)
1503
+ return best
1504
+
1505
+
1506
+ def stats_for(text: str) -> dict[str, int | float]:
1507
+ token_list = words(text)
1508
+ sentence_list = sentences(text)
1509
+ sentence_lengths = [len(words(sentence)) for sentence in sentence_list]
1510
+ word_count = len(token_list)
1511
+ unique_words = {word.lower() for word in token_list}
1512
+ return {
1513
+ "words": word_count,
1514
+ "sentences": len(sentence_list),
1515
+ "avg_sentence_words": round(sum(sentence_lengths) / len(sentence_lengths), 2) if sentence_lengths else 0,
1516
+ "sentence_length_cv": round(coefficient_of_variation(sentence_lengths), 2),
1517
+ "sentence_length_range": (max(sentence_lengths) - min(sentence_lengths)) if sentence_lengths else 0,
1518
+ "midband_sentence_share": round(
1519
+ sum(1 for length in sentence_lengths if 10 <= length <= 20) / len(sentence_lengths), 2
1520
+ ) if sentence_lengths else 0,
1521
+ "max_uniform_run": max_uniform_run(sentence_lengths),
1522
+ "paragraphs": len(paragraphs(text)),
1523
+ "type_token_ratio": round(len(unique_words) / word_count, 2) if word_count else 0,
1524
+ "mattr_50": moving_avg_type_token_ratio(token_list, 50),
1525
+ "em_dash_count": text.count("—"),
1526
+ "heading_count": len(re.findall(r"^\s{0,3}#{1,6}\s+", text, re.M)),
1527
+ "title_case_heading_count": title_case_heading_count(text),
1528
+ "bold_marker_count": text.count("**"),
1529
+ "table_line_count": len(re.findall(r"^\s*\|.+\|\s*$", text, re.M)),
1530
+ "bullet_count": len(re.findall(r"^\s*[-*+]\s+", text, re.M)),
1531
+ "code_block_count": len(CODE_BLOCK_RE.findall(text)),
1532
+ }
1533
+
1534
+
1535
+ def regex_findings(text: str, rules: Iterable[Rule], starts: list[int]) -> list[dict[str, object]]:
1536
+ found = []
1537
+ for rule in rules:
1538
+ for match in rule.regex.finditer(text):
1539
+ found.append(make_finding(rule, match, starts))
1540
+ if rule.once_per_doc:
1541
+ break
1542
+ return found
1543
+
1544
+
1545
+ def ai_vocab_findings(text: str, starts: list[int]) -> list[dict[str, object]]:
1546
+ found_terms = [(match.group(0).lower(), match.start()) for match in re.finditer(r"[A-Za-z][A-Za-z'-]*", text)]
1547
+ matched = [(term, offset) for term, offset in found_terms if term in AI_VOCAB]
1548
+ # A cluster means distinct words. One topical word repeated ("robust
1549
+ # estimators ... robust ... robust") is not a vocabulary cluster.
1550
+ distinct_terms = {term for term, _offset in matched}
1551
+ tier1_terms = distinct_terms & AI_VOCAB_TIER1
1552
+ if len(distinct_terms) < 3 and len(tier1_terms) < 2:
1553
+ paragraph_hits = []
1554
+ cursor = 0
1555
+ for paragraph in paragraphs(text):
1556
+ start = text.find(paragraph, cursor)
1557
+ cursor = start + len(paragraph)
1558
+ terms = {term for term in re.findall(r"[A-Za-z][A-Za-z'-]*", paragraph.lower()) if term in AI_VOCAB}
1559
+ if len(terms) >= 2:
1560
+ paragraph_hits.append((sorted(terms), start))
1561
+ if not paragraph_hits:
1562
+ return []
1563
+ terms, offset = paragraph_hits[0]
1564
+ else:
1565
+ terms = sorted(distinct_terms)
1566
+ offset = matched[0][1]
1567
+
1568
+ line, column = line_column(starts, offset)
1569
+ return [
1570
+ {
1571
+ "id": "family4.ai_vocab_cluster",
1572
+ "family": 4,
1573
+ "severity": "warning",
1574
+ "line": line,
1575
+ "column": column,
1576
+ "evidence": ", ".join(terms[:12]),
1577
+ "message": "AI-vocabulary cluster",
1578
+ "source_risk": False,
1579
+ }
1580
+ ]
1581
+
1582
+
1583
+ def rhythm_findings(stats: dict[str, int | float]) -> list[dict[str, object]]:
1584
+ findings = []
1585
+ if stats["sentences"] >= 4 and stats["sentence_length_cv"] <= 0.2:
1586
+ findings.append(
1587
+ {
1588
+ "id": "structure.low_sentence_variance",
1589
+ "family": 8,
1590
+ "severity": "info",
1591
+ "line": 1,
1592
+ "column": 1,
1593
+ "evidence": f"sentence_length_cv={stats['sentence_length_cv']}",
1594
+ "message": "Low sentence-length variance can read mechanically, even in human prose",
1595
+ "source_risk": False,
1596
+ }
1597
+ )
1598
+ if stats["paragraphs"] >= 4 and stats["sentences"] and stats["avg_sentence_words"] >= 18:
1599
+ findings.append(
1600
+ {
1601
+ "id": "structure.long_uniform_paragraphs",
1602
+ "family": 8,
1603
+ "severity": "info",
1604
+ "line": 1,
1605
+ "column": 1,
1606
+ "evidence": f"paragraphs={stats['paragraphs']}, avg_sentence_words={stats['avg_sentence_words']}",
1607
+ "message": "Long regular paragraphs may need rhythm review",
1608
+ "source_risk": False,
1609
+ }
1610
+ )
1611
+ # Threshold calibrated against the human-control corpus (corpus/RESULTS.md):
1612
+ # >=4 fires on 40% of human documents, >=6 on 11% — comparable to the other
1613
+ # structure signals. Info severity either way; this is a rhythm nudge.
1614
+ if stats["max_uniform_run"] >= 6:
1615
+ findings.append(
1616
+ {
1617
+ "id": "structure.uniform_length_run",
1618
+ "family": 8,
1619
+ "severity": "info",
1620
+ "line": 1,
1621
+ "column": 1,
1622
+ "evidence": f"max_uniform_run={stats['max_uniform_run']}",
1623
+ "message": "Run of consecutive sentences within 5 words of each other",
1624
+ "source_risk": False,
1625
+ }
1626
+ )
1627
+ if (
1628
+ stats["sentences"] >= 8
1629
+ and stats["midband_sentence_share"] >= 0.6
1630
+ and stats["sentence_length_range"] < 20
1631
+ ):
1632
+ findings.append(
1633
+ {
1634
+ "id": "structure.midband_dominance",
1635
+ "family": 8,
1636
+ "severity": "info",
1637
+ "line": 1,
1638
+ "column": 1,
1639
+ "evidence": (
1640
+ f"midband_sentence_share={stats['midband_sentence_share']}, "
1641
+ f"sentence_length_range={stats['sentence_length_range']}"
1642
+ ),
1643
+ "message": "Most sentences sit in the 10-20 word band with a narrow range",
1644
+ "source_risk": False,
1645
+ }
1646
+ )
1647
+ return findings
1648
+
1649
+
1650
+ ANAPHORA_SKIP_WORDS = {
1651
+ "the", "a", "an", "it", "i", "in", "this", "that", "he", "she", "they", "we", "you",
1652
+ }
1653
+
1654
+
1655
+ def anaphora_findings(text: str) -> list[dict[str, object]]:
1656
+ """Three or more consecutive sentences opening with the same word (§7.13)."""
1657
+ openers = []
1658
+ for sentence in sentences(text):
1659
+ tokens = words(sentence)
1660
+ openers.append(tokens[0].lower() if tokens else "")
1661
+ findings = []
1662
+ index = 0
1663
+ while index < len(openers):
1664
+ run = 1
1665
+ while index + run < len(openers) and openers[index + run] == openers[index]:
1666
+ run += 1
1667
+ opener = openers[index]
1668
+ if run >= 3 and opener and opener not in ANAPHORA_SKIP_WORDS:
1669
+ findings.append(
1670
+ {
1671
+ "id": "structure.anaphora",
1672
+ "family": 8,
1673
+ "severity": "info",
1674
+ "line": 1,
1675
+ "column": 1,
1676
+ "evidence": f"opener='{opener}' x{run} consecutive sentences",
1677
+ "message": "Consecutive sentences share the same opening word",
1678
+ "source_risk": False,
1679
+ }
1680
+ )
1681
+ index += run
1682
+ return findings
1683
+
1684
+
1685
+ def risk_score(findings: list[dict[str, object]]) -> int:
1686
+ artifact_count = sum(1 for finding in findings if str(finding["id"]).startswith("artifact."))
1687
+ source_count = sum(1 for finding in findings if finding["source_risk"])
1688
+ family_count = sum(1 for finding in findings if str(finding["id"]).startswith("family"))
1689
+ family_coverage = {finding["family"] for finding in findings if str(finding["id"]).startswith("family")}
1690
+ formula_count = sum(1 for finding in findings if finding["id"] == "family7.rhetorical_formula")
1691
+ structure_count = sum(1 for finding in findings if str(finding["id"]).startswith("structure."))
1692
+ score = 0
1693
+ score += min(40, artifact_count * 10)
1694
+ score += min(30, family_count * 5)
1695
+ score += min(20, source_count * 4)
1696
+ score += min(10, structure_count * 5)
1697
+ if len(family_coverage) >= 5 or family_count >= 10 or formula_count >= 3 or (source_count and family_count >= 6):
1698
+ score = max(score, 60)
1699
+ return min(100, score)
1700
+
1701
+
1702
+ def bypass_findings(counts: dict[str, int], first_offset: int, starts: list[int]) -> list[dict[str, object]]:
1703
+ total = counts["invisible"] + counts["homoglyph"]
1704
+ if total == 0:
1705
+ return []
1706
+ line, column = line_column(starts, max(first_offset, 0))
1707
+ return [
1708
+ {
1709
+ "id": "artifact.bypass_characters",
1710
+ "family": 9,
1711
+ "severity": "error",
1712
+ "line": line,
1713
+ "column": column,
1714
+ "evidence": f"invisible={counts['invisible']}, homoglyph={counts['homoglyph']}",
1715
+ "message": "Invisible or lookalike characters; strip them",
1716
+ "source_risk": False,
1717
+ }
1718
+ ]
1719
+
1720
+
1721
+ # Inline code: a run of N backticks closed by a run of exactly N, on one line.
1722
+ INLINE_CODE_RE = re.compile(r"(?<!`)(`+)(?!`)[^\n]+?(?<!`)\1(?!`)")
1723
+
1724
+
1725
+ def mask_code(text: str) -> str:
1726
+ """Blank fenced and inline code, keeping every offset and line break.
1727
+
1728
+ Code is not prose: `utilize()` in a README is an API name, not wordiness.
1729
+ Only the prose rules and stats see the masked text. Artifact rules always
1730
+ see everything: a chatbot reply pasted with its ```markdown wrapper still
1731
+ carries its leaked citation tokens.
1732
+ """
1733
+ fenced = blank_spans(text, regex_spans(text, CODE_BLOCK_RE))
1734
+ return blank_spans(fenced, regex_spans(fenced, INLINE_CODE_RE))
1735
+
1736
+
1737
+ def audit_text(text: str, path: str, include_code: bool = False) -> dict[str, object]:
1738
+ original_starts = line_starts(text)
1739
+ text, bypass_counts, bypass_offset = normalize_bypass_text(text)
1740
+ # Bypass characters are judged on the whole text above; code is masked
1741
+ # only for the prose rules and stats below.
1742
+ code_block_count = len(CODE_BLOCK_RE.findall(text))
1743
+ full_text = text
1744
+ if not include_code:
1745
+ text = mask_code(text)
1746
+ # Masking blanks characters in place, so line starts are the same for both.
1747
+ starts = line_starts(text)
1748
+ stats = stats_for(text)
1749
+ stats["code_block_count"] = code_block_count
1750
+ findings = []
1751
+ findings.extend(bypass_findings(bypass_counts, bypass_offset, original_starts))
1752
+ findings.extend(regex_findings(full_text, ARTIFACT_RULES, starts))
1753
+ findings.extend(regex_findings(text, FAMILY_RULES, starts))
1754
+ findings.extend(ai_vocab_findings(text, starts))
1755
+ findings.extend(regex_findings(text, SOURCE_RISK_RULES, starts))
1756
+ findings.extend(regex_findings(text, CLARITY_RULES, starts))
1757
+ findings.extend(rhythm_findings(stats))
1758
+ findings.extend(anaphora_findings(text))
1759
+ findings.sort(key=lambda item: (int(item["line"]), int(item["column"]), str(item["id"])))
1760
+ return {
1761
+ "path": path,
1762
+ "risk_score": risk_score(findings),
1763
+ "stats": stats,
1764
+ "findings": findings,
1765
+ }
1766
+
1767
+
1768
+ def severity_for(findings: list[dict[str, object]]) -> str:
1769
+ if not findings:
1770
+ return "info"
1771
+ return max((str(finding["severity"]) for finding in findings), key=lambda value: SEVERITY_ORDER[value])
1772
+
1773
+
1774
+ def summarize(documents: list[dict[str, object]], fail_score: int) -> dict[str, object]:
1775
+ all_findings = [finding for document in documents for finding in document["findings"]]
1776
+ max_risk = max((int(document["risk_score"]) for document in documents), default=0)
1777
+ max_severity = severity_for(all_findings)
1778
+ has_error = any(finding["severity"] == "error" for finding in all_findings)
1779
+ exit_code = 2 if has_error else 1 if max_risk >= fail_score else 0
1780
+ return {
1781
+ "documents": len(documents),
1782
+ "max_risk_score": max_risk,
1783
+ "max_severity": max_severity,
1784
+ "artifact_count": sum(1 for finding in all_findings if str(finding["id"]).startswith("artifact.")),
1785
+ "source_risk_count": sum(1 for finding in all_findings if finding["source_risk"]),
1786
+ "family_hit_count": sum(1 for finding in all_findings if str(finding["id"]).startswith("family")),
1787
+ "clarity_hit_count": sum(1 for finding in all_findings if str(finding["id"]).startswith("clarity.")),
1788
+ "exit_code": exit_code,
1789
+ }
1790
+
1791
+
1792
+ def summarize_compare(compare: dict[str, object]) -> dict[str, object]:
1793
+ findings = list(compare["findings"])
1794
+ exit_code = 2 if findings else 0
1795
+ return {
1796
+ "documents": 2,
1797
+ "comparisons": 1,
1798
+ "max_risk_score": 0,
1799
+ "max_severity": severity_for(findings),
1800
+ "artifact_count": 0,
1801
+ "source_risk_count": sum(1 for finding in findings if finding["source_risk"]),
1802
+ "family_hit_count": 0,
1803
+ "compare_finding_count": len(findings),
1804
+ "exit_code": exit_code,
1805
+ }
1806
+
1807
+
1808
+ def iter_input_files(target: Path) -> list[Path]:
1809
+ if target.is_file():
1810
+ return [target]
1811
+ if target.is_dir():
1812
+ return sorted(path for path in target.rglob("*") if path.is_file() and path.suffix.lower() in {".md", ".txt"})
1813
+ raise FileNotFoundError(f"No such file or directory: {target}")
1814
+
1815
+
1816
+ def audit_paths(paths: list[Path], include_code: bool = False) -> list[dict[str, object]]:
1817
+ documents = []
1818
+ for path in paths:
1819
+ text = path.read_text(encoding="utf-8")
1820
+ # Forward slashes on every OS, so JSON and text output (and tests)
1821
+ # read the same on Windows as on Linux. SARIF already does this.
1822
+ documents.append(audit_text(text, path.as_posix(), include_code))
1823
+ return documents
1824
+
1825
+
1826
+ def compare_paths(original_path: Path, revised_path: Path) -> dict[str, object]:
1827
+ original_text = original_path.read_text(encoding="utf-8")
1828
+ revised_text = revised_path.read_text(encoding="utf-8")
1829
+ return compare_texts(original_text, revised_text, original_path.as_posix(), revised_path.as_posix())
1830
+
1831
+
1832
+ def render_text_report(result: dict[str, object]) -> str:
1833
+ lines = []
1834
+ summary = result["summary"]
1835
+ lines.append(
1836
+ f"Humanizer audit: {summary['documents']} document(s), max risk {summary['max_risk_score']}, "
1837
+ f"severity {summary['max_severity']}, exit {summary['exit_code']}"
1838
+ )
1839
+ for document in result["documents"]:
1840
+ lines.append("")
1841
+ lines.append(f"{document['path']} — risk {document['risk_score']}")
1842
+ for finding in document["findings"]:
1843
+ lines.append(
1844
+ f" {finding['severity'].upper()} L{finding['line']}:C{finding['column']} "
1845
+ f"{finding['id']} — {finding['message']} [{finding['evidence']}]"
1846
+ )
1847
+ return "\n".join(lines)
1848
+
1849
+
1850
+ def render_compare_report(result: dict[str, object]) -> str:
1851
+ summary = result["summary"]
1852
+ compare = result["compare"]
1853
+ lines = [
1854
+ f"Humanizer compare: {compare['original']} -> {compare['revised']}, "
1855
+ f"{summary['compare_finding_count']} finding(s), severity {summary['max_severity']}, "
1856
+ f"exit {summary['exit_code']}"
1857
+ ]
1858
+ for finding in compare["findings"]:
1859
+ lines.append(
1860
+ f" {finding['severity'].upper()} {finding['side']} L{finding['line']}:C{finding['column']} "
1861
+ f"{finding['id']} — {finding['message']} [{finding['evidence']}]"
1862
+ )
1863
+ return "\n".join(lines)
1864
+
1865
+
1866
+ SARIF_LEVELS = {"error": "error", "warning": "warning", "info": "note"}
1867
+ SARIF_SCHEMA = "https://json.schemastore.org/sarif-2.1.0.json"
1868
+ INFORMATION_URI = "https://github.com/eddyplolz/humanizer-pro"
1869
+
1870
+
1871
+ def sarif_uri(path: str) -> str:
1872
+ """A SARIF artifact URI: relative to the working directory when possible,
1873
+ so GitHub code scanning can map it onto the checked-out repository."""
1874
+ if path == "<stdin>":
1875
+ return "stdin"
1876
+ candidate = Path(path)
1877
+ try:
1878
+ relative = candidate.resolve().relative_to(Path.cwd().resolve())
1879
+ except ValueError:
1880
+ return candidate.resolve().as_uri()
1881
+ return quote(relative.as_posix())
1882
+
1883
+
1884
+ def render_sarif(result: dict[str, object]) -> dict[str, object]:
1885
+ """SARIF 2.1.0 log for an audit or compare result."""
1886
+ located: list[tuple[str, dict[str, object]]] = []
1887
+ if "compare" in result:
1888
+ compare = result["compare"]
1889
+ for finding in compare["findings"]:
1890
+ located.append((str(compare[finding["side"]]), finding))
1891
+ else:
1892
+ for document in result["documents"]:
1893
+ for finding in document["findings"]:
1894
+ located.append((str(document["path"]), finding))
1895
+ rule_index: dict[str, int] = {}
1896
+ rules: list[dict[str, object]] = []
1897
+ for _path, finding in sorted(located, key=lambda item: str(item[1]["id"])):
1898
+ rule_id = str(finding["id"])
1899
+ if rule_id in rule_index:
1900
+ continue
1901
+ rule_index[rule_id] = len(rules)
1902
+ rules.append(
1903
+ {
1904
+ "id": rule_id,
1905
+ "shortDescription": {"text": str(finding["message"])},
1906
+ "defaultConfiguration": {"level": SARIF_LEVELS[str(finding["severity"])]},
1907
+ "helpUri": INFORMATION_URI,
1908
+ }
1909
+ )
1910
+ results = [
1911
+ {
1912
+ "ruleId": str(finding["id"]),
1913
+ "ruleIndex": rule_index[str(finding["id"])],
1914
+ "level": SARIF_LEVELS[str(finding["severity"])],
1915
+ "message": {"text": f"{finding['message']}: {finding['evidence']}"},
1916
+ "locations": [
1917
+ {
1918
+ "physicalLocation": {
1919
+ "artifactLocation": {"uri": sarif_uri(path)},
1920
+ "region": {
1921
+ "startLine": int(finding["line"]),
1922
+ "startColumn": int(finding["column"]),
1923
+ },
1924
+ }
1925
+ }
1926
+ ],
1927
+ }
1928
+ for path, finding in located
1929
+ ]
1930
+ return {
1931
+ "$schema": SARIF_SCHEMA,
1932
+ "version": "2.1.0",
1933
+ "runs": [
1934
+ {
1935
+ "tool": {
1936
+ "driver": {
1937
+ "name": "humanizer-audit",
1938
+ "version": __version__,
1939
+ "informationUri": INFORMATION_URI,
1940
+ "rules": rules,
1941
+ }
1942
+ },
1943
+ # Columns count Python code points, not SARIF's default UTF-16 units.
1944
+ "columnKind": "unicodeCodePoints",
1945
+ "results": results,
1946
+ }
1947
+ ],
1948
+ }
1949
+
1950
+
1951
+ class AuditArgumentParser(argparse.ArgumentParser):
1952
+ """argparse exits 2 on a usage error, which this CLI reserves for "block".
1953
+
1954
+ A CI job branching on exit codes must not read a typo in its own command
1955
+ line as a leaked artifact, so usage errors exit 3 like read errors.
1956
+ """
1957
+
1958
+ def error(self, message: str) -> None: # type: ignore[override]
1959
+ self.print_usage(sys.stderr)
1960
+ self.exit(3, f"{self.prog}: error: {message}\n")
1961
+
1962
+
1963
+ def build_parser() -> argparse.ArgumentParser:
1964
+ parser = AuditArgumentParser(description="Audit text for Humanizer Pro tells and source-risk artifacts.")
1965
+ parser.add_argument("targets", nargs="*", metavar="target", help="Files or directories to audit.")
1966
+ parser.add_argument("--stdin", action="store_true", help="Read text from stdin instead of a path.")
1967
+ parser.add_argument(
1968
+ "--compare",
1969
+ nargs=2,
1970
+ metavar=("ORIGINAL", "REVISED"),
1971
+ help="Compare an original and revised file for protected-content drift.",
1972
+ )
1973
+ parser.add_argument("--json", action="store_true", help="Emit JSON instead of a text report.")
1974
+ parser.add_argument("--fail-score", type=int, default=60, help="Risk score that exits with review status 1.")
1975
+ parser.add_argument(
1976
+ "--include-code",
1977
+ action="store_true",
1978
+ help="Also apply prose rules inside fenced and inline code (skipped by default).",
1979
+ )
1980
+ parser.add_argument("--sarif", metavar="PATH", help="Also write findings as SARIF 2.1.0 to PATH.")
1981
+ parser.add_argument("--version", action="version", version=f"humanizer-audit {__version__}")
1982
+ return parser
1983
+
1984
+
1985
+ def main(argv: list[str] | None = None) -> int:
1986
+ parser = build_parser()
1987
+ args = parser.parse_args(argv)
1988
+ if sum(1 for active in (args.stdin, bool(args.targets), bool(args.compare)) if active) != 1:
1989
+ parser.error("provide exactly one input mode: <file-or-dir>..., --stdin, or --compare ORIGINAL REVISED")
1990
+ try:
1991
+ if args.compare:
1992
+ compare = compare_paths(Path(args.compare[0]), Path(args.compare[1]))
1993
+ summary = summarize_compare(compare)
1994
+ result = {"schema": SCHEMA, "summary": summary, "documents": [], "compare": compare}
1995
+ elif args.stdin:
1996
+ # Decode stdin as strict UTF-8 like file input, rather than with the
1997
+ # platform locale (cp1252 on many Windows setups), and translate
1998
+ # newlines the way text-mode file reads do.
1999
+ raw = sys.stdin.buffer.read().decode("utf-8")
2000
+ text = raw.replace("\r\n", "\n").replace("\r", "\n")
2001
+ documents = [audit_text(text, "<stdin>", args.include_code)]
2002
+ summary = summarize(documents, args.fail_score)
2003
+ result = {"schema": SCHEMA, "summary": summary, "documents": documents}
2004
+ else:
2005
+ paths: list[Path] = []
2006
+ seen: set[Path] = set()
2007
+ for target in args.targets:
2008
+ found = iter_input_files(Path(target))
2009
+ if not found:
2010
+ print(f"humanizer-audit: no .md or .txt files under {target}", file=sys.stderr)
2011
+ for path in found:
2012
+ if path not in seen:
2013
+ seen.add(path)
2014
+ paths.append(path)
2015
+ documents = audit_paths(paths, args.include_code)
2016
+ summary = summarize(documents, args.fail_score)
2017
+ result = {"schema": SCHEMA, "summary": summary, "documents": documents}
2018
+ if args.sarif:
2019
+ Path(args.sarif).write_text(
2020
+ json.dumps(render_sarif(result), indent=2) + "\n", encoding="utf-8"
2021
+ )
2022
+ except (OSError, UnicodeDecodeError) as exc:
2023
+ print(f"humanizer-audit: {exc}", file=sys.stderr)
2024
+ return 3
2025
+
2026
+ # The text report quotes evidence verbatim, and a Windows pipe or redirect
2027
+ # encodes stdout as cp1252, which cannot hold Cyrillic, CJK, or emoji
2028
+ # evidence. Escape what the stream cannot encode instead of crashing.
2029
+ if hasattr(sys.stdout, "reconfigure"):
2030
+ sys.stdout.reconfigure(errors="backslashreplace")
2031
+ if args.json:
2032
+ print(json.dumps(result, indent=2, sort_keys=True))
2033
+ elif args.compare:
2034
+ print(render_compare_report(result))
2035
+ else:
2036
+ print(render_text_report(result))
2037
+ return int(summary["exit_code"])
2038
+
2039
+
2040
+ if __name__ == "__main__":
2041
+ raise SystemExit(main())