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 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,5 @@
1
+ Wheel-Version: 1.0
2
+ Generator: setuptools (84.2.1)
3
+ Root-Is-Purelib: true
4
+ Tag: py3-none-any
5
+
@@ -0,0 +1,2 @@
1
+ [console_scripts]
2
+ grai-gate = grai_gate.cli:main
@@ -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