gator-command 1.0.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 (110) hide show
  1. gator_command/__init__.py +2 -0
  2. gator_command/cli.py +137 -0
  3. gator_command/scripts/crawler.py +633 -0
  4. gator_command/scripts/dashboard/dashboard.css +982 -0
  5. gator_command/scripts/dashboard/dashboard.html +84 -0
  6. gator_command/scripts/dashboard/dashboard.js +419 -0
  7. gator_command/scripts/dashboard/views/audit.js +270 -0
  8. gator_command/scripts/dashboard/views/fleet.js +307 -0
  9. gator_command/scripts/dashboard/views/repo.js +599 -0
  10. gator_command/scripts/dashboard/views/settings.js +173 -0
  11. gator_command/scripts/dashboard/views/updates.js +308 -0
  12. gator_command/scripts/enforcer-prompt.md +22 -0
  13. gator_command/scripts/extract-claude-sessions.py +489 -0
  14. gator_command/scripts/extract-codex-sessions.py +477 -0
  15. gator_command/scripts/extract-gemini-sessions.py +410 -0
  16. gator_command/scripts/gator-audit.py +956 -0
  17. gator_command/scripts/gator-charter-draft.py +919 -0
  18. gator_command/scripts/gator-charter-lint.py +427 -0
  19. gator_command/scripts/gator-charter-verify.py +606 -0
  20. gator_command/scripts/gator-dashboard.py +1271 -0
  21. gator_command/scripts/gator-deploy.py +916 -0
  22. gator_command/scripts/gator-drift.py +569 -0
  23. gator_command/scripts/gator-enforce.py +82 -0
  24. gator_command/scripts/gator-fleet-intel.py +460 -0
  25. gator_command/scripts/gator-fleet-report.py +615 -0
  26. gator_command/scripts/gator-init-command-post.py +315 -0
  27. gator_command/scripts/gator-init.py +434 -0
  28. gator_command/scripts/gator-machine-id.py +153 -0
  29. gator_command/scripts/gator-policy-status.py +631 -0
  30. gator_command/scripts/gator-pulse.py +459 -0
  31. gator_command/scripts/gator-repo-status.py +649 -0
  32. gator_command/scripts/gator-session-common.py +372 -0
  33. gator_command/scripts/gator-session-sink.py +831 -0
  34. gator_command/scripts/gator-sessions.py +1244 -0
  35. gator_command/scripts/gator-update.py +615 -0
  36. gator_command/scripts/gator-version.py +38 -0
  37. gator_command/scripts/gator_core.py +489 -0
  38. gator_command/scripts/gator_remote.py +381 -0
  39. gator_command/scripts/gator_runtime.py +142 -0
  40. gator_command/scripts/gatorize-actions.sh +989 -0
  41. gator_command/scripts/gatorize-lib.sh +166 -0
  42. gator_command/scripts/gatorize-post.sh +394 -0
  43. gator_command/scripts/gatorize.py +1163 -0
  44. gator_command/scripts/gatorize.sh +185 -0
  45. gator_command/scripts/generate_markdown.py +212 -0
  46. gator_command/scripts/generate_wiki.py +424 -0
  47. gator_command/scripts/graph_health.py +780 -0
  48. gator_command/scripts/memex-lint.py +286 -0
  49. gator_command/scripts/memex-lint.sh +205 -0
  50. gator_command/scripts/memex.py +1472 -0
  51. gator_command/scripts/memex_formatters.py +191 -0
  52. gator_command/scripts/memex_state.py +236 -0
  53. gator_command/scripts/spawn.py +650 -0
  54. gator_command/templates/gator-starter/blueprints/README.md +32 -0
  55. gator_command/templates/gator-starter/charterignore +53 -0
  56. gator_command/templates/gator-starter/charters/README.md +178 -0
  57. gator_command/templates/gator-starter/charters/_template.md +31 -0
  58. gator_command/templates/gator-starter/commands/commit.md +33 -0
  59. gator_command/templates/gator-starter/commands/init.md +11 -0
  60. gator_command/templates/gator-starter/commands/update.md +5 -0
  61. gator_command/templates/gator-starter/constitution.md +165 -0
  62. gator_command/templates/gator-starter/field-guides/README.md +25 -0
  63. gator_command/templates/gator-starter/gator-start-up.md +119 -0
  64. gator_command/templates/gator-starter/procedures/charter-alignment.md +83 -0
  65. gator_command/templates/gator-starter/procedures/enforcer-review.md +317 -0
  66. gator_command/templates/gator-starter/procedures/field-guide-generation.md +176 -0
  67. gator_command/templates/gator-starter/procedures/knowledge-capture.md +57 -0
  68. gator_command/templates/gator-starter/procedures/significance-check.md +69 -0
  69. gator_command/templates/gator-starter/reference-notes/concierge-responses.md +535 -0
  70. gator_command/templates/gator-starter/reference-notes/dangerous-patterns.md +91 -0
  71. gator_command/templates/gator-starter/reference-notes/dashboard-operations.md +22 -0
  72. gator_command/templates/gator-starter/reference-notes/enforcer-configuration.md +232 -0
  73. gator_command/templates/gator-starter/reference-notes/example-project.md +289 -0
  74. gator_command/templates/gator-starter/reference-notes/failure-modes-and-self-correction.md +72 -0
  75. gator_command/templates/gator-starter/reference-notes/git-workflow.md +60 -0
  76. gator_command/templates/gator-starter/reference-notes/identity-and-ownership.md +37 -0
  77. gator_command/templates/gator-starter/reference-notes/refactor-approach.md +155 -0
  78. gator_command/templates/gator-starter/reference-notes/what-gator-requires-from-a-model.md +108 -0
  79. gator_command/templates/gator-starter/reference-notes/why-navigation-coding-feels-different.md +99 -0
  80. gator_command/templates/gator-starter/reference-notes/workflow-profiles.md +155 -0
  81. gator_command/templates/gator-starter/scripts/__pycache__/enforcer-review.cpython-313.pyc +0 -0
  82. gator_command/templates/gator-starter/scripts/__pycache__/gator-approve.cpython-313.pyc +0 -0
  83. gator_command/templates/gator-starter/scripts/__pycache__/gator-init.cpython-313.pyc +0 -0
  84. gator_command/templates/gator-starter/scripts/__pycache__/gator-pre-commit.cpython-313.pyc +0 -0
  85. gator_command/templates/gator-starter/scripts/__pycache__/gator-update.cpython-313.pyc +0 -0
  86. gator_command/templates/gator-starter/scripts/enforcer-prompt.md +55 -0
  87. gator_command/templates/gator-starter/scripts/enforcer-review.py +1551 -0
  88. gator_command/templates/gator-starter/scripts/gator-approve.py +139 -0
  89. gator_command/templates/gator-starter/scripts/gator-enforce.py +82 -0
  90. gator_command/templates/gator-starter/scripts/gator-init.py +434 -0
  91. gator_command/templates/gator-starter/scripts/gator-pre-commit.py +2670 -0
  92. gator_command/templates/gator-starter/scripts/gator-pulse.py +459 -0
  93. gator_command/templates/gator-starter/scripts/gator-update.py +615 -0
  94. gator_command/templates/gator-starter/scripts/gator-version.py +38 -0
  95. gator_command/templates/gator-starter/scripts/gator_core.py +487 -0
  96. gator_command/templates/gator-starter/scripts/hooks/__pycache__/commit-msgcpython-313.pyc +0 -0
  97. gator_command/templates/gator-starter/scripts/hooks/__pycache__/post-commitcpython-313.pyc +0 -0
  98. gator_command/templates/gator-starter/scripts/hooks/__pycache__/pre-commitcpython-313.pyc +0 -0
  99. gator_command/templates/gator-starter/scripts/hooks/commit-msg +5 -0
  100. gator_command/templates/gator-starter/scripts/hooks/post-commit +7 -0
  101. gator_command/templates/gator-starter/scripts/hooks/pre-commit +5 -0
  102. gator_command/templates/gator-starter/sessions/.gitignore +7 -0
  103. gator_command/templates/gator-starter/vault/.gitkeep +0 -0
  104. gator_command/templates/gator-starter/whiteboard.md +5 -0
  105. gator_command-1.0.0.dist-info/METADATA +122 -0
  106. gator_command-1.0.0.dist-info/RECORD +110 -0
  107. gator_command-1.0.0.dist-info/WHEEL +5 -0
  108. gator_command-1.0.0.dist-info/entry_points.txt +2 -0
  109. gator_command-1.0.0.dist-info/licenses/LICENSE +21 -0
  110. gator_command-1.0.0.dist-info/top_level.txt +1 -0
