agentforge-framework 0.2.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.
- agentforge_framework/.claude-plugin/plugin.json +4 -0
- agentforge_framework/__init__.py +3 -0
- agentforge_framework/agents/__init__.py +92 -0
- agentforge_framework/agents/architect.py +146 -0
- agentforge_framework/agents/implementer.py +162 -0
- agentforge_framework/agents/orchestrator.py +588 -0
- agentforge_framework/agents/reviewer.py +335 -0
- agentforge_framework/agents/security.py +138 -0
- agentforge_framework/agents/tester.py +125 -0
- agentforge_framework/cli.py +461 -0
- agentforge_framework/context/__init__.py +1 -0
- agentforge_framework/context/extractors/__init__.py +76 -0
- agentforge_framework/context/extractors/base.py +47 -0
- agentforge_framework/context/extractors/python.py +65 -0
- agentforge_framework/context/extractors/sql.py +121 -0
- agentforge_framework/context/extractors/yaml.py +59 -0
- agentforge_framework/context/prompt.py +104 -0
- agentforge_framework/context/resolver.py +185 -0
- agentforge_framework/core/__init__.py +1 -0
- agentforge_framework/core/commands.py +170 -0
- agentforge_framework/core/config.py +90 -0
- agentforge_framework/core/contracts.py +875 -0
- agentforge_framework/core/gates.py +333 -0
- agentforge_framework/core/issues.py +697 -0
- agentforge_framework/core/plan_format.py +272 -0
- agentforge_framework/core/process.py +141 -0
- agentforge_framework/core/project.py +262 -0
- agentforge_framework/core/registry.py +455 -0
- agentforge_framework/core/repo.py +185 -0
- agentforge_framework/core/router.py +1 -0
- agentforge_framework/core/runtime.py +639 -0
- agentforge_framework/core/skills.py +255 -0
- agentforge_framework/core/workflow.py +215 -0
- agentforge_framework/plugins/__init__.py +35 -0
- agentforge_framework/plugins/databricks/__init__.py +86 -0
- agentforge_framework/plugins/pyspark/__init__.py +57 -0
- agentforge_framework/plugins/python/__init__.py +45 -0
- agentforge_framework/plugins/sql/__init__.py +377 -0
- agentforge_framework/providers/__init__.py +48 -0
- agentforge_framework/providers/base.py +248 -0
- agentforge_framework/providers/claude.py +159 -0
- agentforge_framework/providers/codex.py +139 -0
- agentforge_framework/skills/MANIFEST.yaml +157 -0
- agentforge_framework/skills/NOTICE +49 -0
- agentforge_framework/skills/domain-modeling/ADR-FORMAT.md +47 -0
- agentforge_framework/skills/domain-modeling/CONTEXT-FORMAT.md +60 -0
- agentforge_framework/skills/domain-modeling/SKILL.md +74 -0
- agentforge_framework/skills/domain-modeling/agents/openai.yaml +3 -0
- agentforge_framework/skills/grill-with-docs/SKILL.md +76 -0
- agentforge_framework/skills/grilling/SKILL.md +28 -0
- agentforge_framework/skills/grilling/agents/openai.yaml +3 -0
- agentforge_framework/skills/to-spec/SKILL.md +75 -0
- agentforge_framework/skills/to-spec/agents/openai.yaml +5 -0
- agentforge_framework/skills/to-tickets/SKILL.md +105 -0
- agentforge_framework/skills/to-tickets/agents/openai.yaml +5 -0
- agentforge_framework/skills/unslop/SKILL.md +131 -0
- agentforge_framework/skills/unslop/evals/fixtures/silhouette/human_reference.json +66 -0
- agentforge_framework/skills/unslop/scripts/_lang.py +106 -0
- agentforge_framework/skills/unslop/scripts/banned_phrase_scan.py +784 -0
- agentforge_framework/skills/unslop/scripts/calibrate_pairs.py +580 -0
- agentforge_framework/skills/unslop/scripts/calibrate_score.py +273 -0
- agentforge_framework/skills/unslop/scripts/check_packs.py +80 -0
- agentforge_framework/skills/unslop/scripts/check_suggestions.py +225 -0
- agentforge_framework/skills/unslop/scripts/contribute.py +373 -0
- agentforge_framework/skills/unslop/scripts/diff_check.py +139 -0
- agentforge_framework/skills/unslop/scripts/extract_constraints.py +201 -0
- agentforge_framework/skills/unslop/scripts/harvest_classify.py +223 -0
- agentforge_framework/skills/unslop/scripts/harvest_samples.py +534 -0
- agentforge_framework/skills/unslop/scripts/readability_metrics.py +295 -0
- agentforge_framework/skills/unslop/scripts/refresh_status.py +154 -0
- agentforge_framework/skills/unslop/scripts/silhouette_scan.py +390 -0
- agentforge_framework/skills/unslop/scripts/structure_scan.py +322 -0
- agentforge_framework/skills/unslop/scripts/suggest.py +211 -0
- agentforge_framework/skills/unslop/scripts/validate_preservation.py +409 -0
- agentforge_framework/skills/unslop/scripts/voice_card.py +496 -0
- agentforge_framework/skills/unslop/scripts/voice_profile.py +194 -0
- agentforge_framework/skills/unslop/scripts/voice_score.py +271 -0
- agentforge_framework/skills/unslop/scripts/wiki_sync.py +479 -0
- agentforge_framework/skills/write-plainly/SKILL.md +94 -0
- agentforge_framework/workflows/bugfix.yaml +8 -0
- agentforge_framework/workflows/feature.yaml +16 -0
- agentforge_framework/workflows/review.yaml +10 -0
- agentforge_framework-0.2.0.dist-info/METADATA +321 -0
- agentforge_framework-0.2.0.dist-info/RECORD +89 -0
- agentforge_framework-0.2.0.dist-info/WHEEL +5 -0
- agentforge_framework-0.2.0.dist-info/entry_points.txt +3 -0
- agentforge_framework-0.2.0.dist-info/licenses/LICENSE +202 -0
- agentforge_framework-0.2.0.dist-info/licenses/src/agentforge_framework/skills/NOTICE +49 -0
- agentforge_framework-0.2.0.dist-info/top_level.txt +1 -0
|
@@ -0,0 +1,784 @@
|
|
|
1
|
+
#!/usr/bin/env python3
|
|
2
|
+
"""
|
|
3
|
+
Scan text for AI-isms and banned phrases.
|
|
4
|
+
|
|
5
|
+
Checks against taboo phrases list and returns violations with line numbers.
|
|
6
|
+
Provides suggested replacements where available.
|
|
7
|
+
|
|
8
|
+
By default, quoted examples and code snippets are ignored so the scanner
|
|
9
|
+
doesn't flag illustrative bad writing inside docs or tutorials. Pass
|
|
10
|
+
--include-quoted to scan those spans too.
|
|
11
|
+
|
|
12
|
+
Usage:
|
|
13
|
+
python banned_phrase_scan.py < input.txt
|
|
14
|
+
python banned_phrase_scan.py input.txt
|
|
15
|
+
python banned_phrase_scan.py input.txt --include-quoted
|
|
16
|
+
"""
|
|
17
|
+
|
|
18
|
+
from __future__ import annotations
|
|
19
|
+
|
|
20
|
+
import argparse
|
|
21
|
+
import bisect
|
|
22
|
+
import sys
|
|
23
|
+
import re
|
|
24
|
+
import json
|
|
25
|
+
from pathlib import Path
|
|
26
|
+
from typing import TypedDict
|
|
27
|
+
|
|
28
|
+
HERE = Path(__file__).resolve().parent
|
|
29
|
+
sys.path.insert(0, str(HERE))
|
|
30
|
+
|
|
31
|
+
from _lang import ( # noqa: E402
|
|
32
|
+
ENGLISH_FUNCTION_WORDS,
|
|
33
|
+
english_function_share,
|
|
34
|
+
is_probably_english,
|
|
35
|
+
)
|
|
36
|
+
|
|
37
|
+
|
|
38
|
+
class Violation(TypedDict):
|
|
39
|
+
phrase: str
|
|
40
|
+
category: str
|
|
41
|
+
severity: str
|
|
42
|
+
line_number: int
|
|
43
|
+
column: int
|
|
44
|
+
context: str
|
|
45
|
+
suggestion: str | None
|
|
46
|
+
|
|
47
|
+
|
|
48
|
+
def _mask_non_newlines(text: str) -> str:
|
|
49
|
+
"""Replace visible characters with spaces while preserving line/column layout."""
|
|
50
|
+
return re.sub(r"[^\n]", " ", text)
|
|
51
|
+
|
|
52
|
+
|
|
53
|
+
def mask_ignored_spans(text: str, include_quoted: bool = False) -> str:
|
|
54
|
+
"""Mask examples and code so they don't produce false-positive matches."""
|
|
55
|
+
masked = re.sub(r"```[\s\S]*?```", lambda m: _mask_non_newlines(m.group(0)), text)
|
|
56
|
+
masked = re.sub(r"`[^`\n]+`", lambda m: _mask_non_newlines(m.group(0)), masked)
|
|
57
|
+
|
|
58
|
+
if include_quoted:
|
|
59
|
+
return masked
|
|
60
|
+
|
|
61
|
+
# Markdown blockquotes are almost always cited examples rather than prose to edit.
|
|
62
|
+
masked = re.sub(r"(?m)^>.*$", lambda m: _mask_non_newlines(m.group(0)), masked)
|
|
63
|
+
|
|
64
|
+
# Double quotes only, any length, across line breaks. Single quotes are NOT
|
|
65
|
+
# masked: they collide with apostrophes/emphasis and would silently hide real
|
|
66
|
+
# slop inside ordinary single-quoted prose.
|
|
67
|
+
quote_patterns = [
|
|
68
|
+
r'"[^"]*"',
|
|
69
|
+
r"“[^”]*”",
|
|
70
|
+
]
|
|
71
|
+
for pattern in quote_patterns:
|
|
72
|
+
masked = re.sub(pattern, lambda m: _mask_non_newlines(m.group(0)), masked)
|
|
73
|
+
|
|
74
|
+
return masked
|
|
75
|
+
|
|
76
|
+
|
|
77
|
+
def _phrase_pattern(phrase: str) -> re.Pattern[str]:
|
|
78
|
+
left = r"(?<![a-z0-9_-])" if phrase[0].isalnum() else ""
|
|
79
|
+
right = r"(?![a-z0-9_-])" if phrase[-1].isalnum() else ""
|
|
80
|
+
return re.compile(left + re.escape(phrase) + right)
|
|
81
|
+
|
|
82
|
+
|
|
83
|
+
# Case-insensitive variant used only at the scan_for_violations call site, so
|
|
84
|
+
# _phrase_pattern's case-sensitive default is unchanged for any other caller.
|
|
85
|
+
# BANNED_PHRASES keys are lowercase literals; matching case-insensitively
|
|
86
|
+
# against the original (unlowered) text avoids str.lower()'s non-length-
|
|
87
|
+
# preserving folds (e.g. U+0130 "İ" -> 2 chars), which corrupt offsets.
|
|
88
|
+
_phrase_pattern_ci_cache: dict[str, re.Pattern[str]] = {}
|
|
89
|
+
|
|
90
|
+
|
|
91
|
+
def _phrase_pattern_ci(phrase: str) -> re.Pattern[str]:
|
|
92
|
+
cached = _phrase_pattern_ci_cache.get(phrase)
|
|
93
|
+
if cached is None:
|
|
94
|
+
pattern = _phrase_pattern(phrase).pattern
|
|
95
|
+
# Curly apostrophes are typography, not a different phrase. Keep the
|
|
96
|
+
# original phrase key (and its output spelling), but match both forms
|
|
97
|
+
# without normalizing the input and corrupting offsets.
|
|
98
|
+
if "'" in phrase:
|
|
99
|
+
pattern = pattern.replace("'", "['’]")
|
|
100
|
+
cached = re.compile(pattern, re.IGNORECASE)
|
|
101
|
+
_phrase_pattern_ci_cache[phrase] = cached
|
|
102
|
+
return cached
|
|
103
|
+
|
|
104
|
+
|
|
105
|
+
def _line_starts(text: str) -> list[int]:
|
|
106
|
+
"""0-indexed start offset of each line; line N (1-based) starts at index N-1."""
|
|
107
|
+
starts = [0]
|
|
108
|
+
for m in re.finditer("\n", text):
|
|
109
|
+
starts.append(m.end())
|
|
110
|
+
return starts
|
|
111
|
+
|
|
112
|
+
|
|
113
|
+
def _line_col_context(
|
|
114
|
+
text: str,
|
|
115
|
+
line_starts: list[int],
|
|
116
|
+
pos: int,
|
|
117
|
+
context_cache: dict[int, str] | None = None,
|
|
118
|
+
) -> tuple[int, int, str]:
|
|
119
|
+
"""Derive (1-based line, 1-based column, stripped line context) for an offset
|
|
120
|
+
into ORIGINAL text from a precomputed line_starts index, in O(log n)."""
|
|
121
|
+
idx = bisect.bisect_right(line_starts, pos) - 1
|
|
122
|
+
line_start = line_starts[idx]
|
|
123
|
+
line_end = line_starts[idx + 1] - 1 if idx + 1 < len(line_starts) else len(text)
|
|
124
|
+
line_num = idx + 1
|
|
125
|
+
column = pos - line_start + 1
|
|
126
|
+
context = context_cache.get(idx) if context_cache is not None else None
|
|
127
|
+
if context is None:
|
|
128
|
+
context = text[line_start:line_end].strip()
|
|
129
|
+
context = context[:100] + "..." if len(context) > 100 else context
|
|
130
|
+
if context_cache is not None:
|
|
131
|
+
context_cache[idx] = context
|
|
132
|
+
return line_num, column, context
|
|
133
|
+
|
|
134
|
+
|
|
135
|
+
# Banned phrases with categories, suggestions, and severity.
|
|
136
|
+
# severity: "hard" = always an AI tell; "soft" = context-dependent
|
|
137
|
+
|
|
138
|
+
# Compact high-signal scanner pack. Literal/domain-sensitive cases stay explicit:
|
|
139
|
+
# broad vocabulary and low-value rhetorical variants are intentionally retired.
|
|
140
|
+
BANNED_PHRASES: dict[str, dict[str, str | None]] = {
|
|
141
|
+
# Throat-clearing and conclusion scaffolding.
|
|
142
|
+
"here's the thing:": {"category": "throat_clearing", "severity": "hard", "suggestion": None},
|
|
143
|
+
"in conclusion": {
|
|
144
|
+
"category": "conclusion_scaffold",
|
|
145
|
+
"severity": "hard",
|
|
146
|
+
"suggestion": "State the conclusion directly.",
|
|
147
|
+
},
|
|
148
|
+
|
|
149
|
+
# Significance inflation and vague attribution.
|
|
150
|
+
"underscore the importance": {
|
|
151
|
+
"category": "significance_inflation",
|
|
152
|
+
"severity": "hard",
|
|
153
|
+
"suggestion": "State the concrete effect.",
|
|
154
|
+
},
|
|
155
|
+
"analysts predict": {
|
|
156
|
+
"category": "vague_attribution",
|
|
157
|
+
"severity": "hard",
|
|
158
|
+
"suggestion": "Name the analysts or cite the forecast.",
|
|
159
|
+
},
|
|
160
|
+
|
|
161
|
+
# High-signal AI vocabulary.
|
|
162
|
+
"treasure trove": {
|
|
163
|
+
"category": "ai_vocabulary",
|
|
164
|
+
"severity": "hard",
|
|
165
|
+
"suggestion": "collection, source",
|
|
166
|
+
},
|
|
167
|
+
|
|
168
|
+
# False agency, promotion, and assistant artifacts.
|
|
169
|
+
"speak for themselves": {
|
|
170
|
+
"category": "false_agency",
|
|
171
|
+
"severity": "hard",
|
|
172
|
+
"suggestion": "State the numbers and what they show.",
|
|
173
|
+
},
|
|
174
|
+
"rich cultural heritage": {
|
|
175
|
+
"category": "promotional",
|
|
176
|
+
"severity": "hard",
|
|
177
|
+
"suggestion": None,
|
|
178
|
+
},
|
|
179
|
+
"as an ai language model": {
|
|
180
|
+
"category": "assistant_artifact",
|
|
181
|
+
"severity": "hard",
|
|
182
|
+
"suggestion": "Delete the chatbot boilerplate.",
|
|
183
|
+
},
|
|
184
|
+
|
|
185
|
+
# Literal words remain out of the pack unless a jargon collocation is clear.
|
|
186
|
+
"game changer": {
|
|
187
|
+
"category": "jargon",
|
|
188
|
+
"severity": "hard",
|
|
189
|
+
"suggestion": "significant, important",
|
|
190
|
+
},
|
|
191
|
+
"synergy": {
|
|
192
|
+
"category": "jargon",
|
|
193
|
+
"severity": "hard",
|
|
194
|
+
"suggestion": "cooperation, collaboration",
|
|
195
|
+
},
|
|
196
|
+
"robust": {
|
|
197
|
+
"category": "jargon",
|
|
198
|
+
"severity": "soft",
|
|
199
|
+
"suggestion": "strong, solid, thorough",
|
|
200
|
+
},
|
|
201
|
+
"comprehensive": {
|
|
202
|
+
"category": "jargon",
|
|
203
|
+
"severity": "soft",
|
|
204
|
+
"suggestion": "full, complete, thorough",
|
|
205
|
+
},
|
|
206
|
+
|
|
207
|
+
# Filler and chatbot knowledge-cutoff framing.
|
|
208
|
+
"at the end of the day": {
|
|
209
|
+
"category": "filler",
|
|
210
|
+
"severity": "hard",
|
|
211
|
+
"suggestion": None,
|
|
212
|
+
},
|
|
213
|
+
"in today's": {
|
|
214
|
+
"category": "filler",
|
|
215
|
+
"severity": "hard",
|
|
216
|
+
"suggestion": None,
|
|
217
|
+
},
|
|
218
|
+
"as of my last": {
|
|
219
|
+
"category": "knowledge_cutoff",
|
|
220
|
+
"severity": "hard",
|
|
221
|
+
"suggestion": "Delete the training-cutoff disclaimer.",
|
|
222
|
+
},
|
|
223
|
+
"why should you care": {
|
|
224
|
+
"category": "rhetorical_question",
|
|
225
|
+
"severity": "hard",
|
|
226
|
+
"suggestion": "State why it matters directly.",
|
|
227
|
+
},
|
|
228
|
+
}
|
|
229
|
+
|
|
230
|
+
# Compile literal phrase regexes once at module load. In-process callers may
|
|
231
|
+
# scan many documents; they should not pay this fixed compilation cost per
|
|
232
|
+
# document (subprocess callers still pay it once per process, as before).
|
|
233
|
+
for _banned_phrase in BANNED_PHRASES:
|
|
234
|
+
_phrase_pattern_ci(_banned_phrase)
|
|
235
|
+
|
|
236
|
+
# Compact structural pack. Domain-sensitive branches are gated narrowly so
|
|
237
|
+
# literal construction, mechanics, law, medicine, and code remain clean.
|
|
238
|
+
STRUCTURAL_PATTERNS: list[dict[str, str]] = [
|
|
239
|
+
# Emphasis and throat-clearing.
|
|
240
|
+
{
|
|
241
|
+
"pattern": r"(?:^|[.!?]\s+)(?:full stop|period)\.",
|
|
242
|
+
"category": "emphasis_crutch",
|
|
243
|
+
"severity": "hard",
|
|
244
|
+
"suggestion": "Cut the one-word emphasis sentence.",
|
|
245
|
+
},
|
|
246
|
+
{
|
|
247
|
+
"pattern": r"\bthe real \w+ (?:is|isn't|was|wasn't|remains)\b",
|
|
248
|
+
"category": "throat_clearing",
|
|
249
|
+
"severity": "hard",
|
|
250
|
+
"suggestion": "State it directly.",
|
|
251
|
+
},
|
|
252
|
+
{
|
|
253
|
+
"pattern": r"(?i)\b(?:the\s+|that\s+|this\s+)?(?:struggle|stakes|pain|threat|risk|danger|fear|hype|magic|hustle|grind|stress|pressure|burnout|concern|consequences|impact|tension|anxiety|disconnect|divide|need|demand|love|chemistry|connection|mechanic|feels?)\s+(?:is|are|was|were)\s+(?:very\s+|so\s+|all\s+too\s+)?real\b",
|
|
254
|
+
"category": "emphasis_crutch",
|
|
255
|
+
"severity": "soft",
|
|
256
|
+
"suggestion": "State what is actually at stake.",
|
|
257
|
+
},
|
|
258
|
+
|
|
259
|
+
# Jargon collocations. Bare literal words with ordinary meanings are not
|
|
260
|
+
# structural hits; the fixture keeps a protection for each domain branch.
|
|
261
|
+
{
|
|
262
|
+
"pattern": r"\bleverag(?:e|es|ed|ing)\s+(?:our\s+|your\s+|their\s+|its\s+|the\s+)?(?:synerg|core\s+compet|strength|expertise|capabilit|technolog|resource|data\b|ai\b|platform|ecosystem|network|audit\s+stream|power\s+of)",
|
|
263
|
+
"category": "jargon",
|
|
264
|
+
"severity": "hard",
|
|
265
|
+
"suggestion": "use, apply",
|
|
266
|
+
},
|
|
267
|
+
{
|
|
268
|
+
"pattern": r"\bnavigat(?:e|es|ed|ing)\s+(?:the\s+|this\s+|these\s+)?(?:complex|challeng|landscape|nuance|intric|water|terrain|maze|minefield|uncertaint|world\s+of|ever-)",
|
|
269
|
+
"category": "jargon",
|
|
270
|
+
"severity": "hard",
|
|
271
|
+
"suggestion": "handle, address, manage",
|
|
272
|
+
},
|
|
273
|
+
{
|
|
274
|
+
# Keep the high-signal abstract use, but leave literal excavation and
|
|
275
|
+
# mining language ("delve into the mountain") alone.
|
|
276
|
+
"pattern": r"\bdelv(?:e|es|ed|ing)\s+into\s+(?:the\s+)?(?:topic|topics|issue|issues|implication|implications|question|questions|subject|details?|nuance|nuances|meaning|argument|claim|concept|matter|problem|analysis|research|data|strategy|history|world|conversation|evidence|complexit(?:y|ies))\b",
|
|
277
|
+
"category": "jargon",
|
|
278
|
+
"severity": "hard",
|
|
279
|
+
"suggestion": "explore, examine, look at",
|
|
280
|
+
},
|
|
281
|
+
{
|
|
282
|
+
"pattern": r"\bharness(?:es|ed|ing)?\s+(?:the\s+|its\s+|their\s+|our\s+)?(?:power|potential|strength|capabilit|momentum|force|full\s+)",
|
|
283
|
+
"category": "jargon",
|
|
284
|
+
"severity": "hard",
|
|
285
|
+
"suggestion": "use, tap, apply",
|
|
286
|
+
},
|
|
287
|
+
{
|
|
288
|
+
"pattern": r"\bharness(?:es|ed|ing)?\s+(?:(?:(?:the|our|their|its)\s+)?(?:team|group|company|organization|workforce)(?:['’]s)\s+energy\b(?=[^.!?\n]{0,100}\b(?:growth|launch|customer\s+service|transition|collaboration|innovation|expertise|engagement|success|results?)\b)|(?:(?:the|our|their)\s+)?energy\s+(?:and\s+expertise\b|of\s+(?:(?:our|the)\s+)?(?:team|group|workforce|people)\s+(?:collaboration|innovation|expertise)\b|to\s+(?:drive|fuel|accelerate|unlock|advance)\s+(?:innovation|collaboration|engagement|growth|transformation|success|results?)\b))",
|
|
289
|
+
"category": "jargon",
|
|
290
|
+
"severity": "hard",
|
|
291
|
+
"suggestion": "use, focus, coordinate",
|
|
292
|
+
},
|
|
293
|
+
{
|
|
294
|
+
"pattern": r"\bfoster(?:s|ed|ing)?\s+(?:a\s+|an\s+|greater\s+|deeper\s+|stronger\s+)?(?:culture|collaboration|innovation|sense\s+of|community|environment|growth|engagement|inclusion|creativity|dialogue|connection|belonging)",
|
|
295
|
+
"category": "jargon",
|
|
296
|
+
"severity": "hard",
|
|
297
|
+
"suggestion": "build, encourage, create",
|
|
298
|
+
},
|
|
299
|
+
{
|
|
300
|
+
"pattern": r"\bunpack(?:s|ed|ing)?\s+(?:the\s+|this\s+|that\s+|our\s+)?(?:idea|argument|assumption|implication|implications|nuance|meaning|claim|concept|topic|dynamic|why|how|what)\b",
|
|
301
|
+
"category": "jargon",
|
|
302
|
+
"severity": "hard",
|
|
303
|
+
"suggestion": "explain, examine",
|
|
304
|
+
},
|
|
305
|
+
{
|
|
306
|
+
"pattern": r"\bdoubl(?:e|es|ed|ing)\s+down\s+on\s+(?:the\s+|this\s+|that\s+|our\s+|your\s+|its\s+|their\s+|a\s+|an\s+)?(?:strategy|approach|investment|bet|commitment|vision|message|plan|position)\b",
|
|
307
|
+
"category": "jargon",
|
|
308
|
+
"severity": "hard",
|
|
309
|
+
"suggestion": "commit, increase",
|
|
310
|
+
},
|
|
311
|
+
{
|
|
312
|
+
"pattern": r"\bbolster(?:s|ed|ing)?\s+(?:the\s+|this\s+|that\s+|our\s+|your\s+)?(?:argument|case|claim|confidence|credibility|support|position|strategy|effort|security)\b",
|
|
313
|
+
"category": "jargon",
|
|
314
|
+
"severity": "hard",
|
|
315
|
+
"suggestion": "support, strengthen",
|
|
316
|
+
},
|
|
317
|
+
{
|
|
318
|
+
"pattern": r"\bstakeholders?\b[^.!?\n]{0,50}\b(?:buy-in|alignment|engagement|feedback|input|management)\b|\b(?:buy-in|alignment|engagement)\b[^.!?\n]{0,50}\bstakeholders?\b",
|
|
319
|
+
"category": "jargon",
|
|
320
|
+
"severity": "hard",
|
|
321
|
+
"suggestion": "people involved",
|
|
322
|
+
},
|
|
323
|
+
{
|
|
324
|
+
"pattern": r"\b(?:in\s+)?(?:today's|modern|contemporary|business|marketing|tech|ai|media|education|healthcare|finance|industry)\s+landscape\b|\bthe\s+(?:business|marketing|tech|ai|media|education|healthcare|finance|industry)\s+landscape\s+of\b|\bthe\s+landscape\s+of\s+(?:modern\s+|today's\s+|contemporary\s+)?(?:marketing|business|tech\w*|ai|work|media|education|healthcare|finance|the industry)\b",
|
|
325
|
+
"category": "jargon",
|
|
326
|
+
"severity": "hard",
|
|
327
|
+
"suggestion": "situation, field, market",
|
|
328
|
+
},
|
|
329
|
+
{
|
|
330
|
+
"pattern": r"\bload-bearing\s+(?:part|piece|point|claim|idea|insight|assumption|detail|context|constraint|requirement|decision|argument|premise|section|paragraph|sentence|word|term|concept)\b",
|
|
331
|
+
"category": "jargon",
|
|
332
|
+
"severity": "hard",
|
|
333
|
+
"suggestion": "essential, important, necessary",
|
|
334
|
+
},
|
|
335
|
+
{
|
|
336
|
+
"pattern": r"\b(?:our|the|a)\s+wedge\s+into\s+the\s+(?:\w+\s+)?(?:market|enterprise|industry|segment|category|account|vertical)s?\b|\bas\s+a\s+wedge\b",
|
|
337
|
+
"category": "jargon",
|
|
338
|
+
"severity": "hard",
|
|
339
|
+
"suggestion": "opening, angle, advantage, entry point",
|
|
340
|
+
},
|
|
341
|
+
{
|
|
342
|
+
"pattern": r"\b(?:the\s+)?substrate\s+(?:for|of)\s+(?:everything|all|our|the\s+(?:company|business|movement|conversation|debate|work))\b|\bcultural\s+substrate\b",
|
|
343
|
+
"category": "ai_vocabulary",
|
|
344
|
+
"severity": "soft",
|
|
345
|
+
"suggestion": "foundation, base, layer",
|
|
346
|
+
},
|
|
347
|
+
|
|
348
|
+
# Bare unattributed research is the vague-attribution move.
|
|
349
|
+
{
|
|
350
|
+
"pattern": r"(?:^|[.!?;:]\s+)research\s+(?:indicates|shows|suggests)\b",
|
|
351
|
+
"category": "vague_attribution",
|
|
352
|
+
"severity": "soft",
|
|
353
|
+
"suggestion": "Cite the specific research or name the source.",
|
|
354
|
+
},
|
|
355
|
+
{
|
|
356
|
+
"pattern": r"\bboasts?\s+(?:a\s+|an\s+)?(?:world-class|state-of-the-art|cutting-edge|impressive|stunning|robust|comprehensive|unparalleled|rich|vibrant|array of|host of|range of|wealth of|plethora)",
|
|
357
|
+
"category": "promotional",
|
|
358
|
+
"severity": "hard",
|
|
359
|
+
"suggestion": "has",
|
|
360
|
+
},
|
|
361
|
+
{
|
|
362
|
+
"pattern": r"\b(?:data|numbers?|charts?|graphs?|metrics?|figures?|results?|dashboards?|spreadsheets?|trend\s?lines?|statistics)\s+tells?\s+a\s+(?:clear\s+)?story\b",
|
|
363
|
+
"category": "false_agency",
|
|
364
|
+
"severity": "hard",
|
|
365
|
+
"suggestion": "State what the data shows.",
|
|
366
|
+
},
|
|
367
|
+
{
|
|
368
|
+
"pattern": r"\bplays?\s+an?\s+(?:crucial|key|vital|pivotal|significant|central|important|critical|defining|major)\s+(?:role|part)\b",
|
|
369
|
+
"category": "significance_inflation",
|
|
370
|
+
"severity": "soft",
|
|
371
|
+
"suggestion": "State the specific effect.",
|
|
372
|
+
},
|
|
373
|
+
|
|
374
|
+
# Legal/technical and domain-valid protections.
|
|
375
|
+
{
|
|
376
|
+
"pattern": r"\bnotwithstanding\b(?!\s+(?:anything\s+to\s+the\s+contrary|the\s+foregoing|any(?:thing)?\s+(?:other\s+)?provision|section|clause|subsection|anything\s+in))",
|
|
377
|
+
"category": "ai_vocabulary",
|
|
378
|
+
"severity": "soft",
|
|
379
|
+
"suggestion": "Use a direct transition.",
|
|
380
|
+
},
|
|
381
|
+
{
|
|
382
|
+
"pattern": r"\b(?:acts|serves|stands|stood)\s+as\s+(?:a|an|the)\s+(?:testament|reminder|symbol|beacon|foundation|cornerstone|gateway|catalyst|bridge|hub|springboard|window|monument|hallmark|blueprint|cautionary|stark|powerful|shining|prime example|case study|model for)\b",
|
|
383
|
+
"category": "copula_avoidance",
|
|
384
|
+
"severity": "hard",
|
|
385
|
+
"suggestion": "Use a direct verb.",
|
|
386
|
+
},
|
|
387
|
+
{
|
|
388
|
+
"pattern": r"\bconstitutes\s+(?:a|an|the)\s+(?:(?:groundbreaking|transformative|trailblazing|seminal|revolutionary|landmark|pivotal|significant|major|key)\s+)?(?:transformation|breakthrough|milestone|achievement|innovation|advance|success|turning\s+point|cornerstone|testament|legacy|game[- ]changer)\b",
|
|
389
|
+
"category": "copula_avoidance",
|
|
390
|
+
"severity": "hard",
|
|
391
|
+
"suggestion": "Use a direct verb.",
|
|
392
|
+
},
|
|
393
|
+
{
|
|
394
|
+
"pattern": r"\bfunctions\s+as\s+(?:a|an|the)\s+(?:(?:seamless|comprehensive|robust|transformative|groundbreaking|strategic|powerful|key|central|critical|all-in-one|single)\s+)?(?:solution|framework|platform|hub|bridge|catalyst|cornerstone|benchmark|testament|symbol|beacon|transformation|milestone|game[- ]changer)\b",
|
|
395
|
+
"category": "copula_avoidance",
|
|
396
|
+
"severity": "hard",
|
|
397
|
+
"suggestion": "Use a direct verb.",
|
|
398
|
+
},
|
|
399
|
+
|
|
400
|
+
# Anti-slop contrast and parallelism.
|
|
401
|
+
{
|
|
402
|
+
"pattern": r"(?im)(?:^|[.!?]\s+)(?:not|no)\b[^.!?]{0,28}[.!?]\s+(?:the\s+|it'?s?\s+|that'?s?\s+)?[a-z][^.!?]{0,28}[.!?]",
|
|
403
|
+
"category": "anti_slop_register",
|
|
404
|
+
"severity": "soft",
|
|
405
|
+
"suggestion": "Join the fragments into a varied sentence.",
|
|
406
|
+
},
|
|
407
|
+
{
|
|
408
|
+
"pattern": r"not because .+?\. because",
|
|
409
|
+
"category": "binary_contrast",
|
|
410
|
+
"severity": "hard",
|
|
411
|
+
"suggestion": "State the reason in one sentence.",
|
|
412
|
+
},
|
|
413
|
+
{
|
|
414
|
+
"pattern": r"feels like .+?\. it's actually",
|
|
415
|
+
"category": "binary_contrast",
|
|
416
|
+
"severity": "hard",
|
|
417
|
+
"suggestion": "State the diagnosis directly.",
|
|
418
|
+
},
|
|
419
|
+
{
|
|
420
|
+
"pattern": r"\bnot only .+? but also",
|
|
421
|
+
"category": "negative_parallelism",
|
|
422
|
+
"severity": "hard",
|
|
423
|
+
"suggestion": "Use a direct sentence.",
|
|
424
|
+
},
|
|
425
|
+
{
|
|
426
|
+
"pattern": r"\b(?:it'?s not|it\s+is\s+not|this is not|that'?s not|isn'?t|is\s+not|wasn'?t|was\s+not|aren'?t|are\s+not|weren'?t|were\s+not)\s+just\b[^.;!?\n]{1,60}[,;—–-]\s*(?:it'?s|it (?:is|was)|they'?re|that'?s)\b",
|
|
427
|
+
"category": "negative_parallelism",
|
|
428
|
+
"severity": "hard",
|
|
429
|
+
"suggestion": "State the contrast directly.",
|
|
430
|
+
},
|
|
431
|
+
|
|
432
|
+
# Reader-steering and rhetorical-question scaffolding.
|
|
433
|
+
{
|
|
434
|
+
"pattern": r"(?i)\bin this (?:article|section|post|guide|chapter|paper),?\s+(?:we|i)\s+(?:will|'ll|are going to|shall)\b",
|
|
435
|
+
"category": "reader_addressing",
|
|
436
|
+
"severity": "soft",
|
|
437
|
+
"suggestion": "Start with the point.",
|
|
438
|
+
},
|
|
439
|
+
{
|
|
440
|
+
"pattern": r"(?im)(?:^|[.!?]\s+)whether you'?re (?=[^.!?\n]{0,60}\b(?:a|an|just starting)\s)[^.!?\n]{1,60}\bor\b",
|
|
441
|
+
"category": "reader_addressing",
|
|
442
|
+
"severity": "soft",
|
|
443
|
+
"suggestion": "Cut the audience-flattering opener.",
|
|
444
|
+
},
|
|
445
|
+
{
|
|
446
|
+
"pattern": r"(?im)(?:^|[.!?]\s+)(?:why does this matter|what's the (?:real )?takeaway|why this matters|so what does (?:this|that) mean)\b[^.!?\n]{0,40}[?:]",
|
|
447
|
+
"category": "rhetorical_question",
|
|
448
|
+
"severity": "soft",
|
|
449
|
+
"suggestion": "Answer directly instead of teeing up a self-Q&A.",
|
|
450
|
+
},
|
|
451
|
+
{
|
|
452
|
+
"pattern": r"(?m)^\s*(?:but\s+)?what does this mean for\b",
|
|
453
|
+
"category": "rhetorical_question",
|
|
454
|
+
"severity": "soft",
|
|
455
|
+
"suggestion": "State the consequence directly.",
|
|
456
|
+
},
|
|
457
|
+
{
|
|
458
|
+
"pattern": r"\b(?:could|may|might|can)\s+(?:potentially|possibly)\b",
|
|
459
|
+
"category": "hedge_stack",
|
|
460
|
+
"severity": "soft",
|
|
461
|
+
"suggestion": "Drop the redundant hedge.",
|
|
462
|
+
},
|
|
463
|
+
{
|
|
464
|
+
"pattern": r"(?m)^(?:here are|these are|the top)\s+\d+\s+(?:reasons|things|takeaways|lessons|ways)\b",
|
|
465
|
+
"category": "numbered_list_inflation",
|
|
466
|
+
"severity": "soft",
|
|
467
|
+
"suggestion": "List only the points that matter.",
|
|
468
|
+
},
|
|
469
|
+
]
|
|
470
|
+
|
|
471
|
+
|
|
472
|
+
def _sentence_context(text: str, start: int, end: int) -> str:
|
|
473
|
+
"""Return the sentence containing a match, preserving its original width."""
|
|
474
|
+
left = max(text.rfind(mark, 0, start) for mark in ".!?\n")
|
|
475
|
+
right_candidates = [text.find(mark, end) for mark in ".!?\n"]
|
|
476
|
+
right_candidates = [pos for pos in right_candidates if pos >= 0]
|
|
477
|
+
right = min(right_candidates) if right_candidates else len(text)
|
|
478
|
+
return text[left + 1 : right]
|
|
479
|
+
|
|
480
|
+
|
|
481
|
+
def _context_has(pattern: str, text: str, start: int, end: int) -> bool:
|
|
482
|
+
"""Check a small sentence/window around a match for domain evidence."""
|
|
483
|
+
sentence = _sentence_context(text, start, end)
|
|
484
|
+
if re.search(pattern, sentence, re.IGNORECASE):
|
|
485
|
+
return True
|
|
486
|
+
window = text[max(0, start - 180) : min(len(text), end + 180)]
|
|
487
|
+
return bool(re.search(pattern, window, re.IGNORECASE))
|
|
488
|
+
|
|
489
|
+
|
|
490
|
+
_LEGAL_CONTEXT = (
|
|
491
|
+
r"\b(?:act|agreement|agency|clause|contract|court|defendant|filing|hearing|"
|
|
492
|
+
r"judge|jury|landlord|law|lease|legal|liabilit(?:y|ies)|ordinance|part(?:y|ies)|plaintiff|"
|
|
493
|
+
r"proceedings?|provision|pursuant|regulat(?:e|ed|ion|ory)|rights?|section|"
|
|
494
|
+
r"statute|subsection|tenant\w*|warrant)\b"
|
|
495
|
+
)
|
|
496
|
+
_HISTORICAL_CONTEXT = (
|
|
497
|
+
r"\b(?:archaeolog\w*|archive\w*|artifact\w*|catalog\w*|chronicle\w*|"
|
|
498
|
+
r"document\w*|histor\w*|museum\w*|preserv\w*|record\w*|tradition\w*|"
|
|
499
|
+
r"custom\w*|excavat\w*|ancestr\w*)\b"
|
|
500
|
+
)
|
|
501
|
+
_PROMOTIONAL_CONTEXT = (
|
|
502
|
+
r"\b(?:boast\w*|celebrat\w*|famous|known|renowned|touris\w*|visitor\w*|"
|
|
503
|
+
r"destination|vibrant|stunning|impressive|rich\s+in)\b"
|
|
504
|
+
)
|
|
505
|
+
_SOURCE_CONTEXT = (
|
|
506
|
+
r"\b(?:according\s+to|per|citing|based\s+on|as\s+(?:reported|stated|"
|
|
507
|
+
r"estimated)\s+by)\b|\b(?:survey|report|study|data|figures?)\s+"
|
|
508
|
+
r"(?:from|by|of|says?|shows?|finds?|estimates?|projects?|predicts?)\b"
|
|
509
|
+
)
|
|
510
|
+
_MEDICAL_CONTEXT = (
|
|
511
|
+
r"\b(?:anatom\w*|biolog\w*|cancer|cell\w*|clinical\w*|diagnos\w*|"
|
|
512
|
+
r"disease\w*|dose\w*|drug\w*|genes?\b|genetic\w*|genomic\w*|health\w*|immune\w*|infection\w*|"
|
|
513
|
+
r"inflamm\w*|kidney\w*|liver\w*|medical\w*|medicine|patient\w*|patholog\w*|physiolog\w*|"
|
|
514
|
+
r"symptom\w*|therapy|tissue\w*|treatment\w*|tumou?r\w*|syndrome\w*)\b"
|
|
515
|
+
)
|
|
516
|
+
|
|
517
|
+
|
|
518
|
+
def _suppress_contextual_match(
|
|
519
|
+
phrase: str,
|
|
520
|
+
category: str,
|
|
521
|
+
scan_text: str,
|
|
522
|
+
start: int,
|
|
523
|
+
end: int,
|
|
524
|
+
) -> bool:
|
|
525
|
+
"""Protect ordinary domain-valid uses of otherwise useful weak signals."""
|
|
526
|
+
if phrase == "rich cultural heritage":
|
|
527
|
+
# Historical/factual descriptions are not travel-brochure copy. Keep
|
|
528
|
+
# the broad phrase for genuinely promotional "known for" language.
|
|
529
|
+
sentence = _sentence_context(scan_text, start, end)
|
|
530
|
+
return bool(
|
|
531
|
+
re.search(_HISTORICAL_CONTEXT, sentence, re.IGNORECASE)
|
|
532
|
+
and not re.search(_PROMOTIONAL_CONTEXT, sentence, re.IGNORECASE)
|
|
533
|
+
)
|
|
534
|
+
|
|
535
|
+
if phrase == "in today's":
|
|
536
|
+
# Date-specific hearing/session language is ordinary reporting, unlike
|
|
537
|
+
# the generic "in today's market/landscape" filler.
|
|
538
|
+
return bool(
|
|
539
|
+
re.match(
|
|
540
|
+
r"\s+(?:hearing|court\s+hearing|trial|session|proceedings?)\b",
|
|
541
|
+
scan_text[end:],
|
|
542
|
+
re.IGNORECASE,
|
|
543
|
+
)
|
|
544
|
+
)
|
|
545
|
+
|
|
546
|
+
if phrase == "analysts predict":
|
|
547
|
+
# Keep unattributed forecasts flagged, but trust a nearby explicit
|
|
548
|
+
# source/record cue ("according to the survey", "Reuters reports", …).
|
|
549
|
+
sentence = _sentence_context(scan_text, start, end)
|
|
550
|
+
if re.search(_SOURCE_CONTEXT, sentence, re.IGNORECASE):
|
|
551
|
+
return True
|
|
552
|
+
return False
|
|
553
|
+
|
|
554
|
+
if category == "negative_parallelism" and scan_text[start:end].lower().startswith("not only"):
|
|
555
|
+
# Legal drafting uses this parallel construction as precise scope, not
|
|
556
|
+
# as the canned contrast the rule targets.
|
|
557
|
+
return bool(
|
|
558
|
+
re.search(
|
|
559
|
+
_LEGAL_CONTEXT,
|
|
560
|
+
_sentence_context(scan_text, start, end),
|
|
561
|
+
re.IGNORECASE,
|
|
562
|
+
)
|
|
563
|
+
)
|
|
564
|
+
|
|
565
|
+
if category == "significance_inflation" and re.match(
|
|
566
|
+
r"plays?\s+an?\s+(?:crucial|key|vital|pivotal|significant|central|important|critical|defining|major)\s+(?:role|part)\b",
|
|
567
|
+
scan_text[start:end],
|
|
568
|
+
re.IGNORECASE,
|
|
569
|
+
):
|
|
570
|
+
# Scientific and medical prose often needs this precise causal wording.
|
|
571
|
+
return _context_has(_MEDICAL_CONTEXT, scan_text, start, end)
|
|
572
|
+
|
|
573
|
+
return False
|
|
574
|
+
|
|
575
|
+
|
|
576
|
+
def scan_for_violations(text: str, include_quoted: bool = False) -> list[Violation]:
|
|
577
|
+
"""Scan text for banned phrases and structural patterns."""
|
|
578
|
+
violations: list[Violation] = []
|
|
579
|
+
spans: list[tuple[int, int]] = []
|
|
580
|
+
scan_text = mask_ignored_spans(text, include_quoted=include_quoted)
|
|
581
|
+
# Cheap presence index: most documents contain only a few of the
|
|
582
|
+
# retained literal phrases.
|
|
583
|
+
# literal phrases. Precise matches still run against the original-width
|
|
584
|
+
# text, so Unicode case expansion cannot corrupt reported offsets.
|
|
585
|
+
# Normalize curly apostrophes only for the cheap presence check. Match
|
|
586
|
+
# against the original-width text below so reported offsets stay exact.
|
|
587
|
+
scan_text_lower = scan_text.lower().replace("’", "'")
|
|
588
|
+
line_starts = _line_starts(text)
|
|
589
|
+
line_context_cache: dict[int, str] = {}
|
|
590
|
+
|
|
591
|
+
# Check banned phrases. Matching runs case-insensitively directly on
|
|
592
|
+
# scan_text (original case, masking is length-preserving) instead of on a
|
|
593
|
+
# separately lowered copy, so match.start()/match.end() are valid offsets
|
|
594
|
+
# into the original text with no re-derivation needed.
|
|
595
|
+
for phrase, info in BANNED_PHRASES.items():
|
|
596
|
+
if phrase not in scan_text_lower:
|
|
597
|
+
continue
|
|
598
|
+
for match in _phrase_pattern_ci(phrase).finditer(scan_text):
|
|
599
|
+
if _suppress_contextual_match(
|
|
600
|
+
phrase,
|
|
601
|
+
info["category"],
|
|
602
|
+
scan_text,
|
|
603
|
+
match.start(),
|
|
604
|
+
match.end(),
|
|
605
|
+
):
|
|
606
|
+
continue
|
|
607
|
+
tail = scan_text[match.end():]
|
|
608
|
+
if (
|
|
609
|
+
phrase == "robust"
|
|
610
|
+
and re.match(
|
|
611
|
+
r"\s+(?:hash\s+verification|retry\s+mechanism|error\s+handling|test\s+suite)\b",
|
|
612
|
+
tail,
|
|
613
|
+
re.IGNORECASE,
|
|
614
|
+
)
|
|
615
|
+
) or (
|
|
616
|
+
phrase == "comprehensive"
|
|
617
|
+
and re.match(
|
|
618
|
+
r"\s+(?:visual\s+survey|needs\s+screen)\b",
|
|
619
|
+
tail,
|
|
620
|
+
re.IGNORECASE,
|
|
621
|
+
)
|
|
622
|
+
):
|
|
623
|
+
continue
|
|
624
|
+
pos = match.start()
|
|
625
|
+
line_num, column, context = _line_col_context(
|
|
626
|
+
text, line_starts, pos, line_context_cache
|
|
627
|
+
)
|
|
628
|
+
|
|
629
|
+
violations.append({
|
|
630
|
+
"phrase": phrase,
|
|
631
|
+
"category": info["category"],
|
|
632
|
+
"severity": info.get("severity", "hard"),
|
|
633
|
+
"line_number": line_num,
|
|
634
|
+
"column": column,
|
|
635
|
+
"context": context,
|
|
636
|
+
"suggestion": info["suggestion"]
|
|
637
|
+
})
|
|
638
|
+
spans.append((match.start(), match.end()))
|
|
639
|
+
|
|
640
|
+
# Check structural patterns
|
|
641
|
+
for pattern_info in STRUCTURAL_PATTERNS:
|
|
642
|
+
matches = list(re.finditer(pattern_info["pattern"], scan_text, re.IGNORECASE))
|
|
643
|
+
min_matches = int(pattern_info.get("min_matches", "1"))
|
|
644
|
+
if len(matches) < min_matches:
|
|
645
|
+
continue
|
|
646
|
+
for match in matches:
|
|
647
|
+
if _suppress_contextual_match(
|
|
648
|
+
match.group().lower(),
|
|
649
|
+
pattern_info["category"],
|
|
650
|
+
scan_text,
|
|
651
|
+
match.start(),
|
|
652
|
+
match.end(),
|
|
653
|
+
):
|
|
654
|
+
continue
|
|
655
|
+
pos = match.start()
|
|
656
|
+
line_num, column, context = _line_col_context(
|
|
657
|
+
text, line_starts, pos, line_context_cache
|
|
658
|
+
)
|
|
659
|
+
|
|
660
|
+
violations.append({
|
|
661
|
+
# Preserve the pre-fix lowercase phrase field: STRUCTURAL_PATTERNS
|
|
662
|
+
# regexes are lowercase literals, previously matched against a
|
|
663
|
+
# lowered copy of the text, so match.group() was always lowercase.
|
|
664
|
+
"phrase": match.group().lower(),
|
|
665
|
+
"category": pattern_info["category"],
|
|
666
|
+
"severity": pattern_info.get("severity", "hard"),
|
|
667
|
+
"line_number": line_num,
|
|
668
|
+
"column": column,
|
|
669
|
+
"context": context,
|
|
670
|
+
"suggestion": pattern_info["suggestion"]
|
|
671
|
+
})
|
|
672
|
+
spans.append((match.start(), match.end()))
|
|
673
|
+
|
|
674
|
+
# Frequency-gated structural findings (min_matches > 1) describe the DOCUMENT, not
|
|
675
|
+
# a single span. A broad, unrelated match (e.g. anti_slop_register spanning several
|
|
676
|
+
# short headlines) must not silently swallow every occurrence and erase the
|
|
677
|
+
# document-level tell, so these categories are exempt from containment suppression.
|
|
678
|
+
freq_gated = {
|
|
679
|
+
p["category"] for p in STRUCTURAL_PATTERNS if int(p.get("min_matches", "1")) > 1
|
|
680
|
+
}
|
|
681
|
+
|
|
682
|
+
# Linear containment sweep, equivalent to the O(n^2) "any other strictly
|
|
683
|
+
# larger span fully encloses mine" check above, but O(n log n): sort by
|
|
684
|
+
# (start ascending, end descending) and sweep once. For any two spans with
|
|
685
|
+
# other_start <= start and end <= other_end, (other_end - other_start) >
|
|
686
|
+
# (end - start) holds automatically UNLESS the spans are identical -- so
|
|
687
|
+
# weak enclosure by a DISTINCT span is exactly the strict-length condition.
|
|
688
|
+
# A running "tallest end seen among strictly earlier starts" plus a
|
|
689
|
+
# within-group max (for spans sharing the current start) reproduces this
|
|
690
|
+
# without ever comparing every pair.
|
|
691
|
+
n = len(violations)
|
|
692
|
+
order = sorted(range(n), key=lambda i: (spans[i][0], -spans[i][1]))
|
|
693
|
+
contained = [False] * n
|
|
694
|
+
max_end_before_group = -1
|
|
695
|
+
idx = 0
|
|
696
|
+
while idx < n:
|
|
697
|
+
group_start = spans[order[idx]][0]
|
|
698
|
+
group_end = idx
|
|
699
|
+
while group_end < n and spans[order[group_end]][0] == group_start:
|
|
700
|
+
group_end += 1
|
|
701
|
+
running_max_in_group = -1
|
|
702
|
+
for vi in order[idx:group_end]:
|
|
703
|
+
end = spans[vi][1]
|
|
704
|
+
if max_end_before_group >= end or running_max_in_group > end:
|
|
705
|
+
contained[vi] = True
|
|
706
|
+
if end > running_max_in_group:
|
|
707
|
+
running_max_in_group = end
|
|
708
|
+
if running_max_in_group > max_end_before_group:
|
|
709
|
+
max_end_before_group = running_max_in_group
|
|
710
|
+
idx = group_end
|
|
711
|
+
|
|
712
|
+
violations = [
|
|
713
|
+
v for i, v in enumerate(violations)
|
|
714
|
+
if not contained[i] or v["category"] in freq_gated
|
|
715
|
+
]
|
|
716
|
+
|
|
717
|
+
# Sort by line number, then column
|
|
718
|
+
violations.sort(key=lambda v: (v["line_number"], v["column"]))
|
|
719
|
+
|
|
720
|
+
return violations
|
|
721
|
+
|
|
722
|
+
|
|
723
|
+
def parse_args() -> argparse.Namespace:
|
|
724
|
+
parser = argparse.ArgumentParser(description=__doc__)
|
|
725
|
+
parser.add_argument("input_file", nargs="?", help="Optional input file. Reads stdin when omitted.")
|
|
726
|
+
parser.add_argument(
|
|
727
|
+
"--include-quoted",
|
|
728
|
+
action="store_true",
|
|
729
|
+
help="Scan quoted examples and markdown blockquotes instead of skipping them.",
|
|
730
|
+
)
|
|
731
|
+
return parser.parse_args()
|
|
732
|
+
|
|
733
|
+
|
|
734
|
+
def main() -> None:
|
|
735
|
+
args = parse_args()
|
|
736
|
+
|
|
737
|
+
# Read input
|
|
738
|
+
if args.input_file:
|
|
739
|
+
try:
|
|
740
|
+
with open(args.input_file, 'r', errors="replace") as f:
|
|
741
|
+
text = f.read()
|
|
742
|
+
except OSError as e:
|
|
743
|
+
print(json.dumps({"error": f"Could not read input: {e}", "violations": []}))
|
|
744
|
+
sys.exit(2)
|
|
745
|
+
else:
|
|
746
|
+
text = sys.stdin.buffer.read().decode("utf-8", errors="replace")
|
|
747
|
+
|
|
748
|
+
if not text.strip():
|
|
749
|
+
print(json.dumps({"error": "No input provided", "violations": []}))
|
|
750
|
+
sys.exit(1)
|
|
751
|
+
|
|
752
|
+
violations = scan_for_violations(text, include_quoted=args.include_quoted)
|
|
753
|
+
|
|
754
|
+
# English-only graceful decline. Function-word absence alone is not evidence
|
|
755
|
+
# of a foreign language: imperative stacks and buzzword lists are English
|
|
756
|
+
# slop with few function words. Decline only when the text both fails the
|
|
757
|
+
# function-word heuristic AND produced zero English-pattern hits.
|
|
758
|
+
if not violations and not is_probably_english(text):
|
|
759
|
+
print(json.dumps({"non_english": True, "total_violations": 0, "violations": []}, indent=2))
|
|
760
|
+
print("note: input appears non-English; scanner declined (English-only).", file=sys.stderr)
|
|
761
|
+
sys.exit(0)
|
|
762
|
+
|
|
763
|
+
# Group by category for summary
|
|
764
|
+
categories: dict[str, int] = {}
|
|
765
|
+
by_severity: dict[str, int] = {"hard": 0, "soft": 0}
|
|
766
|
+
for v in violations:
|
|
767
|
+
categories[v["category"]] = categories.get(v["category"], 0) + 1
|
|
768
|
+
by_severity[v["severity"]] = by_severity.get(v["severity"], 0) + 1
|
|
769
|
+
|
|
770
|
+
output = {
|
|
771
|
+
"total_violations": len(violations),
|
|
772
|
+
"by_severity": by_severity,
|
|
773
|
+
"by_category": categories,
|
|
774
|
+
"violations": violations
|
|
775
|
+
}
|
|
776
|
+
|
|
777
|
+
print(json.dumps(output, indent=2))
|
|
778
|
+
|
|
779
|
+
# Exit with 1 if violations found
|
|
780
|
+
sys.exit(1 if violations else 0)
|
|
781
|
+
|
|
782
|
+
|
|
783
|
+
if __name__ == "__main__":
|
|
784
|
+
main()
|