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.
Files changed (89) hide show
  1. agentforge_framework/.claude-plugin/plugin.json +4 -0
  2. agentforge_framework/__init__.py +3 -0
  3. agentforge_framework/agents/__init__.py +92 -0
  4. agentforge_framework/agents/architect.py +146 -0
  5. agentforge_framework/agents/implementer.py +162 -0
  6. agentforge_framework/agents/orchestrator.py +588 -0
  7. agentforge_framework/agents/reviewer.py +335 -0
  8. agentforge_framework/agents/security.py +138 -0
  9. agentforge_framework/agents/tester.py +125 -0
  10. agentforge_framework/cli.py +461 -0
  11. agentforge_framework/context/__init__.py +1 -0
  12. agentforge_framework/context/extractors/__init__.py +76 -0
  13. agentforge_framework/context/extractors/base.py +47 -0
  14. agentforge_framework/context/extractors/python.py +65 -0
  15. agentforge_framework/context/extractors/sql.py +121 -0
  16. agentforge_framework/context/extractors/yaml.py +59 -0
  17. agentforge_framework/context/prompt.py +104 -0
  18. agentforge_framework/context/resolver.py +185 -0
  19. agentforge_framework/core/__init__.py +1 -0
  20. agentforge_framework/core/commands.py +170 -0
  21. agentforge_framework/core/config.py +90 -0
  22. agentforge_framework/core/contracts.py +875 -0
  23. agentforge_framework/core/gates.py +333 -0
  24. agentforge_framework/core/issues.py +697 -0
  25. agentforge_framework/core/plan_format.py +272 -0
  26. agentforge_framework/core/process.py +141 -0
  27. agentforge_framework/core/project.py +262 -0
  28. agentforge_framework/core/registry.py +455 -0
  29. agentforge_framework/core/repo.py +185 -0
  30. agentforge_framework/core/router.py +1 -0
  31. agentforge_framework/core/runtime.py +639 -0
  32. agentforge_framework/core/skills.py +255 -0
  33. agentforge_framework/core/workflow.py +215 -0
  34. agentforge_framework/plugins/__init__.py +35 -0
  35. agentforge_framework/plugins/databricks/__init__.py +86 -0
  36. agentforge_framework/plugins/pyspark/__init__.py +57 -0
  37. agentforge_framework/plugins/python/__init__.py +45 -0
  38. agentforge_framework/plugins/sql/__init__.py +377 -0
  39. agentforge_framework/providers/__init__.py +48 -0
  40. agentforge_framework/providers/base.py +248 -0
  41. agentforge_framework/providers/claude.py +159 -0
  42. agentforge_framework/providers/codex.py +139 -0
  43. agentforge_framework/skills/MANIFEST.yaml +157 -0
  44. agentforge_framework/skills/NOTICE +49 -0
  45. agentforge_framework/skills/domain-modeling/ADR-FORMAT.md +47 -0
  46. agentforge_framework/skills/domain-modeling/CONTEXT-FORMAT.md +60 -0
  47. agentforge_framework/skills/domain-modeling/SKILL.md +74 -0
  48. agentforge_framework/skills/domain-modeling/agents/openai.yaml +3 -0
  49. agentforge_framework/skills/grill-with-docs/SKILL.md +76 -0
  50. agentforge_framework/skills/grilling/SKILL.md +28 -0
  51. agentforge_framework/skills/grilling/agents/openai.yaml +3 -0
  52. agentforge_framework/skills/to-spec/SKILL.md +75 -0
  53. agentforge_framework/skills/to-spec/agents/openai.yaml +5 -0
  54. agentforge_framework/skills/to-tickets/SKILL.md +105 -0
  55. agentforge_framework/skills/to-tickets/agents/openai.yaml +5 -0
  56. agentforge_framework/skills/unslop/SKILL.md +131 -0
  57. agentforge_framework/skills/unslop/evals/fixtures/silhouette/human_reference.json +66 -0
  58. agentforge_framework/skills/unslop/scripts/_lang.py +106 -0
  59. agentforge_framework/skills/unslop/scripts/banned_phrase_scan.py +784 -0
  60. agentforge_framework/skills/unslop/scripts/calibrate_pairs.py +580 -0
  61. agentforge_framework/skills/unslop/scripts/calibrate_score.py +273 -0
  62. agentforge_framework/skills/unslop/scripts/check_packs.py +80 -0
  63. agentforge_framework/skills/unslop/scripts/check_suggestions.py +225 -0
  64. agentforge_framework/skills/unslop/scripts/contribute.py +373 -0
  65. agentforge_framework/skills/unslop/scripts/diff_check.py +139 -0
  66. agentforge_framework/skills/unslop/scripts/extract_constraints.py +201 -0
  67. agentforge_framework/skills/unslop/scripts/harvest_classify.py +223 -0
  68. agentforge_framework/skills/unslop/scripts/harvest_samples.py +534 -0
  69. agentforge_framework/skills/unslop/scripts/readability_metrics.py +295 -0
  70. agentforge_framework/skills/unslop/scripts/refresh_status.py +154 -0
  71. agentforge_framework/skills/unslop/scripts/silhouette_scan.py +390 -0
  72. agentforge_framework/skills/unslop/scripts/structure_scan.py +322 -0
  73. agentforge_framework/skills/unslop/scripts/suggest.py +211 -0
  74. agentforge_framework/skills/unslop/scripts/validate_preservation.py +409 -0
  75. agentforge_framework/skills/unslop/scripts/voice_card.py +496 -0
  76. agentforge_framework/skills/unslop/scripts/voice_profile.py +194 -0
  77. agentforge_framework/skills/unslop/scripts/voice_score.py +271 -0
  78. agentforge_framework/skills/unslop/scripts/wiki_sync.py +479 -0
  79. agentforge_framework/skills/write-plainly/SKILL.md +94 -0
  80. agentforge_framework/workflows/bugfix.yaml +8 -0
  81. agentforge_framework/workflows/feature.yaml +16 -0
  82. agentforge_framework/workflows/review.yaml +10 -0
  83. agentforge_framework-0.2.0.dist-info/METADATA +321 -0
  84. agentforge_framework-0.2.0.dist-info/RECORD +89 -0
  85. agentforge_framework-0.2.0.dist-info/WHEEL +5 -0
  86. agentforge_framework-0.2.0.dist-info/entry_points.txt +3 -0
  87. agentforge_framework-0.2.0.dist-info/licenses/LICENSE +202 -0
  88. agentforge_framework-0.2.0.dist-info/licenses/src/agentforge_framework/skills/NOTICE +49 -0
  89. 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()