@@ -0,0 +1,1551 @@
1
+ #!/usr/bin/env python3
2
+ """
3
+ Enforcer Review — charter-grounded linter for Gator projects.
4
+
5
+ Runs in three layers:
6
+ Layer 1: Mechanical lint (no model, instant, always works)
7
+ Layer 2: Charter-grounded review (model reads charters + diff)
8
+ Layer 3: Blast radius check (model reads cross-cutting + diff)
9
+
10
+ Configuration: .gator/scripts/enforcer-config.json
11
+ - Set provider/model/api_key_env for Layer 2-3
12
+ - Supports: anthropic, openai, google, ollama, none
13
+ - Layer 1 always runs regardless of configuration
14
+
15
+ Usage:
16
+ python .gator/scripts/enforcer-review.py # review all modified files
17
+ python .gator/scripts/enforcer-review.py --files "a.py,b.py" # review specific files
18
+ python .gator/scripts/enforcer-review.py --staged # review git staged changes
19
+ python .gator/scripts/enforcer-review.py --layer 1 # mechanical lint only (no model)
20
+ python .gator/scripts/enforcer-review.py --format json # JSON output
21
+
22
+ Output goes to stdout and .gator/whiteboard.md (by default).
23
+ The whiteboard is the authoritative PI-visible record. Use --no-whiteboard
24
+ to suppress whiteboard writes (e.g., CI/CD pipelines that parse stdout).
25
+ """
26
+
27
+ import argparse
28
+ import os
29
+ import re
30
+ import subprocess
31
+ import sys
32
+ import json
33
+
34
+ SCRIPT_DIR = os.path.dirname(os.path.abspath(__file__))
35
+ sys.path.insert(0, SCRIPT_DIR)
36
+ from gator_core import ensure_utf8_stdout, find_gator_root, resolve_thin_link
37
+ CONFIG_PATH = os.path.join(SCRIPT_DIR, "enforcer-config.json")
38
+ MODEL_TIMEOUT_SECONDS = 120
39
+
40
+ # Repo root: .gator/scripts/ → .gator/ → repo root
41
+ _REPO_ROOT = os.path.dirname(os.path.dirname(SCRIPT_DIR))
42
+
43
+
44
+ def _deep_merge(defaults, override):
45
+ """Recursively merge config dicts so partial nested overrides are safe."""
46
+ merged = dict(defaults)
47
+ for key, value in override.items():
48
+ if (
49
+ key in merged
50
+ and isinstance(merged[key], dict)
51
+ and isinstance(value, dict)
52
+ ):
53
+ merged[key] = _deep_merge(merged[key], value)
54
+ else:
55
+ merged[key] = value
56
+ return merged
57
+
58
+
59
+ def _normalize_path(path):
60
+ """Normalize paths for reliable equality checks across cwd styles."""
61
+ return os.path.normcase(os.path.abspath(path))
62
+
63
+
64
+ def load_config():
65
+ """Load enforcer configuration. Returns defaults if file missing."""
66
+ defaults = {
67
+ "layer1": {"enabled": True},
68
+ "layer2_3": {
69
+ "enabled": True,
70
+ "provider": "anthropic",
71
+ "model": "claude-sonnet-4-6",
72
+ "api_key_env": "ANTHROPIC_API_KEY",
73
+ },
74
+ }
75
+ if not os.path.isfile(CONFIG_PATH):
76
+ return defaults
77
+ try:
78
+ with open(CONFIG_PATH, encoding="utf-8") as f:
79
+ cfg = json.load(f)
80
+ return _deep_merge(defaults, cfg)
81
+ except Exception:
82
+ return defaults
83
+
84
+
85
+ # ─── Layer 1: Mechanical Lint (no model, instant) ───────────────────────
86
+
87
+ LAYER1_RULES = [
88
+ # Secrets & credentials
89
+ {
90
+ "id": "SEC-001",
91
+ "name": "Hardcoded password",
92
+ "pattern": r"""(?i)(password|passwd|pwd)\s*=\s*['\"][^'\"]+['\"]""",
93
+ "severity": "HIGH",
94
+ "message": "Possible hardcoded password. Use environment variables or a secrets manager.",
95
+ "exclude_extensions": [".md", ".txt", ".rst"],
96
+ },
97
+ {
98
+ "id": "SEC-002",
99
+ "name": "API key in source",
100
+ "pattern": r"""(?i)(api[_-]?key|secret[_-]?key|access[_-]?token)\s*=\s*['\"][A-Za-z0-9+/=_-]{16,}['\"]""",
101
+ "severity": "HIGH",
102
+ "message": "Possible API key or secret in source code.",
103
+ "exclude_extensions": [".md", ".txt", ".rst"],
104
+ },
105
+ {
106
+ "id": "SEC-003",
107
+ "name": "Private key material",
108
+ "pattern": r"""-----BEGIN\s+(RSA\s+)?PRIVATE\s+KEY-----""",
109
+ "severity": "CRITICAL",
110
+ "message": "Private key material in source file.",
111
+ "exclude_extensions": [],
112
+ # Downgrade to warning in documentation directories — these contain example text
113
+ "doc_paths": ["reference-notes/", "procedures/", "docs/"],
114
+ # Outside doc paths: require base64-looking key body within context_window lines
115
+ # A lone PEM header without key body is almost always documentation or a stub
116
+ "context_required": r"""[A-Za-z0-9+/]{40,}={0,2}\s*$""",
117
+ "context_window": 10,
118
+ },
119
+ # SQL dangers
120
+ {
121
+ "id": "SQL-001",
122
+ "name": "DROP TABLE",
123
+ "pattern": r"""(?i)\bDROP\s+TABLE\b""",
124
+ "severity": "HIGH",
125
+ "message": "DROP TABLE detected. Verify this is intentional and scoped correctly.",
126
+ "exclude_extensions": [".md"],
127
+ },
128
+ {
129
+ "id": "SQL-002",
130
+ "name": "DELETE without WHERE",
131
+ "pattern": r"""(?i)\bDELETE\s+FROM\s+\w+\s*(?:;|\n|$)""",
132
+ "severity": "HIGH",
133
+ "message": "DELETE FROM without WHERE clause — this deletes all rows.",
134
+ "exclude_extensions": [".md"],
135
+ },
136
+ {
137
+ "id": "SQL-003",
138
+ "name": "TRUNCATE",
139
+ "pattern": r"""(?i)\bTRUNCATE\s+(?:TABLE\s+)?\w+""",
140
+ "severity": "HIGH",
141
+ "message": "TRUNCATE detected. Irreversible bulk deletion.",
142
+ "exclude_extensions": [".md"],
143
+ },
144
+ {
145
+ "id": "SQL-004",
146
+ "name": "SQL string concatenation",
147
+ "pattern": r"""(?:execute|cursor\.execute|query)\s*\(.*(?:\+|%\s|\.format|f['\"])""",
148
+ "severity": "MEDIUM",
149
+ "message": "Possible SQL injection — string concatenation in query. Use parameterized queries.",
150
+ "exclude_extensions": [".md"],
151
+ },
152
+ # Code safety
153
+ {
154
+ "id": "CODE-001",
155
+ "name": "eval() usage",
156
+ "pattern": r"""\beval\s*\(""",
157
+ "severity": "MEDIUM",
158
+ "message": "eval() executes arbitrary code. Verify input is trusted.",
159
+ "exclude_extensions": [".md", ".txt"],
160
+ },
161
+ {
162
+ "id": "CODE-002",
163
+ "name": "shell=True in subprocess",
164
+ "pattern": r"""subprocess\.\w+\(.*shell\s*=\s*True""",
165
+ "severity": "MEDIUM",
166
+ "message": "subprocess with shell=True — command injection risk.",
167
+ "exclude_extensions": [".md"],
168
+ },
169
+ {
170
+ "id": "CODE-003",
171
+ "name": "os.system usage",
172
+ "pattern": r"""\bos\.system\s*\(""",
173
+ "severity": "MEDIUM",
174
+ "message": "os.system() is a command injection risk. Use subprocess with shell=False.",
175
+ "exclude_extensions": [".md"],
176
+ },
177
+ # Hygiene
178
+ {
179
+ "id": "HYG-001",
180
+ "name": "TODO/FIXME/HACK introduced",
181
+ "pattern": r"""(?i)\b(?:TODO|FIXME|HACK|XXX)\b""",
182
+ "severity": "LOW",
183
+ "message": "Marker comment found. Track it or resolve it.",
184
+ "exclude_extensions": [".md"],
185
+ },
186
+ {
187
+ "id": "HYG-002",
188
+ "name": ".env file staged",
189
+ "pattern": None, # handled specially
190
+ "severity": "HIGH",
191
+ "message": ".env file should not be committed. Add to .gitignore.",
192
+ "exclude_extensions": [],
193
+ },
194
+ ]
195
+
196
+
197
+ def _effective_severity(rule, filepath, surrounding_lines):
198
+ """Compute context-aware effective severity for a lint match.
199
+
200
+ context_required takes priority over doc_paths: if corroborating evidence
201
+ (e.g. a base64 key body) is present in surrounding lines, the match is
202
+ treated as real secret material at original severity regardless of file
203
+ location. doc_paths only downgrades when evidence is absent — distinguishing
204
+ a lone example header in a reference doc from an actual committed key.
205
+ """
206
+ severity = rule["severity"]
207
+
208
+ # Context-required check first — real evidence overrides location heuristics.
209
+ if severity in ("CRITICAL", "HIGH") and rule.get("context_required"):
210
+ context_found = any(
211
+ re.search(rule["context_required"], line) for line in surrounding_lines
212
+ )
213
+ if context_found:
214
+ # Corroborating evidence present — real key material, keep severity.
215
+ return severity
216
+ # No body found — likely documentation or stub. Fall through to
217
+ # location-based downgrade or unconditional downgrade below.
218
+ if rule.get("doc_paths"):
219
+ f_norm = filepath.replace("\\", "/")
220
+ if any(dp in f_norm for dp in rule["doc_paths"]):
221
+ return "MEDIUM"
222
+ return "MEDIUM"
223
+
224
+ # Rules without context_required: apply doc_paths location-based downgrade.
225
+ if rule.get("doc_paths"):
226
+ f_norm = filepath.replace("\\", "/")
227
+ if any(dp in f_norm for dp in rule["doc_paths"]):
228
+ return "MEDIUM"
229
+
230
+ return severity
231
+
232
+
233
+ def run_layer1(files):
234
+ """Run mechanical lint rules against file list. No model needed."""
235
+ findings = []
236
+
237
+ # Exclude enforcer files by basename — the rule catalog contains the
238
+ # patterns it's searching for, causing false positives. Basename match
239
+ # catches both .gator/scripts/ and template copies.
240
+ SELF_EXCLUDE_NAMES = {"enforcer-review.py", "enforcer-prompt.md"}
241
+
242
+ for filepath in files:
243
+ if os.path.basename(filepath) in SELF_EXCLUDE_NAMES:
244
+ continue
245
+
246
+ _, ext = os.path.splitext(filepath)
247
+
248
+ # Special case: .env file check
249
+ basename = os.path.basename(filepath)
250
+ if basename == ".env" or basename.startswith(".env."):
251
+ if basename != ".env.example":
252
+ findings.append({
253
+ "layer": 1,
254
+ "rule": "HYG-002",
255
+ "severity": "HIGH",
256
+ "file": filepath,
257
+ "line": 0,
258
+ "message": ".env file should not be committed. Add to .gitignore.",
259
+ })
260
+ continue
261
+
262
+ if not os.path.isfile(filepath):
263
+ continue
264
+
265
+ try:
266
+ with open(filepath, encoding="utf-8", errors="replace") as f:
267
+ lines = f.readlines()
268
+ except Exception:
269
+ continue
270
+
271
+ for rule in LAYER1_RULES:
272
+ if rule["pattern"] is None:
273
+ continue
274
+ if ext in rule.get("exclude_extensions", []):
275
+ continue
276
+
277
+ for i, line in enumerate(lines):
278
+ if re.search(rule["pattern"], line):
279
+ window = rule.get("context_window", 10)
280
+ ctx = lines[max(0, i - window):i + window + 1]
281
+ effective_sev = _effective_severity(rule, filepath, ctx)
282
+ findings.append({
283
+ "layer": 1,
284
+ "rule": rule["id"],
285
+ "severity": effective_sev,
286
+ "file": filepath,
287
+ "line": i + 1,
288
+ "message": rule["message"],
289
+ "match": line.strip()[:120],
290
+ })
291
+
292
+ return findings
293
+
294
+
295
+ # ─── Structural Priors (from gator-charter-verify) ─────────────────────
296
+
297
+ def _find_verify_script():
298
+ """Locate gator-charter-verify.py.
299
+
300
+ Search order:
301
+ 1. gator-command/scripts/ in this repo (command-post repo)
302
+ 2. Command post resolved via thin link (fleet repos)
303
+ 3. .gator/scripts/ local (fallback)
304
+
305
+ Returns absolute path or None.
306
+ """
307
+ from pathlib import Path
308
+
309
+ # 1. Command-post repo: gator-command/scripts/ is a sibling of .gator/
310
+ candidate = os.path.join(_REPO_ROOT, "gator-command", "scripts", "gator-charter-verify.py")
311
+ if os.path.isfile(candidate):
312
+ return candidate
313
+
314
+ # 2. Fleet repo: resolve command post via thin link, look in its scripts/
315
+ gator_dir = Path(os.path.join(_REPO_ROOT, ".gator"))
316
+ command_post = resolve_thin_link(gator_dir)
317
+ if command_post:
318
+ # Check both gator-command/ (dev) and gator-engine/ (public) layouts
319
+ for parent in ("gator-command", "gator-engine"):
320
+ candidate = str(command_post / parent / "scripts" / "gator-charter-verify.py")
321
+ if os.path.isfile(candidate):
322
+ return candidate
323
+
324
+ # 3. Local .gator/scripts/ (manual placement)
325
+ candidate = os.path.join(SCRIPT_DIR, "gator-charter-verify.py")
326
+ if os.path.isfile(candidate):
327
+ return candidate
328
+
329
+ return None
330
+
331
+
332
+ def collect_structural_priors(files):
333
+ """Run gator-charter-verify on the repo and filter to changed files.
334
+
335
+ Runs a full-repo verify pass (typically <2s for ~50 files), then
336
+ filters findings to only those affecting the changed file set.
337
+ Returns a formatted string block for inclusion in the model prompt,
338
+ or empty string if verify is unavailable or produces no findings.
339
+ """
340
+ verify_script = _find_verify_script()
341
+ if not verify_script:
342
+ return ""
343
+
344
+ try:
345
+ result = subprocess.run(
346
+ [sys.executable, verify_script, "--path", _REPO_ROOT, "--json"],
347
+ capture_output=True, text=True, timeout=30,
348
+ )
349
+ if result.returncode != 0 or not result.stdout.strip():
350
+ return ""
351
+
352
+ data = json.loads(result.stdout)
353
+ findings = data.get("findings", [])
354
+ if not findings:
355
+ return ""
356
+
357
+ # Filter to findings relevant to the changed files
358
+ changed_set = set(f.replace("\\", "/") for f in files)
359
+ relevant = [f for f in findings if f.get("file", "").replace("\\", "/") in changed_set]
360
+ if not relevant:
361
+ return ""
362
+
363
+ # Format as a compact block for the model
364
+ lines = ["STRUCTURAL PRIORS (mechanical, from gator-charter-verify):"]
365
+ for f in relevant:
366
+ fn_note = f" ({f['function']})" if f.get("function") else ""
367
+ lines.append(f" [{f['class']}] {f['file']}{fn_note}: {f['message']}")
368
+
369
+ return "\n".join(lines)
370
+
371
+ except (subprocess.TimeoutExpired, json.JSONDecodeError, Exception):
372
+ return ""
373
+
374
+
375
+ # ─── Layer 2 & 3: Charter-Grounded Review (model required) ────────────
376
+
377
+ LAYER2_PROMPT = """You are an enforcer reviewing code changes against charters.
378
+
379
+ The CHARTERS are in the system prompt (the authoritative map of what each module owns, its functions, access patterns, and invariants). The DIFF is in the user message (the actual changes being committed).
380
+
381
+ If STRUCTURAL PRIORS are present before the diff, they are cheap mechanical findings from static analysis (gator-charter-verify). Use them to focus your review — they identify files with coverage gaps, undocumented functions, complexity mismatches, or stale charter entries. Structural priors are not verdicts; they are attention signals. You decide which are materially relevant to this diff.
382
+
383
+ Review for these specific failure modes, in priority order:
384
+
385
+ BOUNDARY VIOLATIONS (HIGH severity):
386
+ - Code added to a module that its charter says it "Does Not Own"
387
+ - A function reading/writing something not declared in its @reads/@writes
388
+
389
+ TRIPWIRE VIOLATIONS (HIGH severity):
390
+ - Changes that break a pattern labeled TRIPWIRE in any charter
391
+ - Modifications to cross-language constants, self-contained modules, or synchronized implementations without updating all affected sides
392
+
393
+ CHARTER DRIFT (MEDIUM severity):
394
+ - Functions added, removed, or renamed in code without corresponding charter updates
395
+ - Changed caller/callee relationships (← / →) not reflected in charter entries
396
+ - Charter still documents behavior the code no longer exhibits
397
+
398
+ MISSING CROSS-REFERENCES (MEDIUM severity):
399
+ - New cross-module dependencies not captured in ← / → annotations
400
+ - New files that should appear in a charter's Covers: line but don't
401
+
402
+ CHARTER UPDATE ACCURACY (LOW severity):
403
+ - Charter was updated but the update doesn't match what actually changed in the diff
404
+ - Overly broad or vague charter entries that don't describe the actual function behavior
405
+
406
+ Output ONLY a JSON array. Each object must have exactly these fields:
407
+ [{"severity": "HIGH", "file": "path/to/file", "finding": "one-sentence description"}]
408
+
409
+ If no issues found, output exactly: []
410
+ """
411
+
412
+ FULL_AUDIT_PROMPT = """You are an enforcer performing a comprehensive charter-vs-code audit.
413
+
414
+ The CHARTERS are in the system prompt — they are the authoritative map of what each module owns, its functions, access patterns, invariants, and boundaries. The CODE SAMPLES are in the user message — the actual source files covered by those charters.
415
+
416
+ Audit for these specific failure modes, in priority order:
417
+
418
+ STALE ENTRIES (HIGH severity):
419
+ - Charter documents a function (### name()) that no longer exists in the code
420
+ - Charter describes behavior that the code no longer exhibits
421
+ - Charter's Covers: line references files that don't exist
422
+
423
+ MISSING ENTRIES (HIGH severity):
424
+ - Public functions in the code that have no charter entry
425
+ - Files that should be in a charter's Covers: line but aren't
426
+
427
+ BOUNDARY VIOLATIONS (HIGH severity):
428
+ - Code in a module doing something its charter says it "Does Not Own"
429
+ - Functions reading/writing things not declared in their @reads/@writes
430
+
431
+ STALE CROSS-REFERENCES (MEDIUM severity):
432
+ - ← (caller) or → (callee) annotations that no longer match actual call patterns
433
+ - Cross-cutting patterns that reference functions or behaviors that have changed
434
+
435
+ TRIPWIRE ACCURACY (MEDIUM severity):
436
+ - TRIPWIRE patterns described in cross-cutting charter that no longer match the code
437
+ - ! (tripwire) notes on individual functions that are outdated
438
+
439
+ INDEX ACCURACY (LOW severity):
440
+ - INDEX.md path mappings that point to wrong charters
441
+ - Files not covered by any INDEX entry
442
+
443
+ Output ONLY a JSON array. Each object must have exactly these fields:
444
+ [{"severity": "HIGH", "file": "path/to/file", "finding": "one-sentence description"}]
445
+
446
+ If no issues found, output exactly: []
447
+ """
448
+
449
+
450
+ def get_diff_for_files(files):
451
+ """Get combined diff for a list of files, including untracked files.
452
+
453
+ For tracked files: uses git diff (staged + unstaged).
454
+ For untracked files: synthesizes a diff from file contents so the
455
+ model review covers the same scope as file discovery.
456
+ """
457
+ parts = []
458
+
459
+ # Tracked files: combined staged + unstaged diff
460
+ tracked = [f for f in files if _is_tracked(f)]
461
+ if tracked:
462
+ # Unstaged changes
463
+ result = subprocess.run(
464
+ ["git", "diff", "--no-color", "--"] + tracked,
465
+ capture_output=True, text=True, encoding="utf-8", errors="replace"
466
+ )
467
+ if result.stdout.strip():
468
+ parts.append(result.stdout)
469
+
470
+ # Staged changes
471
+ result = subprocess.run(
472
+ ["git", "diff", "--cached", "--no-color", "--"] + tracked,
473
+ capture_output=True, text=True, encoding="utf-8", errors="replace"
474
+ )
475
+ if result.stdout.strip():
476
+ parts.append(result.stdout)
477
+
478
+ # Untracked files: synthesize diff from contents
479
+ untracked = [f for f in files if not _is_tracked(f)]
480
+ for filepath in untracked:
481
+ if not os.path.isfile(filepath):
482
+ continue
483
+ try:
484
+ with open(filepath, encoding="utf-8", errors="replace") as f:
485
+ content = f.read()
486
+ lines = content.splitlines()
487
+ # Format as a unified diff (new file)
488
+ header = f"diff --git a/{filepath} b/{filepath}\nnew file\n--- /dev/null\n+++ b/{filepath}\n"
489
+ hunk = f"@@ -0,0 +1,{len(lines)} @@\n"
490
+ body = "\n".join(f"+{line}" for line in lines)
491
+ parts.append(header + hunk + body + "\n")
492
+ except Exception:
493
+ continue
494
+
495
+ return "\n".join(parts)
496
+
497
+
498
+ def _is_tracked(filepath):
499
+ """Check if a file is tracked by git."""
500
+ result = subprocess.run(
501
+ ["git", "ls-files", "--error-unmatch", filepath],
502
+ capture_output=True, text=True
503
+ )
504
+ return result.returncode == 0
505
+
506
+
507
+ def _parse_index(charter_dir):
508
+ """Parse INDEX.md to map file path patterns to charter filenames.
509
+
510
+ Returns list of (pattern, [charter_filenames]) tuples.
511
+ """
512
+ index_path = os.path.join(charter_dir, "INDEX.md")
513
+ if not os.path.isfile(index_path):
514
+ return []
515
+
516
+ mappings = []
517
+ try:
518
+ with open(index_path, encoding="utf-8") as f:
519
+ for line in f:
520
+ line = line.strip()
521
+ # Parse table rows: | path pattern | [Charter](file.md) ... |
522
+ if not line.startswith("|") or line.startswith("| If ") or line.startswith("|---"):
523
+ continue
524
+ parts = [p.strip() for p in line.split("|")[1:-1]]
525
+ if len(parts) < 2:
526
+ continue
527
+ raw_pattern = parts[0].strip()
528
+ # Extract charter filenames from markdown links
529
+ charter_refs = re.findall(r'\(([^)]+\.md)\)', parts[1])
530
+ if not raw_pattern or not charter_refs:
531
+ continue
532
+ # INDEX cells can be comma-separated file lists or path prefixes.
533
+ # Split on commas and strip backticks from each sub-pattern.
534
+ sub_patterns = [
535
+ p.strip().strip("`").strip()
536
+ for p in raw_pattern.split(",")
537
+ if p.strip()
538
+ ]
539
+ for sp in sub_patterns:
540
+ mappings.append((sp, charter_refs))
541
+ except (OSError, UnicodeDecodeError):
542
+ pass
543
+ return mappings
544
+
545
+
546
+ def _resolve_primary_charter_dir(repo_root):
547
+ """Return the authoritative charter directory for this repo.
548
+
549
+ Special case: the source gator-command repo uses gator-command/charters
550
+ as the primary script-charter surface. Ordinary governed repos use
551
+ .gator/charters.
552
+ """
553
+ command_post_charters = os.path.join(repo_root, "gator-command", "charters")
554
+ command_post_scripts = os.path.join(repo_root, "gator-command", "scripts")
555
+ if os.path.isdir(command_post_charters) and os.path.isdir(command_post_scripts):
556
+ return command_post_charters
557
+ return os.path.join(repo_root, ".gator", "charters")
558
+
559
+
560
+ def find_relevant_charters(files, charter_dir=None):
561
+ """Find charters relevant to the changed files using INDEX.md.
562
+
563
+ Uses the INDEX to identify which charters cover the changed files,
564
+ then loads only those charters plus cross-cutting.md (always included).
565
+ Falls back to loading all charters if INDEX is missing or unparseable.
566
+ """
567
+ if charter_dir is None:
568
+ repo_root = os.getcwd()
569
+ charter_dir = _resolve_primary_charter_dir(repo_root)
570
+
571
+ if not os.path.isdir(charter_dir):
572
+ return {}
573
+
574
+ # Always include cross-cutting if it exists
575
+ relevant_names = set()
576
+ cross_cutting = os.path.join(charter_dir, "cross-cutting.md")
577
+ if os.path.isfile(cross_cutting):
578
+ relevant_names.add("cross-cutting.md")
579
+
580
+ # Try INDEX-based selection
581
+ index_mappings = _parse_index(charter_dir)
582
+ if index_mappings:
583
+ for changed_file in files:
584
+ f_normalized = changed_file.replace("\\", "/")
585
+ f_basename = f_normalized.rsplit("/", 1)[-1]
586
+ for pattern, charter_refs in index_mappings:
587
+ # Glob-style: "extract-*-sessions.py" → match with fnmatch
588
+ if "*" in pattern or "?" in pattern:
589
+ import fnmatch
590
+ if fnmatch.fnmatch(f_basename, pattern) or fnmatch.fnmatch(f_normalized, pattern):
591
+ relevant_names.update(charter_refs)
592
+ # Exact filename or path prefix match
593
+ elif f_basename == pattern or f_normalized.startswith(pattern) or pattern in f_normalized:
594
+ relevant_names.update(charter_refs)
595
+ # If INDEX matched something, use only those charters
596
+ if len(relevant_names) > 1: # more than just cross-cutting
597
+ charters = {}
598
+ for name in relevant_names:
599
+ fpath = os.path.join(charter_dir, name)
600
+ if os.path.isfile(fpath):
601
+ try:
602
+ with open(fpath, encoding="utf-8") as f:
603
+ charters[name] = f.read()
604
+ except (OSError, UnicodeDecodeError):
605
+ pass
606
+ if charters:
607
+ return charters
608
+
609
+ # Fallback: load all charters (INDEX missing, empty, or no matches)
610
+ charters = {}
611
+ for fname in os.listdir(charter_dir):
612
+ if fname.endswith(".md") and not fname.startswith("_") and fname != "INDEX.md":
613
+ fpath = os.path.join(charter_dir, fname)
614
+ try:
615
+ with open(fpath, encoding="utf-8") as f:
616
+ charters[fname] = f.read()
617
+ except (OSError, UnicodeDecodeError):
618
+ pass
619
+
620
+ return charters
621
+
622
+
623
+ def call_model(provider, model, api_key, system_prompt, user_message,
624
+ charter_text=None):
625
+ """Call an LLM. Returns (response_text, usage_info) tuple.
626
+
627
+ charter_text: when provided and provider is anthropic, charters are placed
628
+ in the system prompt with cache_control so repeated runs within the TTL
629
+ window (~5 min) pay ~90% less for the charter portion.
630
+ """
631
+
632
+ if provider == "anthropic":
633
+ import anthropic
634
+ client = anthropic.Anthropic(api_key=api_key)
635
+
636
+ # Structure system prompt for caching: instructions (small) +
637
+ # charters (large, stable across runs) with cache_control.
638
+ if charter_text:
639
+ system = [
640
+ {"type": "text", "text": system_prompt},
641
+ {"type": "text", "text": charter_text,
642
+ "cache_control": {"type": "ephemeral"}},
643
+ ]
644
+ else:
645
+ system = system_prompt
646
+
647
+ response = client.messages.create(
648
+ model=model,
649
+ max_tokens=2000,
650
+ system=system,
651
+ messages=[{"role": "user", "content": user_message}],
652
+ timeout=MODEL_TIMEOUT_SECONDS,
653
+ )
654
+ usage = response.usage
655
+ usage_info = {
656
+ "input_tokens": usage.input_tokens,
657
+ "output_tokens": usage.output_tokens,
658
+ "cache_creation_input_tokens": getattr(usage, "cache_creation_input_tokens", 0) or 0,
659
+ "cache_read_input_tokens": getattr(usage, "cache_read_input_tokens", 0) or 0,
660
+ }
661
+ return response.content[0].text.strip(), usage_info
662
+
663
+ elif provider == "openai":
664
+ import openai
665
+ client = openai.OpenAI(api_key=api_key)
666
+ full_system = system_prompt
667
+ if charter_text:
668
+ full_system = f"{system_prompt}\n\nCHARTERS:\n{charter_text}"
669
+ response = client.chat.completions.create(
670
+ model=model,
671
+ max_tokens=2000,
672
+ messages=[
673
+ {"role": "system", "content": full_system},
674
+ {"role": "user", "content": user_message},
675
+ ],
676
+ timeout=MODEL_TIMEOUT_SECONDS,
677
+ )
678
+ usage_info = {}
679
+ if response.usage:
680
+ usage_info = {
681
+ "input_tokens": response.usage.prompt_tokens,
682
+ "output_tokens": response.usage.completion_tokens,
683
+ }
684
+ return response.choices[0].message.content.strip(), usage_info
685
+
686
+ elif provider == "google":
687
+ import google.generativeai as genai
688
+ genai.configure(api_key=api_key)
689
+ gen_model = genai.GenerativeModel(model)
690
+ full_prompt = system_prompt
691
+ if charter_text:
692
+ full_prompt = f"{system_prompt}\n\nCHARTERS:\n{charter_text}"
693
+ response = gen_model.generate_content(
694
+ f"{full_prompt}\n\n{user_message}",
695
+ generation_config={"max_output_tokens": 2000},
696
+ request_options={"timeout": MODEL_TIMEOUT_SECONDS},
697
+ )
698
+ return response.text.strip(), {}
699
+
700
+ elif provider == "ollama":
701
+ # Ollama runs locally — no API key, uses HTTP API
702
+ import urllib.request
703
+ full_system = system_prompt
704
+ if charter_text:
705
+ full_system = f"{system_prompt}\n\nCHARTERS:\n{charter_text}"
706
+ payload = json.dumps({
707
+ "model": model,
708
+ "messages": [
709
+ {"role": "system", "content": full_system},
710
+ {"role": "user", "content": user_message},
711
+ ],
712
+ "stream": False,
713
+ }).encode()
714
+ req = urllib.request.Request(
715
+ "http://localhost:11434/api/chat",
716
+ data=payload,
717
+ headers={"Content-Type": "application/json"},
718
+ )
719
+ with urllib.request.urlopen(req, timeout=120) as resp:
720
+ data = json.loads(resp.read())
721
+ return data["message"]["content"].strip(), {}
722
+
723
+ else:
724
+ raise ValueError(f"Unknown provider: {provider}")
725
+
726
+
727
+ def run_layer2_3(files, diff, config):
728
+ """Run charter-grounded review using an LLM."""
729
+ l23 = config.get("layer2_3", {})
730
+ provider = l23.get("provider", "anthropic")
731
+ model = l23.get("model", "claude-sonnet-4-6")
732
+ api_key_env = l23.get("api_key_env", "ANTHROPIC_API_KEY")
733
+ findings = []
734
+
735
+ # Provider "none" = explicitly disabled
736
+ if provider == "none":
737
+ return [{
738
+ "layer": 2,
739
+ "rule": "SKIP",
740
+ "severity": "INFO",
741
+ "file": "",
742
+ "line": 0,
743
+ "message": "Layer 2-3 disabled (provider: none in enforcer-config.json). Layer 1 mechanical lint still ran.",
744
+ }]
745
+
746
+ # Get API key (ollama doesn't need one)
747
+ api_key = None
748
+ if provider != "ollama":
749
+ api_key = os.environ.get(api_key_env)
750
+ if not api_key:
751
+ return [{
752
+ "layer": 2,
753
+ "rule": "SKIP",
754
+ "severity": "INFO",
755
+ "file": "",
756
+ "line": 0,
757
+ "message": (
758
+ f"{api_key_env} not set — skipping charter-grounded review (Layer 2-3).\n"
759
+ f" To enable: set {api_key_env} in your environment.\n"
760
+ f" To use a different provider: edit .gator/scripts/enforcer-config.json.\n"
761
+ f" Supported: anthropic, openai, google, ollama (free/local), none (disable).\n"
762
+ f" Layer 1 mechanical lint still ran."
763
+ ),
764
+ }]
765
+
766
+ charters = find_relevant_charters(files)
767
+ if not charters:
768
+ return [{
769
+ "layer": 2,
770
+ "rule": "SKIP",
771
+ "severity": "INFO",
772
+ "file": "",
773
+ "line": 0,
774
+ "message": "No charters found — skipping charter-grounded review. Run bootstrap first (see .gator/gator-start-up.md).",
775
+ }]
776
+
777
+ # Build context — charters go to system prompt (cached), diff to user message
778
+ charter_text = ""
779
+ for name, content in charters.items():
780
+ charter_text += f"\n\n--- CHARTER: {name} ---\n{content}"
781
+
782
+ diff_limit = 100000
783
+ charter_limit = 100000
784
+ diff_truncated = len(diff) > diff_limit
785
+ charters_truncated = len(charter_text) > charter_limit
786
+
787
+ if diff_truncated or charters_truncated:
788
+ truncated_parts = []
789
+ if diff_truncated:
790
+ truncated_parts.append(
791
+ f"diff truncated to {diff_limit} chars from {len(diff)}"
792
+ )
793
+ if charters_truncated:
794
+ truncated_parts.append(
795
+ f"charters truncated to {charter_limit} chars from {len(charter_text)}"
796
+ )
797
+ findings.append({
798
+ "layer": 2,
799
+ "rule": "TRUNCATED",
800
+ "severity": "INFO",
801
+ "file": "",
802
+ "line": 0,
803
+ "message": (
804
+ "Charter-grounded review used partial context: "
805
+ + "; ".join(truncated_parts)
806
+ + ". Review conclusions may miss tail context."
807
+ ),
808
+ })
809
+
810
+ # Collect structural priors (cheap, no model, fast)
811
+ priors_block = collect_structural_priors(files)
812
+
813
+ # Charters passed separately for caching (Anthropic: system prompt with
814
+ # cache_control; other providers: appended to system prompt as text).
815
+ # User message: structural priors (if any) + diff.
816
+ if priors_block:
817
+ user_message = f"{priors_block}\n\nDIFF:\n```\n{diff[:diff_limit]}\n```"
818
+ findings.append({
819
+ "layer": 2,
820
+ "rule": "STRUCTURAL",
821
+ "severity": "INFO",
822
+ "file": "",
823
+ "line": 0,
824
+ "message": f"Structural priors provided to model ({priors_block.count(chr(10))} lines from gator-charter-verify)",
825
+ })
826
+ else:
827
+ user_message = f"DIFF:\n```\n{diff[:diff_limit]}\n```"
828
+ truncated_charters = charter_text[:charter_limit]
829
+
830
+ try:
831
+ response_text, usage_info = call_model(
832
+ provider, model, api_key, LAYER2_PROMPT, user_message,
833
+ charter_text=truncated_charters,
834
+ )
835
+
836
+ # Report usage if available (cost transparency for the PI)
837
+ if usage_info:
838
+ cache_read = usage_info.get("cache_read_input_tokens", 0)
839
+ cache_created = usage_info.get("cache_creation_input_tokens", 0)
840
+ input_tok = usage_info.get("input_tokens", 0)
841
+ output_tok = usage_info.get("output_tokens", 0)
842
+ usage_parts = [f"{input_tok} input, {output_tok} output"]
843
+ if cache_read:
844
+ usage_parts.append(f"{cache_read} cached (90% savings)")
845
+ elif cache_created:
846
+ usage_parts.append(f"{cache_created} cached for next run")
847
+ findings.append({
848
+ "layer": 2,
849
+ "rule": "USAGE",
850
+ "severity": "INFO",
851
+ "file": "",
852
+ "line": 0,
853
+ "message": f"Token usage ({provider}/{model}): {', '.join(usage_parts)}",
854
+ })
855
+
856
+ # Parse JSON findings from response
857
+ json_match = re.search(r'\[.*\]', response_text, re.DOTALL)
858
+ if json_match:
859
+ model_findings = json.loads(json_match.group())
860
+ for f in model_findings:
861
+ findings.append({
862
+ "layer": 2,
863
+ "rule": "CHARTER",
864
+ "severity": f.get("severity", "MEDIUM"),
865
+ "file": f.get("file", ""),
866
+ "line": 0,
867
+ "message": f.get("finding", ""),
868
+ })
869
+ return findings
870
+ else:
871
+ findings.append({
872
+ "layer": 2,
873
+ "rule": "CHARTER",
874
+ "severity": "INFO",
875
+ "file": "",
876
+ "line": 0,
877
+ "message": f"Charter review ({provider}/{model}): no issues found.",
878
+ })
879
+ return findings
880
+
881
+ except ImportError as e:
882
+ pkg = {"anthropic": "anthropic", "openai": "openai", "google": "google-generativeai"}.get(provider, provider)
883
+ findings.append({
884
+ "layer": 2,
885
+ "rule": "SKIP",
886
+ "severity": "INFO",
887
+ "file": "",
888
+ "line": 0,
889
+ "message": f"Package not installed for {provider}: pip install {pkg}",
890
+ })
891
+ return findings
892
+ except Exception as e:
893
+ findings.append({
894
+ "layer": 2,
895
+ "rule": "ERROR",
896
+ "severity": "INFO",
897
+ "file": "",
898
+ "line": 0,
899
+ "message": f"Charter review failed ({provider}/{model}): {str(e)}",
900
+ })
901
+ return findings
902
+
903
+
904
+ # ─── Full Audit (--full mode) ─────────────────────────────────────────
905
+ #
906
+ # Two-phase approach:
907
+ # Phase 1 (mechanical, free): Parse charters for ### func() entries and
908
+ # Covers: files. Grep code for those function names. Scan code for
909
+ # function declarations not in any charter. Check cross-references.
910
+ # Phase 2 (model, targeted): Send only the discrepancies + relevant
911
+ # function bodies to the model for semantic verification.
912
+
913
+ # Patterns for detecting function declarations across languages
914
+ _FUNC_PATTERNS = [
915
+ # Python: def foo(
916
+ (r'^\s*def\s+(\w+)\s*\(', None),
917
+ # Bash: foo() { or function foo
918
+ (r'^\s*(\w+)\s*\(\)\s*\{', {".sh"}),
919
+ (r'^\s*function\s+(\w+)', {".sh"}),
920
+ # JS/TS: function foo(, async function foo(, export function foo(
921
+ (r'(?:export\s+)?(?:async\s+)?function\s+(\w+)\s*\(', {".js", ".ts", ".jsx", ".tsx"}),
922
+ # Go: func Foo(
923
+ (r'^\s*func\s+(\w+)\s*\(', {".go"}),
924
+ # Rust: fn foo(, pub fn foo(
925
+ (r'^\s*(?:pub\s+)?fn\s+(\w+)\s*\(', {".rs"}),
926
+ ]
927
+
928
+
929
+ def _parse_charter_entries(charter_text):
930
+ """Extract function entries and covered files from a charter.
931
+
932
+ Returns (func_entries, covered_files) where func_entries is a list of
933
+ {name, lines} dicts and covered_files is a list of relative paths.
934
+ """
935
+ func_entries = []
936
+ covered_files = []
937
+
938
+ lines = charter_text.splitlines()
939
+ for i, line in enumerate(lines):
940
+ # Covers: line
941
+ if line.startswith("**Covers**:") or line.startswith("**Covers:**"):
942
+ covered_files = re.findall(r'`([^`]+)`', line)
943
+
944
+ # Function entries: ### name(
945
+ stripped = line.strip()
946
+ if stripped.startswith("### ") and "(" in stripped:
947
+ name = stripped[4:].split("(")[0].strip()
948
+ if name:
949
+ # Capture the entry text (up to next ### or ---)
950
+ entry_lines = [line]
951
+ for j in range(i + 1, min(i + 15, len(lines))):
952
+ if lines[j].strip().startswith("### ") or lines[j].strip() == "---":
953
+ break
954
+ entry_lines.append(lines[j])
955
+ func_entries.append({
956
+ "name": name,
957
+ "entry_text": "\n".join(entry_lines),
958
+ })
959
+
960
+ return func_entries, covered_files
961
+
962
+
963
+ def _extract_function_body(content, func_name, ext):
964
+ """Extract a function body from source code by name.
965
+
966
+ Returns the function signature + body (up to 30 lines) or None.
967
+ """
968
+ lines = content.splitlines()
969
+ for i, line in enumerate(lines):
970
+ # Check if this line declares the function
971
+ for pattern, exts in _FUNC_PATTERNS:
972
+ if exts and ext not in exts:
973
+ continue
974
+ m = re.match(pattern, line)
975
+ if m and m.group(1) == func_name:
976
+ # Capture up to 30 lines of the function body
977
+ end = min(i + 30, len(lines))
978
+ return "\n".join(lines[i:end])
979
+ return None
980
+
981
+
982
+ def _scan_code_functions(filepath, content):
983
+ """Scan a source file for all function declarations.
984
+
985
+ Returns list of function names.
986
+ """
987
+ _, ext = os.path.splitext(filepath)
988
+ ext = ext.lower()
989
+ functions = []
990
+
991
+ for line in content.splitlines():
992
+ for pattern, exts in _FUNC_PATTERNS:
993
+ if exts and ext not in exts:
994
+ continue
995
+ m = re.match(pattern, line)
996
+ if m:
997
+ name = m.group(1)
998
+ # Skip private/internal in some languages
999
+ if not name.startswith("__"):
1000
+ functions.append(name)
1001
+ return functions
1002
+
1003
+
1004
+ def _run_phase1(charter_dir, repo_root):
1005
+ """Phase 1: Mechanical charter-vs-code comparison.
1006
+
1007
+ Returns (findings, discrepancies) where discrepancies is a list of
1008
+ items that need model review.
1009
+ """
1010
+ findings = []
1011
+ discrepancies = []
1012
+
1013
+ # Load all charters
1014
+ charters = {}
1015
+ for fname in os.listdir(charter_dir):
1016
+ if fname.endswith(".md") and not fname.startswith("_") and fname != "INDEX.md":
1017
+ fpath = os.path.join(charter_dir, fname)
1018
+ try:
1019
+ with open(fpath, encoding="utf-8") as f:
1020
+ charters[fname] = f.read()
1021
+ except (OSError, UnicodeDecodeError):
1022
+ pass
1023
+
1024
+ if not charters:
1025
+ return findings, discrepancies, {}
1026
+
1027
+ # Per-charter analysis
1028
+ # Key by (func_name, filepath) to avoid name collisions across files
1029
+ all_chartered_funcs = set() # {(func_name, charter_name)}
1030
+ all_code_funcs = [] # [(func_name, filepath)] — list to preserve all
1031
+ covered_file_contents = {} # {filepath: content}
1032
+ all_covered_paths = set() # All files covered by any charter
1033
+
1034
+ for charter_name, charter_text in charters.items():
1035
+ func_entries, covered_files = _parse_charter_entries(charter_text)
1036
+
1037
+ # Track chartered function names with charter context
1038
+ for entry in func_entries:
1039
+ all_chartered_funcs.add((entry["name"], charter_name))
1040
+
1041
+ # Check Covers: files exist and read their contents
1042
+ charter_covered_content = "" # Combined content of all covered files
1043
+ for rel_path in covered_files:
1044
+ all_covered_paths.add(rel_path)
1045
+ full = os.path.join(repo_root, rel_path)
1046
+ if not os.path.isfile(full):
1047
+ findings.append({
1048
+ "layer": 2, "rule": "AUDIT-MECHANICAL",
1049
+ "severity": "HIGH",
1050
+ "file": charter_name, "line": 0,
1051
+ "message": f"Covers: references '{rel_path}' but file does not exist.",
1052
+ })
1053
+ continue
1054
+
1055
+ # Read the covered file (cache across charters)
1056
+ if rel_path not in covered_file_contents:
1057
+ try:
1058
+ with open(full, encoding="utf-8", errors="replace") as f:
1059
+ covered_file_contents[rel_path] = f.read()
1060
+ except OSError:
1061
+ continue
1062
+
1063
+ content = covered_file_contents[rel_path]
1064
+ charter_covered_content += "\n" + content
1065
+
1066
+ # Scan code for functions — keyed by (name, filepath)
1067
+ code_funcs = _scan_code_functions(rel_path, content)
1068
+ for func in code_funcs:
1069
+ all_code_funcs.append((func, rel_path))
1070
+
1071
+ # Check each chartered function exists in ANY of the covered files
1072
+ for entry in func_entries:
1073
+ if entry["name"] not in charter_covered_content:
1074
+ findings.append({
1075
+ "layer": 2, "rule": "AUDIT-MECHANICAL",
1076
+ "severity": "HIGH",
1077
+ "file": charter_name, "line": 0,
1078
+ "message": (
1079
+ f"Charter documents '{entry['name']}()' but name not found "
1080
+ f"in any Covers: file. Possibly renamed or removed."
1081
+ ),
1082
+ })
1083
+ discrepancies.append({
1084
+ "type": "stale_entry",
1085
+ "charter": charter_name,
1086
+ "function": entry["name"],
1087
+ "entry_text": entry["entry_text"],
1088
+ })
1089
+
1090
+ # Find undocumented functions — check each (name, file) pair individually
1091
+ chartered_names = {name for name, _ in all_chartered_funcs}
1092
+ for func_name, filepath in all_code_funcs:
1093
+ if func_name not in chartered_names:
1094
+ if func_name.startswith("_"):
1095
+ continue
1096
+ findings.append({
1097
+ "layer": 2, "rule": "AUDIT-MECHANICAL",
1098
+ "severity": "MEDIUM",
1099
+ "file": filepath, "line": 0,
1100
+ "message": f"Public function '{func_name}()' has no charter entry.",
1101
+ })
1102
+ content = covered_file_contents.get(filepath, "")
1103
+ _, ext = os.path.splitext(filepath)
1104
+ body = _extract_function_body(content, func_name, ext.lower())
1105
+ if body:
1106
+ discrepancies.append({
1107
+ "type": "missing_entry",
1108
+ "function": func_name,
1109
+ "file": filepath,
1110
+ "body": body,
1111
+ })
1112
+
1113
+ # Scan for completely unchartered source files in the repo
1114
+ # Walk common script/source directories for files not in any Covers: line
1115
+ SOURCE_EXTENSIONS = {".py", ".sh", ".js", ".ts", ".go", ".rs", ".java", ".gd"}
1116
+ SCAN_DIRS = ["gator-command/scripts", "scripts", "src", "lib", "app"]
1117
+ for scan_dir in SCAN_DIRS:
1118
+ scan_path = os.path.join(repo_root, scan_dir)
1119
+ if not os.path.isdir(scan_path):
1120
+ continue
1121
+ for fname in os.listdir(scan_path):
1122
+ _, ext = os.path.splitext(fname)
1123
+ if ext.lower() not in SOURCE_EXTENSIONS:
1124
+ continue
1125
+ rel_path = os.path.join(scan_dir, fname).replace("\\", "/")
1126
+ if rel_path not in all_covered_paths:
1127
+ findings.append({
1128
+ "layer": 2, "rule": "AUDIT-MECHANICAL",
1129
+ "severity": "HIGH",
1130
+ "file": rel_path, "line": 0,
1131
+ "message": (
1132
+ f"Source file '{rel_path}' is not in any charter's Covers: line. "
1133
+ f"No charter governs this module."
1134
+ ),
1135
+ })
1136
+
1137
+ # Check cross-references (← / →) in charters
1138
+ # Skip common names that appear everywhere and aren't meaningful to verify
1139
+ XREF_SKIP = {"main", "init", "setup", "run", "start", "test"}
1140
+ for charter_name, charter_text in charters.items():
1141
+ for line in charter_text.splitlines():
1142
+ # Look for ← func_name() or → func_name() references
1143
+ for arrow_match in re.finditer(r'[←→]\s+(\w+)\(\)', line):
1144
+ ref_name = arrow_match.group(1)
1145
+ if ref_name in XREF_SKIP:
1146
+ continue
1147
+ # Check if referenced function exists in any covered file
1148
+ found = False
1149
+ for content in covered_file_contents.values():
1150
+ if ref_name in content:
1151
+ found = True
1152
+ break
1153
+ if not found and ref_name not in all_chartered_funcs:
1154
+ findings.append({
1155
+ "layer": 2, "rule": "AUDIT-MECHANICAL",
1156
+ "severity": "MEDIUM",
1157
+ "file": charter_name, "line": 0,
1158
+ "message": (
1159
+ f"Cross-reference to '{ref_name}()' but function not "
1160
+ f"found in any covered file."
1161
+ ),
1162
+ })
1163
+
1164
+ return findings, discrepancies, charters
1165
+
1166
+
1167
+ def _run_phase2(discrepancies, charters, config):
1168
+ """Phase 2: Send only discrepancies to the model for semantic review.
1169
+
1170
+ Instead of sending entire source files, sends:
1171
+ - Stale entries: charter entry text (what to verify)
1172
+ - Missing entries: function body + charter overview (should this be documented?)
1173
+ - Cross-cutting: summary of Phase 1 findings for TRIPWIRE check
1174
+ """
1175
+ l23 = config.get("layer2_3", {})
1176
+ provider = l23.get("provider", "anthropic")
1177
+ model = l23.get("model", "claude-sonnet-4-6")
1178
+ api_key_env = l23.get("api_key_env", "ANTHROPIC_API_KEY")
1179
+ findings = []
1180
+
1181
+ if not discrepancies:
1182
+ return findings
1183
+
1184
+ if provider == "none":
1185
+ return findings
1186
+
1187
+ api_key = None
1188
+ if provider != "ollama":
1189
+ api_key = os.environ.get(api_key_env)
1190
+ if not api_key:
1191
+ return [{
1192
+ "layer": 2, "rule": "SKIP", "severity": "INFO",
1193
+ "file": "", "line": 0,
1194
+ "message": (
1195
+ f"{api_key_env} not set — Phase 2 (model review of "
1196
+ f"{len(discrepancies)} discrepancies) skipped."
1197
+ ),
1198
+ }]
1199
+
1200
+ # Build focused prompt with only the discrepancies
1201
+ sections = []
1202
+ for d in discrepancies[:30]: # Cap to prevent runaway token usage
1203
+ if d["type"] == "stale_entry":
1204
+ sections.append(
1205
+ f"STALE? Charter '{d['charter']}' documents '{d['function']}()' "
1206
+ f"but the name was not found in covered files.\n"
1207
+ f"Charter entry:\n{d['entry_text']}\n"
1208
+ f"Question: Is this function gone, renamed, or moved?"
1209
+ )
1210
+ elif d["type"] == "missing_entry":
1211
+ sections.append(
1212
+ f"UNDOCUMENTED? '{d['function']}()' in {d['file']} has no charter entry.\n"
1213
+ f"Function body:\n{d['body']}\n"
1214
+ f"Question: Is this a public function that should be chartered, "
1215
+ f"or an internal helper that's fine without one?"
1216
+ )
1217
+
1218
+ if not sections:
1219
+ return findings
1220
+
1221
+ # Build charter summary for context (not full text — just Owns/Does Not Own)
1222
+ charter_summary = ""
1223
+ for name, text in charters.items():
1224
+ summary_lines = []
1225
+ for line in text.splitlines()[:20]: # First 20 lines captures Owns/Does Not Own
1226
+ summary_lines.append(line)
1227
+ charter_summary += f"\n--- {name} ---\n" + "\n".join(summary_lines) + "\n"
1228
+
1229
+ user_message = (
1230
+ f"Phase 1 mechanical analysis found {len(discrepancies)} discrepancies.\n"
1231
+ f"Review each and assess whether it's a real problem or a false positive.\n\n"
1232
+ + "\n---\n".join(sections)
1233
+ )
1234
+
1235
+ try:
1236
+ phase2_prompt = (
1237
+ "You are an enforcer reviewing charter-vs-code discrepancies found by "
1238
+ "mechanical analysis. For each item, determine if it's a real drift "
1239
+ "problem or a false positive (e.g., the function was intentionally "
1240
+ "removed, or the 'undocumented' function is genuinely internal).\n\n"
1241
+ "Output ONLY a JSON array of real problems (exclude false positives):\n"
1242
+ '[{"severity": "HIGH|MEDIUM", "file": "path", "finding": "description"}]\n\n'
1243
+ "If all discrepancies are false positives, output: []"
1244
+ )
1245
+
1246
+ response_text, usage_info = call_model(
1247
+ provider, model, api_key, phase2_prompt, user_message,
1248
+ charter_text=charter_summary,
1249
+ )
1250
+
1251
+ if usage_info:
1252
+ cache_read = usage_info.get("cache_read_input_tokens", 0)
1253
+ cache_created = usage_info.get("cache_creation_input_tokens", 0)
1254
+ input_tok = usage_info.get("input_tokens", 0)
1255
+ output_tok = usage_info.get("output_tokens", 0)
1256
+ usage_parts = [f"{input_tok} input, {output_tok} output"]
1257
+ if cache_read:
1258
+ usage_parts.append(f"{cache_read} cached (90% savings)")
1259
+ elif cache_created:
1260
+ usage_parts.append(f"{cache_created} cached for next run")
1261
+ findings.append({
1262
+ "layer": 2, "rule": "USAGE", "severity": "INFO",
1263
+ "file": "", "line": 0,
1264
+ "message": (
1265
+ f"Phase 2 model review ({provider}/{model}): "
1266
+ + ", ".join(usage_parts)
1267
+ ),
1268
+ })
1269
+
1270
+ json_match = re.search(r'\[.*\]', response_text, re.DOTALL)
1271
+ if json_match:
1272
+ model_findings = json.loads(json_match.group())
1273
+ for f in model_findings:
1274
+ findings.append({
1275
+ "layer": 2,
1276
+ "rule": "AUDIT-MODEL",
1277
+ "severity": f.get("severity", "MEDIUM"),
1278
+ "file": f.get("file", ""),
1279
+ "line": 0,
1280
+ "message": f.get("finding", ""),
1281
+ })
1282
+
1283
+ return findings
1284
+
1285
+ except ImportError as e:
1286
+ pkg = {"anthropic": "anthropic", "openai": "openai", "google": "google-generativeai"}.get(provider, provider)
1287
+ return [{
1288
+ "layer": 2, "rule": "SKIP", "severity": "INFO",
1289
+ "file": "", "line": 0,
1290
+ "message": f"Package not installed for {provider}: pip install {pkg}",
1291
+ }]
1292
+ except Exception as e:
1293
+ return [{
1294
+ "layer": 2, "rule": "ERROR", "severity": "INFO",
1295
+ "file": "", "line": 0,
1296
+ "message": f"Phase 2 model review failed ({provider}/{model}): {str(e)}",
1297
+ }]
1298
+
1299
+
1300
+ def run_full_audit(config):
1301
+ """Run a comprehensive two-phase charter-vs-code audit.
1302
+
1303
+ Phase 1 (mechanical, free): Parse charters, grep code for function names,
1304
+ scan for undocumented functions, check cross-references. Catches ~70% of
1305
+ drift without a model.
1306
+
1307
+ Phase 2 (model, targeted): Send only the discrepancies from Phase 1 to the
1308
+ model with function-level snippets (not whole files). The model classifies
1309
+ each as real drift or false positive.
1310
+ """
1311
+ repo_root = os.getcwd()
1312
+ charter_dir = _resolve_primary_charter_dir(repo_root)
1313
+
1314
+ if not os.path.isdir(charter_dir):
1315
+ return [{
1316
+ "layer": 2, "rule": "SKIP", "severity": "INFO",
1317
+ "file": "", "line": 0,
1318
+ "message": "No charters found — cannot run full audit.",
1319
+ }]
1320
+
1321
+ # Phase 1: Mechanical
1322
+ phase1_findings, discrepancies, charters = _run_phase1(charter_dir, repo_root)
1323
+
1324
+ all_findings = []
1325
+ all_findings.extend(phase1_findings)
1326
+
1327
+ # Summary of Phase 1
1328
+ mechanical_issues = len([f for f in phase1_findings if f["severity"] != "INFO"])
1329
+ all_findings.append({
1330
+ "layer": 2, "rule": "AUDIT-SUMMARY", "severity": "INFO",
1331
+ "file": "", "line": 0,
1332
+ "message": (
1333
+ f"Phase 1 (mechanical): scanned {len(charters)} charters, "
1334
+ f"found {mechanical_issues} issue(s), "
1335
+ f"{len(discrepancies)} sent to model for verification."
1336
+ ),
1337
+ })
1338
+
1339
+ # Phase 2: Model review of discrepancies only
1340
+ if discrepancies:
1341
+ phase2_findings = _run_phase2(discrepancies, charters, config)
1342
+ all_findings.extend(phase2_findings)
1343
+
1344
+ return all_findings
1345
+
1346
+
1347
+ # ─── Output Formatting ────────────────────────────────────────────────
1348
+
1349
+ SEVERITY_ORDER = {"CRITICAL": 0, "HIGH": 1, "MEDIUM": 2, "LOW": 3, "INFO": 4}
1350
+
1351
+
1352
+ def format_findings(findings, fmt="text"):
1353
+ """Format findings for the primary agent to read."""
1354
+ if not findings:
1355
+ return "Enforcer review: clean. No findings."
1356
+
1357
+ findings.sort(key=lambda f: SEVERITY_ORDER.get(f["severity"], 5))
1358
+
1359
+ if fmt == "json":
1360
+ return json.dumps(findings, indent=2)
1361
+
1362
+ # Separate real findings from informational messages
1363
+ real = [f for f in findings if f["severity"] not in ("INFO",)]
1364
+ info = [f for f in findings if f["severity"] == "INFO"]
1365
+
1366
+ lines = []
1367
+
1368
+ if real:
1369
+ lines.append(f"Enforcer Review — {len(real)} finding(s)")
1370
+ lines.append("=" * 50)
1371
+ for f in real:
1372
+ sev = f["severity"]
1373
+ loc = f["file"]
1374
+ if f.get("line", 0) > 0:
1375
+ loc += f":{f['line']}"
1376
+ lines.append(f"[{sev}] {f['rule']} — {loc}")
1377
+ lines.append(f" {f['message']}")
1378
+ if f.get("match"):
1379
+ lines.append(f" > {f['match']}")
1380
+ lines.append("")
1381
+ else:
1382
+ lines.append("Enforcer Review — clean")
1383
+ lines.append("=" * 50)
1384
+ lines.append(" No issues found.")
1385
+ lines.append("")
1386
+
1387
+ # Append info messages (usage, skips) at the end, clearly labeled
1388
+ if info:
1389
+ for f in info:
1390
+ rule = f["rule"]
1391
+ if rule == "SKIP":
1392
+ lines.append(f" Note: {f['message']}")
1393
+ elif rule == "USAGE":
1394
+ lines.append(f" {f['message']}")
1395
+ else:
1396
+ lines.append(f" [{rule}] {f['message']}")
1397
+ lines.append("")
1398
+
1399
+ return "\n".join(lines)
1400
+
1401
+
1402
+ def append_to_whiteboard(output, review_type="enforcer-review.py"):
1403
+ """Write findings directly to whiteboard.md, bypassing the primary agent.
1404
+
1405
+ This is a trust boundary: the enforcer's output reaches the PI without
1406
+ passing through the agent that authored the code under review.
1407
+ """
1408
+ from datetime import datetime, timezone
1409
+
1410
+ whiteboard_path = os.path.join(
1411
+ os.path.dirname(SCRIPT_DIR), "whiteboard.md"
1412
+ )
1413
+
1414
+ timestamp = datetime.now(timezone.utc).strftime("%Y-%m-%d %H:%M UTC")
1415
+ header = f"\n## Review — {timestamp} — {review_type}\n\n"
1416
+
1417
+ # Read existing content
1418
+ existing = ""
1419
+ if os.path.isfile(whiteboard_path):
1420
+ with open(whiteboard_path, encoding="utf-8") as f:
1421
+ existing = f.read()
1422
+
1423
+ # Append new review
1424
+ with open(whiteboard_path, "w", encoding="utf-8") as f:
1425
+ f.write(existing.rstrip() + "\n" + header + output + "\n")
1426
+
1427
+ # Check size and warn if rotation needed
1428
+ line_count = existing.count("\n") + header.count("\n") + output.count("\n")
1429
+ if line_count > 100:
1430
+ print(
1431
+ f"Note: whiteboard.md is now ~{line_count} lines. "
1432
+ f"Consider archiving older reviews to artifacts/review-log.md."
1433
+ )
1434
+
1435
+
1436
+ # ─── Main ─────────────────────────────────────────────────────────────
1437
+
1438
+ def main():
1439
+ ensure_utf8_stdout()
1440
+
1441
+ parser = argparse.ArgumentParser(description="Enforcer review — charter-grounded linter")
1442
+ parser.add_argument("--files", help="Comma-separated file paths to review")
1443
+ parser.add_argument("--staged", action="store_true", help="Review git staged changes")
1444
+ parser.add_argument("--full", action="store_true",
1445
+ help="Comprehensive charter-vs-code audit (reads all charters and all covered code, no diff needed)")
1446
+ parser.add_argument("--layer", default="all", help="Which layers to run: 1, 2, 3, or all")
1447
+ parser.add_argument("--format", default="text", choices=["text", "json"], help="Output format")
1448
+ parser.add_argument(
1449
+ "--no-whiteboard", action="store_true",
1450
+ help="Skip writing to .gator/whiteboard.md (stdout only)"
1451
+ )
1452
+ args = parser.parse_args()
1453
+
1454
+ config = load_config()
1455
+
1456
+ # Full audit mode — reads all charters and covered code, no diff
1457
+ if args.full:
1458
+ all_findings = run_full_audit(config)
1459
+ output = format_findings(all_findings, fmt=args.format)
1460
+ print(output)
1461
+ if not args.no_whiteboard:
1462
+ append_to_whiteboard(output, review_type="enforcer-review.py (full audit)")
1463
+ print(f"\n\u2192 Findings written to .gator/whiteboard.md")
1464
+ if any(f["severity"] in ("HIGH", "CRITICAL") for f in all_findings):
1465
+ sys.exit(1)
1466
+ return
1467
+
1468
+ # Determine files to review
1469
+ if args.files:
1470
+ files = [f.strip() for f in args.files.split(",")]
1471
+ elif args.staged:
1472
+ result = subprocess.run(
1473
+ ["git", "diff", "--cached", "--name-only"],
1474
+ capture_output=True, text=True
1475
+ )
1476
+ if result.returncode != 0:
1477
+ print(f"Error: git diff --cached failed: {result.stderr.strip()}")
1478
+ sys.exit(1)
1479
+ files = [f.strip() for f in result.stdout.strip().split("\n") if f.strip()]
1480
+ else:
1481
+ # Default: all modified files (tracked changes + untracked files)
1482
+ files = []
1483
+
1484
+ # Modified tracked files
1485
+ result = subprocess.run(
1486
+ ["git", "diff", "--name-only"],
1487
+ capture_output=True, text=True
1488
+ )
1489
+ if result.returncode != 0:
1490
+ print(f"Error: git diff failed: {result.stderr.strip()}")
1491
+ sys.exit(1)
1492
+ files.extend(f.strip() for f in result.stdout.strip().split("\n") if f.strip())
1493
+
1494
+ # Staged files
1495
+ result = subprocess.run(
1496
+ ["git", "diff", "--cached", "--name-only"],
1497
+ capture_output=True, text=True
1498
+ )
1499
+ if result.returncode == 0 and result.stdout.strip():
1500
+ files.extend(f.strip() for f in result.stdout.strip().split("\n") if f.strip())
1501
+
1502
+ # Untracked files (not ignored)
1503
+ result = subprocess.run(
1504
+ ["git", "ls-files", "--others", "--exclude-standard"],
1505
+ capture_output=True, text=True
1506
+ )
1507
+ if result.returncode == 0 and result.stdout.strip():
1508
+ files.extend(f.strip() for f in result.stdout.strip().split("\n") if f.strip())
1509
+
1510
+ # Deduplicate while preserving order
1511
+ seen = set()
1512
+ unique_files = []
1513
+ for f in files:
1514
+ if f not in seen:
1515
+ seen.add(f)
1516
+ unique_files.append(f)
1517
+ files = unique_files
1518
+
1519
+ if not files:
1520
+ print("No files to review. No modified, staged, or untracked files found.")
1521
+ return
1522
+
1523
+ all_findings = []
1524
+ layer = args.layer
1525
+
1526
+ # Layer 1: Mechanical lint (always fast, no model)
1527
+ if layer in ("1", "all"):
1528
+ all_findings.extend(run_layer1(files))
1529
+
1530
+ # Layer 2-3: Charter-grounded review (needs model)
1531
+ if layer in ("2", "3", "all"):
1532
+ diff = get_diff_for_files(files)
1533
+ if diff:
1534
+ all_findings.extend(run_layer2_3(files, diff, config))
1535
+
1536
+ output = format_findings(all_findings, fmt=args.format)
1537
+ print(output)
1538
+
1539
+ # Write to whiteboard by default (trust boundary — bypasses primary agent)
1540
+ if not args.no_whiteboard:
1541
+ review_type = f"enforcer-review.py (Layer {args.layer})"
1542
+ append_to_whiteboard(output, review_type=review_type)
1543
+ print(f"\n→ Findings written to .gator/whiteboard.md")
1544
+
1545
+ # Exit code: non-zero if any HIGH or CRITICAL findings
1546
+ if any(f["severity"] in ("HIGH", "CRITICAL") for f in all_findings):
1547
+ sys.exit(1)
1548
+
1549
+
1550
+ if __name__ == "__main__":
1551
+ main()