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 +2041 -0
- humanizer_pro-4.15.0.dist-info/METADATA +424 -0
- humanizer_pro-4.15.0.dist-info/RECORD +7 -0
- humanizer_pro-4.15.0.dist-info/WHEEL +5 -0
- humanizer_pro-4.15.0.dist-info/entry_points.txt +2 -0
- humanizer_pro-4.15.0.dist-info/licenses/LICENSE +22 -0
- humanizer_pro-4.15.0.dist-info/top_level.txt +1 -0
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())
|