grai-gate 0.1.3__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.
- grai_gate/__init__.py +1 -0
- grai_gate/cli.py +23 -0
- grai_gate/gate.py +964 -0
- grai_gate-0.1.3.dist-info/METADATA +86 -0
- grai_gate-0.1.3.dist-info/RECORD +9 -0
- grai_gate-0.1.3.dist-info/WHEEL +5 -0
- grai_gate-0.1.3.dist-info/entry_points.txt +2 -0
- grai_gate-0.1.3.dist-info/licenses/LICENSE +24 -0
- grai_gate-0.1.3.dist-info/top_level.txt +1 -0
grai_gate/__init__.py
ADDED
|
@@ -0,0 +1 @@
|
|
|
1
|
+
__version__ = "0.1.3"
|
grai_gate/cli.py
ADDED
|
@@ -0,0 +1,23 @@
|
|
|
1
|
+
"""Console entry point for the grai-gate package.
|
|
2
|
+
|
|
3
|
+
The engine accepts -h. This wrapper also accepts --help so the installed
|
|
4
|
+
command behaves like a standard CLI without changing the shared engine.
|
|
5
|
+
"""
|
|
6
|
+
|
|
7
|
+
import sys
|
|
8
|
+
|
|
9
|
+
from . import gate
|
|
10
|
+
|
|
11
|
+
|
|
12
|
+
def main():
|
|
13
|
+
argv = ["-h" if arg == "--help" else arg for arg in sys.argv[1:]]
|
|
14
|
+
previous = sys.argv
|
|
15
|
+
sys.argv = [previous[0]] + argv
|
|
16
|
+
try:
|
|
17
|
+
return gate.main()
|
|
18
|
+
finally:
|
|
19
|
+
sys.argv = previous
|
|
20
|
+
|
|
21
|
+
|
|
22
|
+
if __name__ == "__main__":
|
|
23
|
+
sys.exit(main())
|
grai_gate/gate.py
ADDED
|
@@ -0,0 +1,964 @@
|
|
|
1
|
+
#!/usr/bin/env python3
|
|
2
|
+
"""grai-gate: an advisory anti-slop linter for markdown and text.
|
|
3
|
+
|
|
4
|
+
The engine is deliberately small and deterministic. It uses only the Python
|
|
5
|
+
standard library modules re, sys, json, and math. It never calls the network,
|
|
6
|
+
never writes a file, and never asks a model for an opinion.
|
|
7
|
+
|
|
8
|
+
Two runtimes share one analyzer:
|
|
9
|
+
|
|
10
|
+
CLI: python3 gate.py FILE [FILE ...]
|
|
11
|
+
Clean files stay silent by default. A failing file prints a fixed
|
|
12
|
+
report card to stdout. Use -j for JSON or -v for a clean line.
|
|
13
|
+
|
|
14
|
+
HOOK: Antigravity sends a PostToolUse payload on stdin. The required JSON
|
|
15
|
+
response goes to stdout. A failing file prints the card to stderr.
|
|
16
|
+
Clean files, skipped files, and bad payloads stay completely silent.
|
|
17
|
+
The hook always exits 0 and never denies a tool call.
|
|
18
|
+
|
|
19
|
+
Thresholds come from a 134,522 post study of 1,499 founder profiles,
|
|
20
|
+
January 2023 to September 2026. Human drafts keep a sentence-length
|
|
21
|
+
coefficient of variation at or above 0.40. Flat AI drafts land under 0.32.
|
|
22
|
+
A warning band starts at 0.35. The repair target is 0.45.
|
|
23
|
+
"""
|
|
24
|
+
|
|
25
|
+
import json
|
|
26
|
+
import math
|
|
27
|
+
import os
|
|
28
|
+
import re
|
|
29
|
+
import sys
|
|
30
|
+
|
|
31
|
+
VERSION = "0.1.3"
|
|
32
|
+
UTM_SOURCE = "antigravity"
|
|
33
|
+
UTM_MEDIUM = "gate"
|
|
34
|
+
UTM_CAMPAIGN = "gate_v0.1"
|
|
35
|
+
|
|
36
|
+
PASS_SCORE = 85
|
|
37
|
+
CV_FLAT = 0.32
|
|
38
|
+
CV_WARN = 0.35
|
|
39
|
+
CV_HUMAN = 0.40
|
|
40
|
+
CV_TARGET = 0.45
|
|
41
|
+
CV_MIN_SENTENCES = 4
|
|
42
|
+
CV_MIN_MEAN = 4.0
|
|
43
|
+
MAX_CHARS = 2000000
|
|
44
|
+
MAX_SENTENCES = 6000
|
|
45
|
+
|
|
46
|
+
EM_DASH = "\u2014"
|
|
47
|
+
EN_DASH = "\u2013"
|
|
48
|
+
SENTINEL = "\u0001"
|
|
49
|
+
HYPHEN = chr(45)
|
|
50
|
+
DOUBLE_HYPHEN = HYPHEN * 2
|
|
51
|
+
TRIPLE_HYPHEN = HYPHEN * 3
|
|
52
|
+
BACKTICK = chr(96)
|
|
53
|
+
FENCE3 = BACKTICK * 3
|
|
54
|
+
TILDE3 = "~" * 3
|
|
55
|
+
|
|
56
|
+
TARGET_SUFFIXES = (".md", ".txt")
|
|
57
|
+
ARROWS = ("->", "=>", "\u2192", "\u21b3", "\u2794")
|
|
58
|
+
|
|
59
|
+
RE_HTML_COMMENT = re.compile(
|
|
60
|
+
r"(?s)<!" + re.escape(DOUBLE_HYPHEN) + r".*?"
|
|
61
|
+
+ re.escape(DOUBLE_HYPHEN) + r">"
|
|
62
|
+
)
|
|
63
|
+
RE_HR = re.compile(r"(?m)^[ \t]*([-*_])(?:[ \t]*\1){2,}[ \t]*$")
|
|
64
|
+
RE_SETEXT = re.compile(r"(?m)^[ \t]*={3,}[ \t]*$")
|
|
65
|
+
RE_TABLE_SEP = re.compile(
|
|
66
|
+
r"(?m)^[ \t]*\|?[ \t]*:?-{3,}:?[ \t]*"
|
|
67
|
+
r"(?:\|[ \t]*:?-{3,}:?[ \t]*)+\|?[ \t]*$"
|
|
68
|
+
)
|
|
69
|
+
RE_REF_DEF = re.compile(r"(?m)^[ \t]*\[[^\]\n]{1,120}\]:[ \t]*\S[^\n]*$")
|
|
70
|
+
RE_HEADING = re.compile(r"^[ \t]*#{1,6}(?:[ \t]+(.*)|[ \t]*)$")
|
|
71
|
+
RE_BULLET = re.compile(r"^[ \t]*[-*+][ \t]+(.*)$")
|
|
72
|
+
RE_NUMBERED = re.compile(r"^[ \t]*\d{1,3}[.)][ \t]+(.*)$")
|
|
73
|
+
RE_QUOTE = re.compile(r"^[ \t]*>+[ \t]?(.*)$")
|
|
74
|
+
RE_TABLE_ROW = re.compile(r"^[ \t]*\|.*\|[ \t]*$")
|
|
75
|
+
|
|
76
|
+
RE_IMAGE = re.compile(r"!\[[^\]\n]{0,300}\]\([^)\n]{0,600}\)")
|
|
77
|
+
RE_LINK = re.compile(r"\[([^\]\n]{1,300})\]\([^)\n]{0,600}\)")
|
|
78
|
+
RE_AUTOLINK = re.compile(r"<https?://[^>\s]+>")
|
|
79
|
+
RE_HTML_TAG = re.compile(r"</?[A-Za-z][^>]{0,500}>")
|
|
80
|
+
RE_INLINE_CODE = re.compile(r"`+[^`\n]*`+")
|
|
81
|
+
RE_URL = re.compile(r"(?i)\b(?:https?://|www\.)[^\s<>()\[\]]+")
|
|
82
|
+
RE_WS = re.compile(r"[ \t]+")
|
|
83
|
+
|
|
84
|
+
RE_EM_DASH = re.compile(EM_DASH)
|
|
85
|
+
RE_DOUBLE_HYPHEN = re.compile(r"\x2d{2,}")
|
|
86
|
+
RE_SENT_SPLIT = re.compile(r"(?<=[.!?])\s+")
|
|
87
|
+
RE_ABBREV = re.compile(
|
|
88
|
+
r"(?i)\b(?:e\.g|i\.e|etc|vs|mr|mrs|ms|dr|prof|inc|ltd|no|fig|approx|"
|
|
89
|
+
r"u\.s|u\.k|et\s+al)\.(?=\s|$)"
|
|
90
|
+
)
|
|
91
|
+
RE_TOKEN = re.compile(r"[^\W_]+(?:['\u2019-][^\W_]+)*", re.UNICODE)
|
|
92
|
+
|
|
93
|
+
RE_HERES = re.compile(
|
|
94
|
+
r"(?i)^[\s\"'(\[\u2018\u201c]*(?:here(?:['\u2019]?s|\s+is)\s+why)\b"
|
|
95
|
+
)
|
|
96
|
+
RE_WEAK_CLOSING = re.compile(
|
|
97
|
+
r"(?i)\b(?:"
|
|
98
|
+
r"thoughts|any\s+thoughts|your\s+thoughts|"
|
|
99
|
+
r"what\s+do\s+you\s+think|what\s+do\s+you\s+all\s+think|"
|
|
100
|
+
r"what\s+do\s+you\s+guys\s+think|"
|
|
101
|
+
r"what(?:['\u2019]s|\s+is)\s+your\s+take|"
|
|
102
|
+
r"agree\s+or\s+disagree|agree|right|"
|
|
103
|
+
r"sound\s+familiar|who(?:['\u2019]s|\s+is)\s+with\s+me|"
|
|
104
|
+
r"am\s+i\s+(?:wrong|missing\s+anything)|"
|
|
105
|
+
r"curious\s+(?:what\s+you\s+think|to\s+hear)"
|
|
106
|
+
r")\??$"
|
|
107
|
+
)
|
|
108
|
+
|
|
109
|
+
_CONTRAST_SUBJECT = r"(?:it|this|that)"
|
|
110
|
+
_CONTRAST_IS = r"(?:['\u2019]s|\s+is|\s+was|\s+are|\s+were)"
|
|
111
|
+
RE_CONTRAST = re.compile(
|
|
112
|
+
r"(?i)(?:"
|
|
113
|
+
r"\b" + _CONTRAST_SUBJECT + _CONTRAST_IS + r"\s+not\s+"
|
|
114
|
+
r"(?:just\s+|only\s+|merely\s+|really\s+|about\s+)?"
|
|
115
|
+
r"[^.!?\n]{1,90}?[;,]?\s*"
|
|
116
|
+
+ _CONTRAST_SUBJECT + _CONTRAST_IS + r"\s+(?:about\s+)?[^.!?\n]{1,90}"
|
|
117
|
+
r"|\b" + _CONTRAST_SUBJECT + _CONTRAST_IS + r"\s+not\s+about\s+"
|
|
118
|
+
r"[^.!?\n]{1,90}?,\s*but\s+[^.!?\n]{1,90}"
|
|
119
|
+
r"|\bnot\s+(?:just|only|merely)\s+"
|
|
120
|
+
r"[^.!?\n]{1,80}?,?\s*but\s+(?:also\s+)?[^.!?\n]{1,80}"
|
|
121
|
+
r"|\b" + _CONTRAST_SUBJECT + _CONTRAST_IS + r"\s+not\s+"
|
|
122
|
+
r"[^.!?\n]{1,90}\.\s+"
|
|
123
|
+
+ _CONTRAST_SUBJECT + _CONTRAST_IS + r"\s+(?:about\s+)?[^.!?\n]{1,90}"
|
|
124
|
+
r")"
|
|
125
|
+
)
|
|
126
|
+
|
|
127
|
+
ZOMBIE_PATTERNS = (
|
|
128
|
+
r"delv\w*",
|
|
129
|
+
r"leverag\w*",
|
|
130
|
+
r"game[\s-]?chang\w*",
|
|
131
|
+
r"tapestry",
|
|
132
|
+
r"testament\s+to",
|
|
133
|
+
r"in\s+today(?:['\u2019]s)?\s+fast[\s-]?paced\s+world",
|
|
134
|
+
r"(?:excited|thrilled|humbled|proud)\s+to\s+announce",
|
|
135
|
+
r"seamless(?:ly)?",
|
|
136
|
+
r"dive\s+into",
|
|
137
|
+
r"deep\s+dive",
|
|
138
|
+
r"synerg\w*",
|
|
139
|
+
r"cutting[\s-]?edge",
|
|
140
|
+
r"revolutioniz\w*",
|
|
141
|
+
r"unlock(?:ing|ed|s)?\s+(?:the\s+)?(?:full\s+)?potential",
|
|
142
|
+
r"paradigm\s+shift",
|
|
143
|
+
r"it(?:['\u2019]s|\s+is)\s+worth\s+noting",
|
|
144
|
+
r"at\s+the\s+end\s+of\s+the\s+day",
|
|
145
|
+
r"when\s+it\s+comes\s+to",
|
|
146
|
+
r"ever[\s-]?evolving",
|
|
147
|
+
r"holistic",
|
|
148
|
+
r"actionable",
|
|
149
|
+
r"impactful",
|
|
150
|
+
r"learnings",
|
|
151
|
+
r"best[\s-]?in[\s-]?class",
|
|
152
|
+
r"world[\s-]?class",
|
|
153
|
+
r"next[\s-]?generation",
|
|
154
|
+
r"state[\s-]?of[\s-]?the[\s-]?art",
|
|
155
|
+
r"moreover",
|
|
156
|
+
r"furthermore",
|
|
157
|
+
r"pivotal",
|
|
158
|
+
r"foster\w*",
|
|
159
|
+
r"underscor\w*",
|
|
160
|
+
r"elevat(?:e|es|ed|ing)",
|
|
161
|
+
r"unleash\w*",
|
|
162
|
+
r"transformative",
|
|
163
|
+
r"navigate\s+the\s+(?:complexities|landscape)",
|
|
164
|
+
r"in\s+the\s+realm\s+of",
|
|
165
|
+
r"rapidly\s+(?:changing|evolving)",
|
|
166
|
+
)
|
|
167
|
+
RE_ZOMBIE = re.compile(
|
|
168
|
+
r"\b(?:" + "|".join(ZOMBIE_PATTERNS) + r")\b",
|
|
169
|
+
re.IGNORECASE,
|
|
170
|
+
)
|
|
171
|
+
|
|
172
|
+
FLAG_ORDER = {
|
|
173
|
+
"flat": 0,
|
|
174
|
+
"low_burst": 1,
|
|
175
|
+
"borderline": 2,
|
|
176
|
+
"em_dash": 3,
|
|
177
|
+
"heres_why": 4,
|
|
178
|
+
"contrast": 5,
|
|
179
|
+
"arrows": 6,
|
|
180
|
+
"triplet": 7,
|
|
181
|
+
"closing": 8,
|
|
182
|
+
"zombie": 9,
|
|
183
|
+
}
|
|
184
|
+
|
|
185
|
+
|
|
186
|
+
def _configure_streams():
|
|
187
|
+
for stream in (sys.stdout, sys.stderr):
|
|
188
|
+
try:
|
|
189
|
+
stream.reconfigure(errors="replace")
|
|
190
|
+
except (AttributeError, ValueError):
|
|
191
|
+
pass
|
|
192
|
+
|
|
193
|
+
|
|
194
|
+
def _clean_line(text):
|
|
195
|
+
if not text:
|
|
196
|
+
return ""
|
|
197
|
+
text = RE_IMAGE.sub(" ", text)
|
|
198
|
+
text = RE_LINK.sub(lambda match: match.group(1), text)
|
|
199
|
+
text = RE_AUTOLINK.sub(" ", text)
|
|
200
|
+
text = RE_HTML_TAG.sub(" ", text)
|
|
201
|
+
text = RE_INLINE_CODE.sub(" ", text)
|
|
202
|
+
text = RE_URL.sub(" ", text)
|
|
203
|
+
text = text.replace("**", "").replace("__", "")
|
|
204
|
+
text = re.sub(r"\*([^*]+)\*", r"\1", text)
|
|
205
|
+
text = re.sub(r"(?<!\w)_([^_]+)_(?!\w)", r"\1", text)
|
|
206
|
+
text = text.replace("`", " ")
|
|
207
|
+
text = RE_WS.sub(" ", text).strip()
|
|
208
|
+
return text
|
|
209
|
+
|
|
210
|
+
|
|
211
|
+
def _drop_frontmatter(lines):
|
|
212
|
+
if not lines or lines[0].strip() != TRIPLE_HYPHEN:
|
|
213
|
+
return lines
|
|
214
|
+
for index in range(1, min(len(lines), 500)):
|
|
215
|
+
if lines[index].strip() == TRIPLE_HYPHEN:
|
|
216
|
+
return lines[index + 1:]
|
|
217
|
+
return lines
|
|
218
|
+
|
|
219
|
+
|
|
220
|
+
def _parse_blocks(text):
|
|
221
|
+
text = text.replace("\r\n", "\n").replace("\r", "\n").lstrip("\ufeff")
|
|
222
|
+
text = RE_HTML_COMMENT.sub(" ", text)
|
|
223
|
+
lines = _drop_frontmatter(text.split("\n"))
|
|
224
|
+
blocks = []
|
|
225
|
+
paragraph = []
|
|
226
|
+
fence = None
|
|
227
|
+
|
|
228
|
+
def flush_paragraph():
|
|
229
|
+
if paragraph:
|
|
230
|
+
body = _clean_line(" ".join(paragraph))
|
|
231
|
+
if body:
|
|
232
|
+
blocks.append({"kind": "prose", "text": body})
|
|
233
|
+
del paragraph[:]
|
|
234
|
+
|
|
235
|
+
for line in lines:
|
|
236
|
+
stripped = line.strip()
|
|
237
|
+
if fence is not None:
|
|
238
|
+
if stripped.startswith(fence):
|
|
239
|
+
fence = None
|
|
240
|
+
continue
|
|
241
|
+
if stripped.startswith(FENCE3):
|
|
242
|
+
flush_paragraph()
|
|
243
|
+
fence = FENCE3
|
|
244
|
+
continue
|
|
245
|
+
if stripped.startswith(TILDE3):
|
|
246
|
+
flush_paragraph()
|
|
247
|
+
fence = TILDE3
|
|
248
|
+
continue
|
|
249
|
+
if not stripped:
|
|
250
|
+
flush_paragraph()
|
|
251
|
+
continue
|
|
252
|
+
if RE_HR.match(line) or RE_SETEXT.match(line) or RE_TABLE_SEP.match(line):
|
|
253
|
+
flush_paragraph()
|
|
254
|
+
continue
|
|
255
|
+
if RE_REF_DEF.match(line):
|
|
256
|
+
flush_paragraph()
|
|
257
|
+
continue
|
|
258
|
+
|
|
259
|
+
match = RE_HEADING.match(line)
|
|
260
|
+
if match:
|
|
261
|
+
flush_paragraph()
|
|
262
|
+
heading = (match.group(1) or "").strip()
|
|
263
|
+
heading = re.sub(r"[ \t]+#+[ \t]*$", "", heading)
|
|
264
|
+
heading = _clean_line(heading)
|
|
265
|
+
if heading:
|
|
266
|
+
blocks.append({"kind": "heading", "text": heading})
|
|
267
|
+
continue
|
|
268
|
+
|
|
269
|
+
match = RE_BULLET.match(line)
|
|
270
|
+
if match:
|
|
271
|
+
flush_paragraph()
|
|
272
|
+
body = _clean_line(match.group(1))
|
|
273
|
+
if body:
|
|
274
|
+
blocks.append({"kind": "bullet", "text": body})
|
|
275
|
+
continue
|
|
276
|
+
|
|
277
|
+
match = RE_NUMBERED.match(line)
|
|
278
|
+
if match:
|
|
279
|
+
flush_paragraph()
|
|
280
|
+
body = _clean_line(match.group(1))
|
|
281
|
+
if body:
|
|
282
|
+
blocks.append({"kind": "bullet", "text": body})
|
|
283
|
+
continue
|
|
284
|
+
|
|
285
|
+
match = RE_QUOTE.match(line)
|
|
286
|
+
if match:
|
|
287
|
+
flush_paragraph()
|
|
288
|
+
body = _clean_line(match.group(1))
|
|
289
|
+
if body:
|
|
290
|
+
blocks.append({"kind": "quote", "text": body})
|
|
291
|
+
continue
|
|
292
|
+
|
|
293
|
+
if RE_TABLE_ROW.match(line):
|
|
294
|
+
flush_paragraph()
|
|
295
|
+
body = _clean_line(line.strip(" \t|").replace("|", " "))
|
|
296
|
+
if body:
|
|
297
|
+
blocks.append({"kind": "table", "text": body})
|
|
298
|
+
continue
|
|
299
|
+
|
|
300
|
+
paragraph.append(line)
|
|
301
|
+
|
|
302
|
+
flush_paragraph()
|
|
303
|
+
return blocks
|
|
304
|
+
|
|
305
|
+
|
|
306
|
+
def _split_sentences(text):
|
|
307
|
+
if not text:
|
|
308
|
+
return []
|
|
309
|
+
text = text.replace("...", ".")
|
|
310
|
+
protected = RE_ABBREV.sub(
|
|
311
|
+
lambda match: match.group(0).replace(".", SENTINEL),
|
|
312
|
+
text,
|
|
313
|
+
)
|
|
314
|
+
sentences = []
|
|
315
|
+
for part in RE_SENT_SPLIT.split(protected):
|
|
316
|
+
part = part.replace(SENTINEL, ".").strip()
|
|
317
|
+
if part:
|
|
318
|
+
sentences.append(part)
|
|
319
|
+
return sentences
|
|
320
|
+
|
|
321
|
+
|
|
322
|
+
def _token_count(text):
|
|
323
|
+
if not text:
|
|
324
|
+
return 0
|
|
325
|
+
return len(RE_TOKEN.findall(text))
|
|
326
|
+
|
|
327
|
+
|
|
328
|
+
def _sanitize_quote(text, limit=64):
|
|
329
|
+
if not text:
|
|
330
|
+
return ""
|
|
331
|
+
out = text.replace(EM_DASH, " - ").replace(EN_DASH, "-")
|
|
332
|
+
out = RE_DOUBLE_HYPHEN.sub("-", out)
|
|
333
|
+
out = out.replace("\u2018", "'").replace("\u2019", "'")
|
|
334
|
+
out = out.replace("\u201c", "'").replace("\u201d", "'")
|
|
335
|
+
out = out.replace('"', "'").replace("`", "'")
|
|
336
|
+
out = " ".join(out.split())
|
|
337
|
+
if len(out) > limit:
|
|
338
|
+
cut = out[:limit].rstrip()
|
|
339
|
+
if " " in cut:
|
|
340
|
+
cut = cut.rsplit(" ", 1)[0]
|
|
341
|
+
out = cut.rstrip(" -:;,") + "..."
|
|
342
|
+
return out.strip(" \t\r\n")
|
|
343
|
+
|
|
344
|
+
|
|
345
|
+
def _context(text, start, before=30, after=40):
|
|
346
|
+
low = max(0, start - before)
|
|
347
|
+
high = min(len(text), start + after)
|
|
348
|
+
if low > 0:
|
|
349
|
+
space = text.find(" ", low, start)
|
|
350
|
+
if space != -1:
|
|
351
|
+
low = space + 1
|
|
352
|
+
if high < len(text):
|
|
353
|
+
space = text.rfind(" ", start, high)
|
|
354
|
+
if space != -1:
|
|
355
|
+
high = space
|
|
356
|
+
return _sanitize_quote(text[low:high])
|
|
357
|
+
|
|
358
|
+
|
|
359
|
+
def _flag(key, name, count, penalty, hard, quote):
|
|
360
|
+
return {
|
|
361
|
+
"key": key,
|
|
362
|
+
"name": name,
|
|
363
|
+
"count": int(count),
|
|
364
|
+
"penalty": int(penalty),
|
|
365
|
+
"hard": bool(hard),
|
|
366
|
+
"quote": _sanitize_quote(quote),
|
|
367
|
+
}
|
|
368
|
+
|
|
369
|
+
|
|
370
|
+
def analyze(text):
|
|
371
|
+
"""Score one markdown or text document and return a result dictionary."""
|
|
372
|
+
blocks = _parse_blocks(text)
|
|
373
|
+
scan_text = "\n".join(block["text"] for block in blocks)
|
|
374
|
+
flags = []
|
|
375
|
+
|
|
376
|
+
em_hits = list(RE_EM_DASH.finditer(scan_text))
|
|
377
|
+
em_hits.extend(RE_DOUBLE_HYPHEN.finditer(scan_text))
|
|
378
|
+
em_hits.sort(key=lambda match: match.start())
|
|
379
|
+
if em_hits:
|
|
380
|
+
count = len(em_hits)
|
|
381
|
+
flags.append(_flag(
|
|
382
|
+
"em_dash",
|
|
383
|
+
"em dash",
|
|
384
|
+
count,
|
|
385
|
+
18 * min(count, 3),
|
|
386
|
+
True,
|
|
387
|
+
_context(scan_text, em_hits[0].start()),
|
|
388
|
+
))
|
|
389
|
+
|
|
390
|
+
here_units = []
|
|
391
|
+
for block in blocks:
|
|
392
|
+
if block["kind"] == "heading":
|
|
393
|
+
if RE_HERES.match(block["text"]):
|
|
394
|
+
here_units.append(block["text"])
|
|
395
|
+
else:
|
|
396
|
+
for sentence in _split_sentences(block["text"]):
|
|
397
|
+
if RE_HERES.match(sentence):
|
|
398
|
+
here_units.append(sentence)
|
|
399
|
+
if here_units:
|
|
400
|
+
flags.append(_flag(
|
|
401
|
+
"heres_why",
|
|
402
|
+
"here's why opener",
|
|
403
|
+
len(here_units),
|
|
404
|
+
12 * min(len(here_units), 2),
|
|
405
|
+
True,
|
|
406
|
+
here_units[0],
|
|
407
|
+
))
|
|
408
|
+
|
|
409
|
+
contrast_hits = [match.group(0) for match in RE_CONTRAST.finditer(scan_text)]
|
|
410
|
+
if contrast_hits:
|
|
411
|
+
flags.append(_flag(
|
|
412
|
+
"contrast",
|
|
413
|
+
"contrastive negation",
|
|
414
|
+
len(contrast_hits),
|
|
415
|
+
14 * min(len(contrast_hits), 2),
|
|
416
|
+
True,
|
|
417
|
+
contrast_hits[0],
|
|
418
|
+
))
|
|
419
|
+
|
|
420
|
+
arrow_lines = []
|
|
421
|
+
arrow_total = 0
|
|
422
|
+
for block in blocks:
|
|
423
|
+
if block["kind"] == "bullet":
|
|
424
|
+
count = sum(block["text"].count(arrow) for arrow in ARROWS)
|
|
425
|
+
if count:
|
|
426
|
+
arrow_lines.append(block["text"])
|
|
427
|
+
arrow_total += count
|
|
428
|
+
elif block["kind"] in ("prose", "quote"):
|
|
429
|
+
first = block["text"].lstrip()
|
|
430
|
+
if any(first.startswith(arrow) for arrow in ARROWS):
|
|
431
|
+
count = sum(block["text"].count(arrow) for arrow in ARROWS)
|
|
432
|
+
if count:
|
|
433
|
+
arrow_lines.append(block["text"])
|
|
434
|
+
arrow_total += count
|
|
435
|
+
if arrow_total >= 2:
|
|
436
|
+
flags.append(_flag(
|
|
437
|
+
"arrows",
|
|
438
|
+
"arrow cascade",
|
|
439
|
+
arrow_total,
|
|
440
|
+
12 + 4 * min(arrow_total - 2, 2),
|
|
441
|
+
True,
|
|
442
|
+
arrow_lines[0],
|
|
443
|
+
))
|
|
444
|
+
|
|
445
|
+
triplets = []
|
|
446
|
+
index = 0
|
|
447
|
+
while index < len(blocks):
|
|
448
|
+
if blocks[index]["kind"] != "bullet":
|
|
449
|
+
index += 1
|
|
450
|
+
continue
|
|
451
|
+
end = index
|
|
452
|
+
while end < len(blocks) and blocks[end]["kind"] == "bullet":
|
|
453
|
+
end += 1
|
|
454
|
+
group = blocks[index:end]
|
|
455
|
+
if len(group) == 3:
|
|
456
|
+
single_sentence = True
|
|
457
|
+
for item in group:
|
|
458
|
+
if len(_split_sentences(item["text"])) > 1:
|
|
459
|
+
single_sentence = False
|
|
460
|
+
break
|
|
461
|
+
if single_sentence:
|
|
462
|
+
lengths = [_token_count(item["text"]) for item in group]
|
|
463
|
+
if min(lengths) >= 4 and max(lengths) - min(lengths) <= 2:
|
|
464
|
+
triplets.append(lengths)
|
|
465
|
+
index = end
|
|
466
|
+
if triplets:
|
|
467
|
+
lengths = triplets[0]
|
|
468
|
+
flags.append(_flag(
|
|
469
|
+
"triplet",
|
|
470
|
+
"monotonous triplet",
|
|
471
|
+
len(triplets),
|
|
472
|
+
10 * min(len(triplets), 2),
|
|
473
|
+
True,
|
|
474
|
+
"%d, %d, %d words" % (lengths[0], lengths[1], lengths[2]),
|
|
475
|
+
))
|
|
476
|
+
|
|
477
|
+
last_sentence = ""
|
|
478
|
+
for block in reversed(blocks):
|
|
479
|
+
if block["kind"] in ("prose", "bullet", "quote"):
|
|
480
|
+
sentences = _split_sentences(block["text"])
|
|
481
|
+
if sentences:
|
|
482
|
+
last_sentence = sentences[-1].strip()
|
|
483
|
+
break
|
|
484
|
+
if last_sentence.endswith("?") and RE_WEAK_CLOSING.search(last_sentence):
|
|
485
|
+
flags.append(_flag(
|
|
486
|
+
"closing",
|
|
487
|
+
"weak closing question",
|
|
488
|
+
1,
|
|
489
|
+
12,
|
|
490
|
+
True,
|
|
491
|
+
last_sentence,
|
|
492
|
+
))
|
|
493
|
+
|
|
494
|
+
zombie_hits = list(RE_ZOMBIE.finditer(scan_text))
|
|
495
|
+
if zombie_hits:
|
|
496
|
+
examples = []
|
|
497
|
+
seen = set()
|
|
498
|
+
for match in zombie_hits:
|
|
499
|
+
word = match.group(0).strip().lower()
|
|
500
|
+
if word and word not in seen:
|
|
501
|
+
seen.add(word)
|
|
502
|
+
examples.append(word)
|
|
503
|
+
count = len(zombie_hits)
|
|
504
|
+
zombie_flag = _flag(
|
|
505
|
+
"zombie",
|
|
506
|
+
"zombie words",
|
|
507
|
+
count,
|
|
508
|
+
8 * min(count, 4),
|
|
509
|
+
False,
|
|
510
|
+
_context(scan_text, zombie_hits[0].start()),
|
|
511
|
+
)
|
|
512
|
+
zombie_flag["examples"] = examples[:4]
|
|
513
|
+
flags.append(zombie_flag)
|
|
514
|
+
|
|
515
|
+
sentence_lengths = []
|
|
516
|
+
for block in blocks:
|
|
517
|
+
if block["kind"] in ("prose", "bullet", "quote"):
|
|
518
|
+
for sentence in _split_sentences(block["text"]):
|
|
519
|
+
length = _token_count(sentence)
|
|
520
|
+
if length >= 2 and len(sentence_lengths) < MAX_SENTENCES:
|
|
521
|
+
sentence_lengths.append(length)
|
|
522
|
+
sentence_count = len(sentence_lengths)
|
|
523
|
+
cv = None
|
|
524
|
+
if sentence_count >= CV_MIN_SENTENCES:
|
|
525
|
+
mean = sum(sentence_lengths) / float(sentence_count)
|
|
526
|
+
if mean >= CV_MIN_MEAN:
|
|
527
|
+
variance = (
|
|
528
|
+
sum((length - mean) ** 2 for length in sentence_lengths)
|
|
529
|
+
/ float(sentence_count)
|
|
530
|
+
)
|
|
531
|
+
sigma = math.sqrt(variance)
|
|
532
|
+
cv = sigma / mean
|
|
533
|
+
if cv < CV_FLAT:
|
|
534
|
+
flags.append(_flag(
|
|
535
|
+
"flat",
|
|
536
|
+
"flat cadence",
|
|
537
|
+
1,
|
|
538
|
+
20,
|
|
539
|
+
True,
|
|
540
|
+
"CV %.2f across %d sentences" % (cv, sentence_count),
|
|
541
|
+
))
|
|
542
|
+
elif cv < CV_WARN:
|
|
543
|
+
flags.append(_flag(
|
|
544
|
+
"low_burst",
|
|
545
|
+
"low burstiness",
|
|
546
|
+
1,
|
|
547
|
+
14,
|
|
548
|
+
True,
|
|
549
|
+
"CV %.2f across %d sentences" % (cv, sentence_count),
|
|
550
|
+
))
|
|
551
|
+
elif cv < CV_HUMAN:
|
|
552
|
+
flags.append(_flag(
|
|
553
|
+
"borderline",
|
|
554
|
+
"borderline cadence",
|
|
555
|
+
1,
|
|
556
|
+
3,
|
|
557
|
+
False,
|
|
558
|
+
"CV %.2f across %d sentences" % (cv, sentence_count),
|
|
559
|
+
))
|
|
560
|
+
|
|
561
|
+
flags.sort(key=lambda item: (-item["penalty"], FLAG_ORDER.get(item["key"], 99)))
|
|
562
|
+
penalty = sum(item["penalty"] for item in flags)
|
|
563
|
+
score = max(0, min(100, 100 - penalty))
|
|
564
|
+
hard_flags = sum(1 for item in flags if item["hard"])
|
|
565
|
+
return {
|
|
566
|
+
"score": score,
|
|
567
|
+
"cv": cv,
|
|
568
|
+
"sentences": sentence_count,
|
|
569
|
+
"flags": flags,
|
|
570
|
+
"hard_flags": hard_flags,
|
|
571
|
+
"pass": bool(score >= PASS_SCORE and hard_flags == 0),
|
|
572
|
+
"worst": flags[0] if flags else None,
|
|
573
|
+
}
|
|
574
|
+
|
|
575
|
+
|
|
576
|
+
def _clean_source(value):
|
|
577
|
+
if not value:
|
|
578
|
+
return ""
|
|
579
|
+
cleaned = re.sub(r"[^A-Za-z0-9_-]", "", value.strip().lower())
|
|
580
|
+
return cleaned[:32]
|
|
581
|
+
|
|
582
|
+
|
|
583
|
+
def _resolve_source(fallback):
|
|
584
|
+
override = _clean_source(os.environ.get("GRAI_UTM_SOURCE", ""))
|
|
585
|
+
if override:
|
|
586
|
+
return override
|
|
587
|
+
if os.environ.get("GITHUB_ACTIONS"):
|
|
588
|
+
return "github-action"
|
|
589
|
+
if os.environ.get("PRE_COMMIT"):
|
|
590
|
+
return "precommit"
|
|
591
|
+
return fallback
|
|
592
|
+
|
|
593
|
+
|
|
594
|
+
def utm_url(source=None):
|
|
595
|
+
if source is None:
|
|
596
|
+
source = _resolve_source("cli")
|
|
597
|
+
source = _clean_source(source) or "cli"
|
|
598
|
+
return (
|
|
599
|
+
"https://grai.page?utm_source=%s&utm_medium=%s&utm_campaign=%s#developer"
|
|
600
|
+
% (source, UTM_MEDIUM, UTM_CAMPAIGN)
|
|
601
|
+
)
|
|
602
|
+
|
|
603
|
+
|
|
604
|
+
def report_lines(result, source=None):
|
|
605
|
+
"""Return the fixed report card for a failing result. A pass returns []."""
|
|
606
|
+
if result["pass"]:
|
|
607
|
+
return []
|
|
608
|
+
flags = result["flags"]
|
|
609
|
+
cv_text = "n/a" if result["cv"] is None else "%.2f" % result["cv"]
|
|
610
|
+
lines = [
|
|
611
|
+
"[grai-gate] Slop Score: %d/100 (CV: %s | Flags: %d)"
|
|
612
|
+
% (result["score"], cv_text, len(flags))
|
|
613
|
+
]
|
|
614
|
+
|
|
615
|
+
worst = result["worst"]
|
|
616
|
+
if worst:
|
|
617
|
+
if worst["quote"]:
|
|
618
|
+
lines.append(
|
|
619
|
+
'- Worst offender: %s ("%s")' % (worst["name"], worst["quote"])
|
|
620
|
+
)
|
|
621
|
+
else:
|
|
622
|
+
lines.append("- Worst offender: %s" % worst["name"])
|
|
623
|
+
else:
|
|
624
|
+
lines.append("- Worst offender: none")
|
|
625
|
+
|
|
626
|
+
tells = []
|
|
627
|
+
for item in flags:
|
|
628
|
+
label = item["name"]
|
|
629
|
+
if item["count"] > 1:
|
|
630
|
+
label += " (x%d)" % item["count"]
|
|
631
|
+
tells.append(label)
|
|
632
|
+
lines.append("- Tells detected: %s" % (", ".join(tells) if tells else "none"))
|
|
633
|
+
|
|
634
|
+
fix_names = []
|
|
635
|
+
for item in flags:
|
|
636
|
+
if item["name"] not in fix_names:
|
|
637
|
+
fix_names.append(item["name"])
|
|
638
|
+
fix_text = ", ".join(fix_names) if fix_names else "the flagged patterns"
|
|
639
|
+
lines.append(
|
|
640
|
+
'- Fix prompt: "Rewrite to increase sentence burstiness '
|
|
641
|
+
'(target CV > %.2f) and eliminate %s."' % (CV_TARGET, fix_text)
|
|
642
|
+
)
|
|
643
|
+
lines.append(
|
|
644
|
+
"- Pro Cockpit: Full Voice DNA & repair workbench at %s" % utm_url(source)
|
|
645
|
+
)
|
|
646
|
+
return lines
|
|
647
|
+
|
|
648
|
+
|
|
649
|
+
def _json_result(label, result, source=None):
|
|
650
|
+
flags = []
|
|
651
|
+
for item in result["flags"]:
|
|
652
|
+
entry = {
|
|
653
|
+
"key": item["key"],
|
|
654
|
+
"name": item["name"],
|
|
655
|
+
"count": item["count"],
|
|
656
|
+
"penalty": item["penalty"],
|
|
657
|
+
"hard": item["hard"],
|
|
658
|
+
"quote": item["quote"],
|
|
659
|
+
}
|
|
660
|
+
if item.get("examples"):
|
|
661
|
+
entry["examples"] = item["examples"]
|
|
662
|
+
flags.append(entry)
|
|
663
|
+
worst = None
|
|
664
|
+
if result["worst"]:
|
|
665
|
+
worst = {
|
|
666
|
+
"key": result["worst"]["key"],
|
|
667
|
+
"name": result["worst"]["name"],
|
|
668
|
+
"quote": result["worst"]["quote"],
|
|
669
|
+
"penalty": result["worst"]["penalty"],
|
|
670
|
+
}
|
|
671
|
+
return {
|
|
672
|
+
"file": label,
|
|
673
|
+
"score": result["score"],
|
|
674
|
+
"cv": None if result["cv"] is None else round(result["cv"], 4),
|
|
675
|
+
"sentences": result["sentences"],
|
|
676
|
+
"flags": flags,
|
|
677
|
+
"hard_flags": result["hard_flags"],
|
|
678
|
+
"pass": result["pass"],
|
|
679
|
+
"worst": worst,
|
|
680
|
+
"report": report_lines(result, source),
|
|
681
|
+
}
|
|
682
|
+
|
|
683
|
+
|
|
684
|
+
def _clean_summary(label, result):
|
|
685
|
+
cv_text = "n/a" if result["cv"] is None else "%.2f" % result["cv"]
|
|
686
|
+
names = ", ".join(item["name"] for item in result["flags"])
|
|
687
|
+
if names:
|
|
688
|
+
return (
|
|
689
|
+
"[grai-gate] clean: %s (score %d/100, CV %s, soft flags: %s)"
|
|
690
|
+
% (label, result["score"], cv_text, names)
|
|
691
|
+
)
|
|
692
|
+
return (
|
|
693
|
+
"[grai-gate] clean: %s (score %d/100, CV %s, 0 flags)"
|
|
694
|
+
% (label, result["score"], cv_text)
|
|
695
|
+
)
|
|
696
|
+
|
|
697
|
+
|
|
698
|
+
def _read_path(path, workspaces):
|
|
699
|
+
candidates = [path]
|
|
700
|
+
if not _is_absolute(path) and isinstance(workspaces, list):
|
|
701
|
+
for workspace in workspaces:
|
|
702
|
+
if isinstance(workspace, str) and workspace:
|
|
703
|
+
candidates.append(os.path.join(workspace, path))
|
|
704
|
+
for candidate in candidates:
|
|
705
|
+
try:
|
|
706
|
+
with open(candidate, "r", encoding="utf-8", errors="replace") as handle:
|
|
707
|
+
data = handle.read(MAX_CHARS + 1)
|
|
708
|
+
except (OSError, ValueError, UnicodeError):
|
|
709
|
+
continue
|
|
710
|
+
if len(data) > MAX_CHARS:
|
|
711
|
+
return None
|
|
712
|
+
return data
|
|
713
|
+
return None
|
|
714
|
+
|
|
715
|
+
|
|
716
|
+
def _is_absolute(path):
|
|
717
|
+
if os.path.isabs(path):
|
|
718
|
+
return True
|
|
719
|
+
if path.startswith("/") or path.startswith("\\\\"):
|
|
720
|
+
return True
|
|
721
|
+
if len(path) >= 3 and path[1] == ":" and path[2] in ("/", "\\"):
|
|
722
|
+
return True
|
|
723
|
+
return False
|
|
724
|
+
|
|
725
|
+
|
|
726
|
+
def _normalize_path(path):
|
|
727
|
+
if path.startswith("file://"):
|
|
728
|
+
path = path[7:]
|
|
729
|
+
if len(path) >= 3 and path[0] == "/" and path[1].isalpha() and path[2] == ":":
|
|
730
|
+
path = path[1:]
|
|
731
|
+
return os.path.normpath(path)
|
|
732
|
+
|
|
733
|
+
|
|
734
|
+
def _flatten_args(args):
|
|
735
|
+
flat = {}
|
|
736
|
+
for key, value in args.items():
|
|
737
|
+
if isinstance(key, str):
|
|
738
|
+
flat[re.sub(r"[^a-z0-9]", "", key.lower())] = value
|
|
739
|
+
return flat
|
|
740
|
+
|
|
741
|
+
|
|
742
|
+
def _first_string(args, keys):
|
|
743
|
+
for key in keys:
|
|
744
|
+
value = args.get(key)
|
|
745
|
+
if isinstance(value, str) and value.strip():
|
|
746
|
+
return value.strip()
|
|
747
|
+
return None
|
|
748
|
+
|
|
749
|
+
|
|
750
|
+
def _run_hook(payload):
|
|
751
|
+
try:
|
|
752
|
+
if payload.get("error"):
|
|
753
|
+
return
|
|
754
|
+
tool_call = payload.get("toolCall") or payload.get("tool_call") or {}
|
|
755
|
+
if not isinstance(tool_call, dict):
|
|
756
|
+
return
|
|
757
|
+
raw_args = tool_call.get("args") or tool_call.get("arguments") or {}
|
|
758
|
+
if not isinstance(raw_args, dict):
|
|
759
|
+
return
|
|
760
|
+
args = _flatten_args(raw_args)
|
|
761
|
+
path = _first_string(
|
|
762
|
+
args,
|
|
763
|
+
("targetfile", "path", "filepath", "absolutepath", "filename", "file"),
|
|
764
|
+
)
|
|
765
|
+
if not path:
|
|
766
|
+
return
|
|
767
|
+
path = _normalize_path(path)
|
|
768
|
+
if not path.lower().endswith(TARGET_SUFFIXES):
|
|
769
|
+
return
|
|
770
|
+
|
|
771
|
+
text = _read_path(path, payload.get("workspacePaths"))
|
|
772
|
+
if text is None:
|
|
773
|
+
inline = _first_string(args, ("codecontent", "content"))
|
|
774
|
+
if inline is not None:
|
|
775
|
+
text = inline
|
|
776
|
+
if text is None:
|
|
777
|
+
return
|
|
778
|
+
if len(text) > MAX_CHARS or "\x00" in text:
|
|
779
|
+
return
|
|
780
|
+
|
|
781
|
+
result = analyze(text)
|
|
782
|
+
if not result["pass"]:
|
|
783
|
+
source = _resolve_source(UTM_SOURCE)
|
|
784
|
+
for line in report_lines(result, source):
|
|
785
|
+
sys.stderr.write(line + "\n")
|
|
786
|
+
sys.stderr.flush()
|
|
787
|
+
except Exception:
|
|
788
|
+
pass
|
|
789
|
+
finally:
|
|
790
|
+
sys.stdout.write("{}\n")
|
|
791
|
+
sys.stdout.flush()
|
|
792
|
+
|
|
793
|
+
|
|
794
|
+
def _run_files(paths, json_mode, verbose):
|
|
795
|
+
records = []
|
|
796
|
+
errors = 0
|
|
797
|
+
any_fail = False
|
|
798
|
+
source = _resolve_source("cli")
|
|
799
|
+
|
|
800
|
+
for path in paths:
|
|
801
|
+
try:
|
|
802
|
+
with open(path, "r", encoding="utf-8", errors="replace") as handle:
|
|
803
|
+
data = handle.read(MAX_CHARS + 1)
|
|
804
|
+
except (OSError, ValueError, UnicodeError) as exc:
|
|
805
|
+
sys.stderr.write(
|
|
806
|
+
"[grai-gate] error: cannot read %s: %s\n" % (path, exc)
|
|
807
|
+
)
|
|
808
|
+
errors += 1
|
|
809
|
+
continue
|
|
810
|
+
if len(data) > MAX_CHARS:
|
|
811
|
+
sys.stderr.write(
|
|
812
|
+
"[grai-gate] error: %s exceeds the %d character limit\n"
|
|
813
|
+
% (path, MAX_CHARS)
|
|
814
|
+
)
|
|
815
|
+
errors += 1
|
|
816
|
+
continue
|
|
817
|
+
if "\x00" in data:
|
|
818
|
+
sys.stderr.write("[grai-gate] error: %s looks binary\n" % path)
|
|
819
|
+
errors += 1
|
|
820
|
+
continue
|
|
821
|
+
result = analyze(data)
|
|
822
|
+
records.append((path, result))
|
|
823
|
+
if not result["pass"]:
|
|
824
|
+
any_fail = True
|
|
825
|
+
|
|
826
|
+
if json_mode:
|
|
827
|
+
values = [_json_result(path, result, source) for path, result in records]
|
|
828
|
+
if len(paths) == 1 and len(values) == 1:
|
|
829
|
+
output = values[0]
|
|
830
|
+
else:
|
|
831
|
+
output = values
|
|
832
|
+
sys.stdout.write(json.dumps(output, ensure_ascii=True) + "\n")
|
|
833
|
+
else:
|
|
834
|
+
printed = False
|
|
835
|
+
for path, result in records:
|
|
836
|
+
if result["pass"]:
|
|
837
|
+
if verbose:
|
|
838
|
+
sys.stdout.write(_clean_summary(path, result) + "\n")
|
|
839
|
+
continue
|
|
840
|
+
if printed:
|
|
841
|
+
sys.stdout.write("\n")
|
|
842
|
+
if len(paths) > 1:
|
|
843
|
+
sys.stdout.write("File: %s\n" % path)
|
|
844
|
+
for line in report_lines(result, source):
|
|
845
|
+
sys.stdout.write(line + "\n")
|
|
846
|
+
printed = True
|
|
847
|
+
|
|
848
|
+
if errors:
|
|
849
|
+
return 2
|
|
850
|
+
if any_fail:
|
|
851
|
+
return 1
|
|
852
|
+
return 0
|
|
853
|
+
|
|
854
|
+
|
|
855
|
+
def _run_text(text, label, json_mode, verbose):
|
|
856
|
+
if len(text) > MAX_CHARS:
|
|
857
|
+
sys.stderr.write(
|
|
858
|
+
"[grai-gate] error: %s exceeds the %d character limit\n"
|
|
859
|
+
% (label, MAX_CHARS)
|
|
860
|
+
)
|
|
861
|
+
return 2
|
|
862
|
+
if "\x00" in text:
|
|
863
|
+
sys.stderr.write("[grai-gate] error: %s looks binary\n" % label)
|
|
864
|
+
return 2
|
|
865
|
+
result = analyze(text)
|
|
866
|
+
source = _resolve_source("cli")
|
|
867
|
+
if json_mode:
|
|
868
|
+
sys.stdout.write(
|
|
869
|
+
json.dumps(_json_result(label, result, source), ensure_ascii=True) + "\n"
|
|
870
|
+
)
|
|
871
|
+
elif not result["pass"]:
|
|
872
|
+
for line in report_lines(result, source):
|
|
873
|
+
sys.stdout.write(line + "\n")
|
|
874
|
+
elif verbose:
|
|
875
|
+
sys.stdout.write(_clean_summary(label, result) + "\n")
|
|
876
|
+
return 0 if result["pass"] else 1
|
|
877
|
+
|
|
878
|
+
|
|
879
|
+
USAGE = """grai-gate %s: advisory anti-slop linter for markdown and text.
|
|
880
|
+
|
|
881
|
+
Usage:
|
|
882
|
+
python3 gate.py FILE [FILE ...]
|
|
883
|
+
python3 gate.py -j FILE [FILE ...]
|
|
884
|
+
python3 gate.py -v FILE
|
|
885
|
+
cat draft.md | python3 gate.py
|
|
886
|
+
|
|
887
|
+
Options:
|
|
888
|
+
-j JSON output. One object for one file, an array for several.
|
|
889
|
+
-v Verbose. Print a clean line when a file passes.
|
|
890
|
+
-h Show this help.
|
|
891
|
+
|
|
892
|
+
Clean files are silent by default. The hook always exits 0 and prints {}.
|
|
893
|
+
Exit codes for the command line: 0 clean, 1 slop found, 2 input error.
|
|
894
|
+
""" % VERSION
|
|
895
|
+
|
|
896
|
+
|
|
897
|
+
def run_cli(argv):
|
|
898
|
+
paths = []
|
|
899
|
+
json_mode = False
|
|
900
|
+
verbose = False
|
|
901
|
+
|
|
902
|
+
for argument in argv:
|
|
903
|
+
if argument == "-h":
|
|
904
|
+
sys.stdout.write(USAGE)
|
|
905
|
+
return 0
|
|
906
|
+
if argument == "-j":
|
|
907
|
+
json_mode = True
|
|
908
|
+
continue
|
|
909
|
+
if argument == "-v":
|
|
910
|
+
verbose = True
|
|
911
|
+
continue
|
|
912
|
+
if argument.startswith("-") and argument != "-":
|
|
913
|
+
sys.stderr.write(
|
|
914
|
+
"[grai-gate] error: unknown option %s\n" % argument
|
|
915
|
+
)
|
|
916
|
+
sys.stderr.write(USAGE)
|
|
917
|
+
return 2
|
|
918
|
+
paths.append(argument)
|
|
919
|
+
|
|
920
|
+
if paths:
|
|
921
|
+
return _run_files(paths, json_mode, verbose)
|
|
922
|
+
|
|
923
|
+
if sys.stdin.isatty():
|
|
924
|
+
sys.stderr.write(USAGE)
|
|
925
|
+
return 2
|
|
926
|
+
|
|
927
|
+
raw = sys.stdin.read()
|
|
928
|
+
if not raw.strip():
|
|
929
|
+
sys.stdout.write("{}\n")
|
|
930
|
+
return 0
|
|
931
|
+
|
|
932
|
+
stripped = raw.lstrip()
|
|
933
|
+
if stripped.startswith("{") or stripped.startswith("["):
|
|
934
|
+
try:
|
|
935
|
+
payload = json.loads(raw)
|
|
936
|
+
except ValueError:
|
|
937
|
+
sys.stdout.write("{}\n")
|
|
938
|
+
return 0
|
|
939
|
+
if isinstance(payload, dict):
|
|
940
|
+
tool_call = payload.get("toolCall") or payload.get("tool_call")
|
|
941
|
+
if isinstance(tool_call, dict):
|
|
942
|
+
_run_hook(payload)
|
|
943
|
+
return 0
|
|
944
|
+
sys.stdout.write("{}\n")
|
|
945
|
+
return 0
|
|
946
|
+
|
|
947
|
+
return _run_text(raw, "stdin", json_mode, verbose)
|
|
948
|
+
|
|
949
|
+
|
|
950
|
+
def main():
|
|
951
|
+
_configure_streams()
|
|
952
|
+
try:
|
|
953
|
+
return run_cli(sys.argv[1:])
|
|
954
|
+
except BrokenPipeError:
|
|
955
|
+
return 0
|
|
956
|
+
except KeyboardInterrupt:
|
|
957
|
+
return 130
|
|
958
|
+
except Exception as exc:
|
|
959
|
+
sys.stderr.write("[grai-gate] fatal: %s\n" % exc)
|
|
960
|
+
return 2
|
|
961
|
+
|
|
962
|
+
|
|
963
|
+
if __name__ == "__main__":
|
|
964
|
+
sys.exit(main())
|
|
@@ -0,0 +1,86 @@
|
|
|
1
|
+
Metadata-Version: 2.4
|
|
2
|
+
Name: grai-gate
|
|
3
|
+
Version: 0.1.3
|
|
4
|
+
Summary: Deterministic anti-slop linter for markdown written by coding agents
|
|
5
|
+
Author-email: Adrian Peticila <adrian@grai.page>
|
|
6
|
+
License: MIT
|
|
7
|
+
Project-URL: Homepage, https://grai.page?utm_source=pypi&utm_medium=gate&utm_campaign=gate_v0.1#developer
|
|
8
|
+
Project-URL: Repository, https://github.com/adrianpeticila/grai-gate-action
|
|
9
|
+
Project-URL: Issues, https://github.com/adrianpeticila/grai-gate-action/issues
|
|
10
|
+
Keywords: markdown,linter,ai-slop,writing,cadence
|
|
11
|
+
Classifier: Programming Language :: Python :: 3
|
|
12
|
+
Classifier: Topic :: Software Development :: Quality Assurance
|
|
13
|
+
Classifier: Topic :: Text Processing :: Markup :: Markdown
|
|
14
|
+
Requires-Python: >=3.8
|
|
15
|
+
Description-Content-Type: text/markdown
|
|
16
|
+
License-File: LICENSE
|
|
17
|
+
Dynamic: license-file
|
|
18
|
+
|
|
19
|
+
# grai-gate
|
|
20
|
+
|
|
21
|
+
Deterministic anti-slop gate for markdown written by coding agents. One Python
|
|
22
|
+
engine, no model calls, no network. It scores sentence burstiness and flags the
|
|
23
|
+
structural habits that survive spellcheck.
|
|
24
|
+
|
|
25
|
+
## GitHub Action
|
|
26
|
+
|
|
27
|
+
```yaml
|
|
28
|
+
name: cadence
|
|
29
|
+
on:
|
|
30
|
+
pull_request:
|
|
31
|
+
paths: ['**.md', '**.mdx', '**.txt']
|
|
32
|
+
jobs:
|
|
33
|
+
gate:
|
|
34
|
+
runs-on: ubuntu-latest
|
|
35
|
+
steps:
|
|
36
|
+
- uses: actions/checkout@v4
|
|
37
|
+
- uses: adrianpeticila/grai-gate-action@v0.1.3
|
|
38
|
+
with:
|
|
39
|
+
fail-on-slop: "false"
|
|
40
|
+
```
|
|
41
|
+
|
|
42
|
+
The action scores only files changed in the pull request. It fetches the base
|
|
43
|
+
ref itself, so a shallow checkout is fine. The default is advisory: the card
|
|
44
|
+
lands in the log and the job stays green. Set `fail-on-slop: "true"` to block.
|
|
45
|
+
|
|
46
|
+
Inputs: `path` (scan target when no pull request base is available),
|
|
47
|
+
`fail-on-slop`, `verbose`.
|
|
48
|
+
|
|
49
|
+
## PyPI
|
|
50
|
+
|
|
51
|
+
```bash
|
|
52
|
+
pipx run grai-gate README.md
|
|
53
|
+
```
|
|
54
|
+
|
|
55
|
+
or
|
|
56
|
+
|
|
57
|
+
```bash
|
|
58
|
+
pip install grai-gate
|
|
59
|
+
grai-gate docs/
|
|
60
|
+
```
|
|
61
|
+
|
|
62
|
+
Exit codes: 0 clean, 1 slop, 2 input error. The CLI accepts `-h` and `--help`.
|
|
63
|
+
|
|
64
|
+
## pre-commit
|
|
65
|
+
|
|
66
|
+
```yaml
|
|
67
|
+
repos:
|
|
68
|
+
- repo: https://github.com/adrianpeticila/grai-gate-action
|
|
69
|
+
rev: v0.1.3
|
|
70
|
+
hooks:
|
|
71
|
+
- id: grai-gate
|
|
72
|
+
```
|
|
73
|
+
|
|
74
|
+
The hook scores markdown, mdx, and plain text. It exits 1 on slop and stops the
|
|
75
|
+
commit. Skip one commit with `SKIP=grai-gate git commit`.
|
|
76
|
+
|
|
77
|
+
## One engine
|
|
78
|
+
|
|
79
|
+
`src/grai_gate/gate.py` is vendored byte-for-byte from the Antigravity plugin
|
|
80
|
+
in the grai repo. Run `python scripts/sync_engine.py --check` to prove the
|
|
81
|
+
copies match; it exits 1 on drift.
|
|
82
|
+
|
|
83
|
+
## UTM attribution
|
|
84
|
+
|
|
85
|
+
The report card links to the Pro Cockpit. Attribution resolves from
|
|
86
|
+
`GRAI_UTM_SOURCE`, then `GITHUB_ACTIONS`, then `PRE_COMMIT`, then `cli`.
|
|
@@ -0,0 +1,9 @@
|
|
|
1
|
+
grai_gate/__init__.py,sha256=XEqb2aiIn8fzGE68Mph4ck1FtQqsR_am0wRWvrYPffQ,22
|
|
2
|
+
grai_gate/cli.py,sha256=nK9vOPKV1UqWSdiVgUJUsOllWO4ccx4E7Ovc-z-gZOQ,503
|
|
3
|
+
grai_gate/gate.py,sha256=vNbaowReTrZJEaFoNqZ85-BO27rBDLlAe_2CLE8dou0,29443
|
|
4
|
+
grai_gate-0.1.3.dist-info/licenses/LICENSE,sha256=OqTFWZj5noTxrNqVNBC-VO_ZIZea3qtycP9QnPtCFZ0,1150
|
|
5
|
+
grai_gate-0.1.3.dist-info/METADATA,sha256=nTxqY6z4LEUMpB1S1RMXmr9ZPFXLZDKI7O_nHZMQjBY,2456
|
|
6
|
+
grai_gate-0.1.3.dist-info/WHEEL,sha256=mOdXR9C3wMVov7cSY8PY-XCzo3SmmWCHvGQr5csJoao,91
|
|
7
|
+
grai_gate-0.1.3.dist-info/entry_points.txt,sha256=NZPhtqE61FwRAfjfJDHmNA_04efOsju_YL-UYf3fXdQ,49
|
|
8
|
+
grai_gate-0.1.3.dist-info/top_level.txt,sha256=6fUPhDwSS0zIJzxhvudiJXZC3wxTY44u09gqFcqRiCw,10
|
|
9
|
+
grai_gate-0.1.3.dist-info/RECORD,,
|
|
@@ -0,0 +1,24 @@
|
|
|
1
|
+
MIT License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2026 grai
|
|
4
|
+
|
|
5
|
+
Permission is hereby granted, free of charge, to any person obtaining a copy
|
|
6
|
+
of this software and associated documentation files (the "Software"), to deal
|
|
7
|
+
in the Software without restriction, including without limitation the rights
|
|
8
|
+
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
|
9
|
+
copies of the Software, and to permit persons to whom the Software is
|
|
10
|
+
furnished to do so, subject to the following conditions:
|
|
11
|
+
|
|
12
|
+
The above copyright notice and this permission notice shall be included in all
|
|
13
|
+
copies or substantial portions of the Software.
|
|
14
|
+
|
|
15
|
+
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
|
16
|
+
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
|
17
|
+
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
|
18
|
+
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
|
19
|
+
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
|
20
|
+
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
|
21
|
+
SOFTWARE.
|
|
22
|
+
|
|
23
|
+
The paid cockpit at grai.page is a separate product and is not covered by
|
|
24
|
+
this license.
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
grai_gate
|