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.
- gator_command/__init__.py +2 -0
- gator_command/cli.py +137 -0
- gator_command/scripts/crawler.py +633 -0
- gator_command/scripts/dashboard/dashboard.css +982 -0
- gator_command/scripts/dashboard/dashboard.html +84 -0
- gator_command/scripts/dashboard/dashboard.js +419 -0
- gator_command/scripts/dashboard/views/audit.js +270 -0
- gator_command/scripts/dashboard/views/fleet.js +307 -0
- gator_command/scripts/dashboard/views/repo.js +599 -0
- gator_command/scripts/dashboard/views/settings.js +173 -0
- gator_command/scripts/dashboard/views/updates.js +308 -0
- gator_command/scripts/enforcer-prompt.md +22 -0
- gator_command/scripts/extract-claude-sessions.py +489 -0
- gator_command/scripts/extract-codex-sessions.py +477 -0
- gator_command/scripts/extract-gemini-sessions.py +410 -0
- gator_command/scripts/gator-audit.py +956 -0
- gator_command/scripts/gator-charter-draft.py +919 -0
- gator_command/scripts/gator-charter-lint.py +427 -0
- gator_command/scripts/gator-charter-verify.py +606 -0
- gator_command/scripts/gator-dashboard.py +1271 -0
- gator_command/scripts/gator-deploy.py +916 -0
- gator_command/scripts/gator-drift.py +569 -0
- gator_command/scripts/gator-enforce.py +82 -0
- gator_command/scripts/gator-fleet-intel.py +460 -0
- gator_command/scripts/gator-fleet-report.py +615 -0
- gator_command/scripts/gator-init-command-post.py +315 -0
- gator_command/scripts/gator-init.py +434 -0
- gator_command/scripts/gator-machine-id.py +153 -0
- gator_command/scripts/gator-policy-status.py +631 -0
- gator_command/scripts/gator-pulse.py +459 -0
- gator_command/scripts/gator-repo-status.py +649 -0
- gator_command/scripts/gator-session-common.py +372 -0
- gator_command/scripts/gator-session-sink.py +831 -0
- gator_command/scripts/gator-sessions.py +1244 -0
- gator_command/scripts/gator-update.py +615 -0
- gator_command/scripts/gator-version.py +38 -0
- gator_command/scripts/gator_core.py +489 -0
- gator_command/scripts/gator_remote.py +381 -0
- gator_command/scripts/gator_runtime.py +142 -0
- gator_command/scripts/gatorize-actions.sh +989 -0
- gator_command/scripts/gatorize-lib.sh +166 -0
- gator_command/scripts/gatorize-post.sh +394 -0
- gator_command/scripts/gatorize.py +1163 -0
- gator_command/scripts/gatorize.sh +185 -0
- gator_command/scripts/generate_markdown.py +212 -0
- gator_command/scripts/generate_wiki.py +424 -0
- gator_command/scripts/graph_health.py +780 -0
- gator_command/scripts/memex-lint.py +286 -0
- gator_command/scripts/memex-lint.sh +205 -0
- gator_command/scripts/memex.py +1472 -0
- gator_command/scripts/memex_formatters.py +191 -0
- gator_command/scripts/memex_state.py +236 -0
- gator_command/scripts/spawn.py +650 -0
- gator_command/templates/gator-starter/blueprints/README.md +32 -0
- gator_command/templates/gator-starter/charterignore +53 -0
- gator_command/templates/gator-starter/charters/README.md +178 -0
- gator_command/templates/gator-starter/charters/_template.md +31 -0
- gator_command/templates/gator-starter/commands/commit.md +33 -0
- gator_command/templates/gator-starter/commands/init.md +11 -0
- gator_command/templates/gator-starter/commands/update.md +5 -0
- gator_command/templates/gator-starter/constitution.md +165 -0
- gator_command/templates/gator-starter/field-guides/README.md +25 -0
- gator_command/templates/gator-starter/gator-start-up.md +119 -0
- gator_command/templates/gator-starter/procedures/charter-alignment.md +83 -0
- gator_command/templates/gator-starter/procedures/enforcer-review.md +317 -0
- gator_command/templates/gator-starter/procedures/field-guide-generation.md +176 -0
- gator_command/templates/gator-starter/procedures/knowledge-capture.md +57 -0
- gator_command/templates/gator-starter/procedures/significance-check.md +69 -0
- gator_command/templates/gator-starter/reference-notes/concierge-responses.md +535 -0
- gator_command/templates/gator-starter/reference-notes/dangerous-patterns.md +91 -0
- gator_command/templates/gator-starter/reference-notes/dashboard-operations.md +22 -0
- gator_command/templates/gator-starter/reference-notes/enforcer-configuration.md +232 -0
- gator_command/templates/gator-starter/reference-notes/example-project.md +289 -0
- gator_command/templates/gator-starter/reference-notes/failure-modes-and-self-correction.md +72 -0
- gator_command/templates/gator-starter/reference-notes/git-workflow.md +60 -0
- gator_command/templates/gator-starter/reference-notes/identity-and-ownership.md +37 -0
- gator_command/templates/gator-starter/reference-notes/refactor-approach.md +155 -0
- gator_command/templates/gator-starter/reference-notes/what-gator-requires-from-a-model.md +108 -0
- gator_command/templates/gator-starter/reference-notes/why-navigation-coding-feels-different.md +99 -0
- gator_command/templates/gator-starter/reference-notes/workflow-profiles.md +155 -0
- gator_command/templates/gator-starter/scripts/__pycache__/enforcer-review.cpython-313.pyc +0 -0
- gator_command/templates/gator-starter/scripts/__pycache__/gator-approve.cpython-313.pyc +0 -0
- gator_command/templates/gator-starter/scripts/__pycache__/gator-init.cpython-313.pyc +0 -0
- gator_command/templates/gator-starter/scripts/__pycache__/gator-pre-commit.cpython-313.pyc +0 -0
- gator_command/templates/gator-starter/scripts/__pycache__/gator-update.cpython-313.pyc +0 -0
- gator_command/templates/gator-starter/scripts/enforcer-prompt.md +55 -0
- gator_command/templates/gator-starter/scripts/enforcer-review.py +1551 -0
- gator_command/templates/gator-starter/scripts/gator-approve.py +139 -0
- gator_command/templates/gator-starter/scripts/gator-enforce.py +82 -0
- gator_command/templates/gator-starter/scripts/gator-init.py +434 -0
- gator_command/templates/gator-starter/scripts/gator-pre-commit.py +2670 -0
- gator_command/templates/gator-starter/scripts/gator-pulse.py +459 -0
- gator_command/templates/gator-starter/scripts/gator-update.py +615 -0
- gator_command/templates/gator-starter/scripts/gator-version.py +38 -0
- gator_command/templates/gator-starter/scripts/gator_core.py +487 -0
- gator_command/templates/gator-starter/scripts/hooks/__pycache__/commit-msgcpython-313.pyc +0 -0
- gator_command/templates/gator-starter/scripts/hooks/__pycache__/post-commitcpython-313.pyc +0 -0
- gator_command/templates/gator-starter/scripts/hooks/__pycache__/pre-commitcpython-313.pyc +0 -0
- gator_command/templates/gator-starter/scripts/hooks/commit-msg +5 -0
- gator_command/templates/gator-starter/scripts/hooks/post-commit +7 -0
- gator_command/templates/gator-starter/scripts/hooks/pre-commit +5 -0
- gator_command/templates/gator-starter/sessions/.gitignore +7 -0
- gator_command/templates/gator-starter/vault/.gitkeep +0 -0
- gator_command/templates/gator-starter/whiteboard.md +5 -0
- gator_command-1.0.0.dist-info/METADATA +122 -0
- gator_command-1.0.0.dist-info/RECORD +110 -0
- gator_command-1.0.0.dist-info/WHEEL +5 -0
- gator_command-1.0.0.dist-info/entry_points.txt +2 -0
- gator_command-1.0.0.dist-info/licenses/LICENSE +21 -0
- 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()
|