@julioborges/gantry 0.1.0
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.
- package/.agents/skills/gantry/SKILL.md +166 -0
- package/.agents/skills/gantry/capabilities/claude-code.json +15 -0
- package/.agents/skills/gantry/capabilities/codex.json +14 -0
- package/.agents/skills/gantry/capabilities/opencode.json +15 -0
- package/.agents/skills/gantry/dashboard/static/app.js +100 -0
- package/.agents/skills/gantry/dashboard/static/index.html +16 -0
- package/.agents/skills/gantry/dashboard/static/style.css +74 -0
- package/.agents/skills/gantry/hooks/claude-code.settings.json +56 -0
- package/.agents/skills/gantry/hooks/codex.hooks.json +4 -0
- package/.agents/skills/gantry/hooks/git/pre-commit +77 -0
- package/.agents/skills/gantry/hooks/git/pre-push +123 -0
- package/.agents/skills/gantry/hooks/git/skipscan.py +88 -0
- package/.agents/skills/gantry/hooks/opencode.plugin.js +44 -0
- package/.agents/skills/gantry/reference/plan-workflow.md +383 -0
- package/.agents/skills/gantry/reference/round-workflow.md +755 -0
- package/.agents/skills/gantry/schemas/critic.json +93 -0
- package/.agents/skills/gantry/schemas/implementer.json +52 -0
- package/.agents/skills/gantry/schemas/learner.json +35 -0
- package/.agents/skills/gantry/schemas/plan-critic.json +39 -0
- package/.agents/skills/gantry/schemas/planner.json +64 -0
- package/.agents/skills/gantry/schemas/requirement-critic.json +48 -0
- package/.agents/skills/gantry/schemas/reviewer.json +52 -0
- package/.agents/skills/gantry/scripts/acceptance.py +66 -0
- package/.agents/skills/gantry/scripts/budget.py +162 -0
- package/.agents/skills/gantry/scripts/cleanup.py +186 -0
- package/.agents/skills/gantry/scripts/common.py +361 -0
- package/.agents/skills/gantry/scripts/dashboard.py +233 -0
- package/.agents/skills/gantry/scripts/frontier.py +192 -0
- package/.agents/skills/gantry/scripts/gates.py +401 -0
- package/.agents/skills/gantry/scripts/guard.py +568 -0
- package/.agents/skills/gantry/scripts/learner.py +99 -0
- package/.agents/skills/gantry/scripts/result.py +104 -0
- package/.agents/skills/gantry/scripts/roadmap.py +212 -0
- package/.agents/skills/gantry/scripts/runlog.py +491 -0
- package/.agents/skills/gantry/scripts/setup.py +139 -0
- package/.agents/skills/gantry/scripts/spec.py +252 -0
- package/.agents/skills/gantry/templates/issue.md +32 -0
- package/.agents/skills/gantry/templates/prd.md +26 -0
- package/.agents/skills/gantry/templates/spec.md +48 -0
- package/.agents/skills/gantry-dashboard/SKILL.md +55 -0
- package/.agents/skills/gantry-setup/SKILL.md +30 -0
- package/LICENSE +201 -0
- package/README.md +437 -0
- package/bin/gantry.mjs +45 -0
- package/package.json +36 -0
- package/scripts/ensure-npm-author.mjs +29 -0
|
@@ -0,0 +1,401 @@
|
|
|
1
|
+
#!/usr/bin/env python3
|
|
2
|
+
"""Detect and run repository quality gates, emitting a machine-readable verdict."""
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import argparse
|
|
6
|
+
import json
|
|
7
|
+
import os
|
|
8
|
+
import re
|
|
9
|
+
import shutil
|
|
10
|
+
import subprocess
|
|
11
|
+
import sys
|
|
12
|
+
import tempfile
|
|
13
|
+
from pathlib import Path
|
|
14
|
+
|
|
15
|
+
from common import resolve_policy
|
|
16
|
+
|
|
17
|
+
FRONTEND_RE = re.compile(r"(\.(tsx|jsx|vue|svelte|css|scss|less|html)$)|(/|^)(components|pages|app|ui|views|layouts|public|styles|dashboard/src)/", re.IGNORECASE)
|
|
18
|
+
NODE_SCRIPT_ORDER = ["lint", "typecheck", "format:check", "test", "build", "pack:check"]
|
|
19
|
+
MAKE_TARGETS = ["lint", "typecheck", "test", "build"]
|
|
20
|
+
MAPPING_FIELDS = ("findings", "rule", "file", "line", "message", "severity")
|
|
21
|
+
SEVERITY_ORDER = {"info": 0, "warning": 1, "error": 2}
|
|
22
|
+
|
|
23
|
+
|
|
24
|
+
def sh(command: list[str] | str, cwd: Path, timeout: int) -> tuple[int, str]:
|
|
25
|
+
try:
|
|
26
|
+
result = subprocess.run(command, cwd=cwd, shell=isinstance(command, str), capture_output=True, text=True, timeout=timeout,
|
|
27
|
+
env={**os.environ, "CI": "1", "FORCE_COLOR": "0", "NO_COLOR": "1"})
|
|
28
|
+
return result.returncode, result.stdout + result.stderr
|
|
29
|
+
except subprocess.TimeoutExpired as error:
|
|
30
|
+
return 124, f"timed out after {timeout}s\n{error.stdout or ''}{error.stderr or ''}"
|
|
31
|
+
except FileNotFoundError as error:
|
|
32
|
+
return 127, str(error)
|
|
33
|
+
|
|
34
|
+
|
|
35
|
+
def pointer_parts(pointer: object) -> list[str]:
|
|
36
|
+
"""Parse one RFC 6901 JSON Pointer, rejecting malformed escape sequences."""
|
|
37
|
+
if not isinstance(pointer, str) or (pointer and not pointer.startswith("/")):
|
|
38
|
+
raise ValueError("must be an RFC 6901 JSON Pointer")
|
|
39
|
+
parts: list[str] = []
|
|
40
|
+
for raw_part in pointer[1:].split("/") if pointer else []:
|
|
41
|
+
part: list[str] = []
|
|
42
|
+
index = 0
|
|
43
|
+
while index < len(raw_part):
|
|
44
|
+
if raw_part[index] != "~":
|
|
45
|
+
part.append(raw_part[index])
|
|
46
|
+
index += 1
|
|
47
|
+
continue
|
|
48
|
+
if index + 1 == len(raw_part) or raw_part[index + 1] not in "01":
|
|
49
|
+
raise ValueError(f"has an invalid RFC 6901 escape in {pointer!r}")
|
|
50
|
+
part.append("~" if raw_part[index + 1] == "0" else "/")
|
|
51
|
+
index += 2
|
|
52
|
+
parts.append("".join(part))
|
|
53
|
+
return parts
|
|
54
|
+
|
|
55
|
+
|
|
56
|
+
def resolve_pointer(document: object, pointer: object) -> object:
|
|
57
|
+
"""Resolve an RFC 6901 pointer against JSON data."""
|
|
58
|
+
current = document
|
|
59
|
+
for part in pointer_parts(pointer):
|
|
60
|
+
if isinstance(current, dict):
|
|
61
|
+
if part not in current:
|
|
62
|
+
raise ValueError(f"does not resolve {pointer!r}")
|
|
63
|
+
current = current[part]
|
|
64
|
+
elif isinstance(current, list):
|
|
65
|
+
if not part.isdigit() or (len(part) > 1 and part.startswith("0")):
|
|
66
|
+
raise ValueError(f"does not resolve {pointer!r}")
|
|
67
|
+
index = int(part)
|
|
68
|
+
if index >= len(current):
|
|
69
|
+
raise ValueError(f"does not resolve {pointer!r}")
|
|
70
|
+
current = current[index]
|
|
71
|
+
else:
|
|
72
|
+
raise ValueError(f"does not resolve {pointer!r}")
|
|
73
|
+
return current
|
|
74
|
+
|
|
75
|
+
|
|
76
|
+
def scalar(document: object, pointer: object, name: str) -> object:
|
|
77
|
+
value = resolve_pointer(document, pointer)
|
|
78
|
+
if value is None or isinstance(value, (dict, list)):
|
|
79
|
+
raise ValueError(f"{name} must resolve to one scalar")
|
|
80
|
+
return value
|
|
81
|
+
|
|
82
|
+
|
|
83
|
+
def normalise_file(value: object, root: Path) -> str:
|
|
84
|
+
if not isinstance(value, str) or not value.strip():
|
|
85
|
+
raise ValueError("file must resolve to a non-empty string")
|
|
86
|
+
root = root.resolve()
|
|
87
|
+
candidate = Path(value)
|
|
88
|
+
candidate = candidate.resolve() if candidate.is_absolute() else (root / candidate).resolve()
|
|
89
|
+
try:
|
|
90
|
+
return candidate.relative_to(root).as_posix()
|
|
91
|
+
except ValueError as error:
|
|
92
|
+
raise ValueError(f"file {value!r} is outside the repository root") from error
|
|
93
|
+
|
|
94
|
+
|
|
95
|
+
def validate_mapping(mapping: object) -> dict:
|
|
96
|
+
if not isinstance(mapping, dict):
|
|
97
|
+
raise ValueError("differential checks require a mapping object")
|
|
98
|
+
missing = [field for field in MAPPING_FIELDS if field not in mapping]
|
|
99
|
+
if missing:
|
|
100
|
+
raise ValueError(f"mapping is missing {', '.join(missing)}")
|
|
101
|
+
for field in MAPPING_FIELDS:
|
|
102
|
+
pointer_parts(mapping[field])
|
|
103
|
+
return mapping
|
|
104
|
+
|
|
105
|
+
|
|
106
|
+
def parse_findings(output: str, mapping: dict, root: Path, source: str) -> list[dict]:
|
|
107
|
+
try:
|
|
108
|
+
document = json.loads(output)
|
|
109
|
+
except json.JSONDecodeError as error:
|
|
110
|
+
raise ValueError(f"{source} command did not emit JSON: {error.msg}") from error
|
|
111
|
+
findings = resolve_pointer(document, mapping["findings"])
|
|
112
|
+
if not isinstance(findings, list):
|
|
113
|
+
raise ValueError("findings must resolve to an array")
|
|
114
|
+
parsed: list[dict] = []
|
|
115
|
+
for index, finding in enumerate(findings):
|
|
116
|
+
values = {field: scalar(finding, mapping[field], field) for field in MAPPING_FIELDS[1:]}
|
|
117
|
+
if not isinstance(values["rule"], str) or not values["rule"]:
|
|
118
|
+
raise ValueError(f"finding {index} rule must be a non-empty string")
|
|
119
|
+
if not isinstance(values["message"], str) or not values["message"]:
|
|
120
|
+
raise ValueError(f"finding {index} message must be a non-empty string")
|
|
121
|
+
if values["severity"] not in SEVERITY_ORDER:
|
|
122
|
+
raise ValueError(f"finding {index} severity must be info, warning or error")
|
|
123
|
+
parsed.append(
|
|
124
|
+
{
|
|
125
|
+
"rule": values["rule"],
|
|
126
|
+
"file": normalise_file(values["file"], root),
|
|
127
|
+
"line": values["line"],
|
|
128
|
+
"message": values["message"],
|
|
129
|
+
"severity": values["severity"],
|
|
130
|
+
}
|
|
131
|
+
)
|
|
132
|
+
return parsed
|
|
133
|
+
|
|
134
|
+
|
|
135
|
+
def index_findings(findings: list[dict], source: str) -> tuple[dict[tuple[str, str, str], dict], list[dict]]:
|
|
136
|
+
indexed: dict[tuple[str, str, str], dict] = {}
|
|
137
|
+
invalid: list[dict] = []
|
|
138
|
+
for finding in findings:
|
|
139
|
+
identity = (finding["rule"], finding["file"], finding["message"])
|
|
140
|
+
if identity in indexed:
|
|
141
|
+
invalid.append(
|
|
142
|
+
{
|
|
143
|
+
"source": source,
|
|
144
|
+
"identity": {"rule": identity[0], "file": identity[1], "message": identity[2]},
|
|
145
|
+
}
|
|
146
|
+
)
|
|
147
|
+
else:
|
|
148
|
+
indexed[identity] = finding
|
|
149
|
+
return indexed, invalid
|
|
150
|
+
|
|
151
|
+
|
|
152
|
+
def differential_gate(gate: dict, cwd: Path, diff_base: str | None, timeout: int) -> dict:
|
|
153
|
+
gate = dict(gate)
|
|
154
|
+
gate.update({"new": [], "aggravated": [], "resolved": [], "preexisting": [], "invalid": []})
|
|
155
|
+
try:
|
|
156
|
+
mapping = validate_mapping(gate.get("mapping"))
|
|
157
|
+
except ValueError as error:
|
|
158
|
+
gate["invalid"].append({"source": "config", "error": str(error)})
|
|
159
|
+
gate["status"] = "fail"
|
|
160
|
+
return gate
|
|
161
|
+
if not diff_base:
|
|
162
|
+
gate["invalid"].append({"source": "config", "error": "differential checks require --diff-base"})
|
|
163
|
+
gate["status"] = "fail"
|
|
164
|
+
return gate
|
|
165
|
+
|
|
166
|
+
base_root = Path(tempfile.mkdtemp(prefix="gantry-gates-"))
|
|
167
|
+
try:
|
|
168
|
+
code, output = sh(["git", "worktree", "add", "--detach", "--quiet", str(base_root), diff_base], cwd, timeout)
|
|
169
|
+
if code:
|
|
170
|
+
gate["invalid"].append({"source": "base", "error": f"could not create base worktree: {output.strip()}"})
|
|
171
|
+
gate["status"] = "fail"
|
|
172
|
+
return gate
|
|
173
|
+
base_code, base_output = sh(gate["command"], base_root, timeout)
|
|
174
|
+
delivery_code, delivery_output = sh(gate["command"], cwd, timeout)
|
|
175
|
+
gate["base_exit_code"] = base_code
|
|
176
|
+
gate["delivery_exit_code"] = delivery_code
|
|
177
|
+
try:
|
|
178
|
+
base_findings = parse_findings(base_output, mapping, base_root, "base")
|
|
179
|
+
except ValueError as error:
|
|
180
|
+
gate["invalid"].append({"source": "base", "error": str(error)})
|
|
181
|
+
gate["status"] = "fail"
|
|
182
|
+
return gate
|
|
183
|
+
try:
|
|
184
|
+
delivery_findings = parse_findings(delivery_output, mapping, cwd, "delivery")
|
|
185
|
+
except ValueError as error:
|
|
186
|
+
gate["invalid"].append({"source": "delivery", "error": str(error)})
|
|
187
|
+
gate["status"] = "fail"
|
|
188
|
+
return gate
|
|
189
|
+
|
|
190
|
+
base_index, base_invalid = index_findings(base_findings, "base")
|
|
191
|
+
delivery_index, delivery_invalid = index_findings(delivery_findings, "delivery")
|
|
192
|
+
gate["invalid"].extend(base_invalid + delivery_invalid)
|
|
193
|
+
if gate["invalid"]:
|
|
194
|
+
gate["status"] = "fail"
|
|
195
|
+
return gate
|
|
196
|
+
for identity in sorted(base_index.keys() | delivery_index.keys()):
|
|
197
|
+
base_finding = base_index.get(identity)
|
|
198
|
+
delivery_finding = delivery_index.get(identity)
|
|
199
|
+
if base_finding is None:
|
|
200
|
+
gate["new"].append(delivery_finding)
|
|
201
|
+
elif delivery_finding is None:
|
|
202
|
+
gate["resolved"].append(base_finding)
|
|
203
|
+
elif SEVERITY_ORDER[delivery_finding["severity"]] > SEVERITY_ORDER[base_finding["severity"]]:
|
|
204
|
+
gate["aggravated"].append(delivery_finding)
|
|
205
|
+
else:
|
|
206
|
+
gate["preexisting"].append(delivery_finding)
|
|
207
|
+
gate["status"] = "fail" if gate["new"] or gate["aggravated"] else "pass"
|
|
208
|
+
return gate
|
|
209
|
+
finally:
|
|
210
|
+
sh(["git", "worktree", "remove", "--force", str(base_root)], cwd, timeout)
|
|
211
|
+
shutil.rmtree(base_root, ignore_errors=True)
|
|
212
|
+
|
|
213
|
+
|
|
214
|
+
def package_manager(cwd: Path, package: dict) -> str:
|
|
215
|
+
requested = str(package.get("packageManager", ""))
|
|
216
|
+
for name in ("pnpm", "yarn", "bun", "npm"):
|
|
217
|
+
if requested.startswith(name):
|
|
218
|
+
return name
|
|
219
|
+
if (cwd / "pnpm-lock.yaml").exists():
|
|
220
|
+
return "pnpm"
|
|
221
|
+
if (cwd / "yarn.lock").exists():
|
|
222
|
+
return "yarn"
|
|
223
|
+
return "bun" if (cwd / "bun.lockb").exists() or (cwd / "bun.lock").exists() else "npm"
|
|
224
|
+
|
|
225
|
+
|
|
226
|
+
def detect(cwd: Path) -> dict:
|
|
227
|
+
gates: list[dict] = []
|
|
228
|
+
notes: list[str] = []
|
|
229
|
+
package_path = cwd / "package.json"
|
|
230
|
+
if package_path.exists():
|
|
231
|
+
try:
|
|
232
|
+
package = json.loads(package_path.read_text(encoding="utf-8"))
|
|
233
|
+
except json.JSONDecodeError as error:
|
|
234
|
+
package, _ = {}, notes.append(f"package.json is not valid JSON: {error}")
|
|
235
|
+
manager = package_manager(cwd, package)
|
|
236
|
+
scripts = package.get("scripts", {}) or {}
|
|
237
|
+
for name in NODE_SCRIPT_ORDER:
|
|
238
|
+
if name in scripts:
|
|
239
|
+
gates.append({"name": name, "source": "package.json", "command": f"{manager} run {name}"})
|
|
240
|
+
if scripts and not any(gate["name"] == "test" for gate in gates):
|
|
241
|
+
notes.append("package.json has scripts but no `test` script")
|
|
242
|
+
if not (cwd / "node_modules").exists():
|
|
243
|
+
notes.append("node_modules missing — install dependencies before running node gates")
|
|
244
|
+
if any((cwd / name).exists() for name in ("pyproject.toml", "pytest.ini", "setup.cfg", "tox.ini")):
|
|
245
|
+
config = (cwd / "pyproject.toml").read_text(encoding="utf-8") if (cwd / "pyproject.toml").exists() else ""
|
|
246
|
+
if "ruff" in config:
|
|
247
|
+
gates.append({"name": "ruff", "source": "pyproject.toml", "command": "ruff check ."})
|
|
248
|
+
if "mypy" in config:
|
|
249
|
+
gates.append({"name": "mypy", "source": "pyproject.toml", "command": "mypy ."})
|
|
250
|
+
if "pytest" in config or (cwd / "pytest.ini").exists() or (cwd / "tests").exists():
|
|
251
|
+
gates.append({"name": "pytest", "source": "pyproject.toml", "command": "pytest -q"})
|
|
252
|
+
makefile = cwd / "Makefile"
|
|
253
|
+
if makefile.exists():
|
|
254
|
+
targets = set(re.findall(r"^([a-zA-Z0-9_.-]+):", makefile.read_text(encoding="utf-8"), re.MULTILINE))
|
|
255
|
+
for name in MAKE_TARGETS:
|
|
256
|
+
if name in targets and not any(gate["name"] == name for gate in gates):
|
|
257
|
+
gates.append({"name": name, "source": "Makefile", "command": f"make {name}"})
|
|
258
|
+
if (cwd / ".pre-commit-config.yaml").exists():
|
|
259
|
+
gates.append({"name": "pre-commit", "source": ".pre-commit-config.yaml", "command": "pre-commit run --all-files"})
|
|
260
|
+
hooks: list[str] = []
|
|
261
|
+
if (cwd / ".husky").is_dir():
|
|
262
|
+
hooks.extend(f".husky/{path.name}" for path in (cwd / ".husky").iterdir() if path.is_file() and not path.name.startswith("_"))
|
|
263
|
+
for name in (".claude/settings.json", ".claude/settings.local.json"):
|
|
264
|
+
path = cwd / name
|
|
265
|
+
if path.exists():
|
|
266
|
+
try:
|
|
267
|
+
hooks.extend(f"{name}: {event} x{len(entries)}" for event, entries in (json.loads(path.read_text(encoding="utf-8")).get("hooks") or {}).items())
|
|
268
|
+
except json.JSONDecodeError:
|
|
269
|
+
notes.append(f"{name} is not valid JSON")
|
|
270
|
+
ci: list[dict] = []
|
|
271
|
+
workflows = cwd / ".github" / "workflows"
|
|
272
|
+
if workflows.is_dir():
|
|
273
|
+
for workflow in sorted(workflows.glob("*.y*ml")):
|
|
274
|
+
text = workflow.read_text(encoding="utf-8")
|
|
275
|
+
jobs: list[str] = []
|
|
276
|
+
marker = re.search(r"^jobs:\s*$", text, re.MULTILINE)
|
|
277
|
+
if marker:
|
|
278
|
+
for line in text[marker.end():].splitlines():
|
|
279
|
+
if line and not line.startswith((" ", "\t", "#")):
|
|
280
|
+
break
|
|
281
|
+
match = re.match(r"^ ([A-Za-z0-9_-]+):\s*$", line)
|
|
282
|
+
if match:
|
|
283
|
+
jobs.append(match.group(1))
|
|
284
|
+
commands = [item.strip() for item in re.findall(r"^\s*(?:-\s*)?run:\s*(.+)$", text, re.MULTILINE)]
|
|
285
|
+
ci.append({"workflow": str(workflow.relative_to(cwd)), "jobs": jobs, "run_steps": commands})
|
|
286
|
+
declared = {gate["name"] for gate in gates}
|
|
287
|
+
package_scripts = set()
|
|
288
|
+
if package_path.exists():
|
|
289
|
+
try:
|
|
290
|
+
package_scripts = set((json.loads(package_path.read_text(encoding="utf-8")).get("scripts") or {}).keys())
|
|
291
|
+
except json.JSONDecodeError:
|
|
292
|
+
pass
|
|
293
|
+
for workflow in ci:
|
|
294
|
+
for job in workflow["jobs"]:
|
|
295
|
+
if job in NODE_SCRIPT_ORDER and job not in declared:
|
|
296
|
+
notes.append(f"CI job `{job}` in {workflow['workflow']} has no matching package script")
|
|
297
|
+
for command in workflow["run_steps"]:
|
|
298
|
+
match = re.match(r"^(?:npm|pnpm|yarn|bun)\s+(?:run\s+)?([A-Za-z0-9:_-]+)", command)
|
|
299
|
+
if match and package_scripts and match.group(1) not in package_scripts and match.group(1) not in {"install", "ci", "test"}:
|
|
300
|
+
notes.append(f"CI runs `{command}` in {workflow['workflow']} but package.json has no `{match.group(1)}` script — CI and local commands have diverged")
|
|
301
|
+
return {"gates": gates, "ci": ci, "hooks": hooks, "notes": notes}
|
|
302
|
+
|
|
303
|
+
|
|
304
|
+
def declared_or_detected(cwd: Path) -> dict:
|
|
305
|
+
policy = resolve_policy(cwd)
|
|
306
|
+
checks = policy.get("checks", [])
|
|
307
|
+
if not checks:
|
|
308
|
+
return detect(cwd)
|
|
309
|
+
if not isinstance(checks, list):
|
|
310
|
+
return {"gates": [{"name": "policy", "invalid": [{"source": "config", "error": "checks must be a list"}]}],
|
|
311
|
+
"ci": [], "hooks": [], "notes": []}
|
|
312
|
+
gates: list[dict] = []
|
|
313
|
+
for index, check in enumerate(checks):
|
|
314
|
+
if not isinstance(check, dict):
|
|
315
|
+
gates.append({"name": f"check-{index + 1}", "invalid": [{"source": "config", "error": "check must be an object"}]})
|
|
316
|
+
continue
|
|
317
|
+
gate = dict(check)
|
|
318
|
+
gate["source"] = ".gantry/config.json"
|
|
319
|
+
if not isinstance(gate.get("name"), str) or not gate["name"]:
|
|
320
|
+
gate["name"] = f"check-{index + 1}"
|
|
321
|
+
gate["invalid"] = [{"source": "config", "error": "check name must be a non-empty string"}]
|
|
322
|
+
elif not isinstance(gate.get("command"), str) or not gate["command"]:
|
|
323
|
+
gate["invalid"] = [{"source": "config", "error": "check command must be a non-empty string"}]
|
|
324
|
+
elif gate.get("mode") not in {"absolute", "differential"}:
|
|
325
|
+
gate["invalid"] = [{"source": "config", "error": "check mode must be absolute or differential"}]
|
|
326
|
+
gates.append(gate)
|
|
327
|
+
return {"gates": gates, "ci": [], "hooks": [], "notes": []}
|
|
328
|
+
|
|
329
|
+
|
|
330
|
+
def git_facts(cwd: Path, diff_base: str | None) -> dict:
|
|
331
|
+
facts = {"is_git": (cwd / ".git").exists() or shutil.which("git") is not None}
|
|
332
|
+
code, output = sh(["git", "status", "--porcelain"], cwd, 30)
|
|
333
|
+
facts["tree_clean"] = code == 0 and not output.strip()
|
|
334
|
+
facts["dirty_files"] = [line[3:] for line in output.splitlines()] if code == 0 else []
|
|
335
|
+
code, output = sh(["git", "branch", "--show-current"], cwd, 30)
|
|
336
|
+
facts["branch"] = output.strip() if code == 0 else None
|
|
337
|
+
changed: list[str] = []
|
|
338
|
+
if diff_base:
|
|
339
|
+
code, output = sh(["git", "diff", "--name-only", f"{diff_base}...HEAD"], cwd, 60)
|
|
340
|
+
if code:
|
|
341
|
+
code, output = sh(["git", "diff", "--name-only", diff_base, "HEAD"], cwd, 60)
|
|
342
|
+
changed = [line for line in output.splitlines() if line.strip()] if code == 0 else []
|
|
343
|
+
facts.update({"diff_base": diff_base, "diff_error": None if code == 0 else output.strip()[:500]})
|
|
344
|
+
facts["changed_files"] = changed
|
|
345
|
+
facts["frontend_touched"] = any(FRONTEND_RE.search(path) for path in changed)
|
|
346
|
+
return facts
|
|
347
|
+
|
|
348
|
+
|
|
349
|
+
def main() -> int:
|
|
350
|
+
parser = argparse.ArgumentParser(description=__doc__)
|
|
351
|
+
parser.add_argument("--cwd", default=".", help="repository or worktree to inspect")
|
|
352
|
+
parser.add_argument("--run", action="store_true", help="execute detected gates")
|
|
353
|
+
parser.add_argument("--diff-base", help="Git ref to diff against")
|
|
354
|
+
parser.add_argument("--timeout", type=int, default=900, help="seconds per gate")
|
|
355
|
+
parser.add_argument("--only", help="comma-separated gate names")
|
|
356
|
+
parser.add_argument("--tail", type=int, default=60, help="output lines per gate")
|
|
357
|
+
parser.add_argument("--json", action="store_true")
|
|
358
|
+
args = parser.parse_args()
|
|
359
|
+
cwd = Path(args.cwd).resolve()
|
|
360
|
+
try:
|
|
361
|
+
result = declared_or_detected(cwd)
|
|
362
|
+
except ValueError as error:
|
|
363
|
+
result = {"gates": [{"name": "policy", "invalid": [{"source": "config", "error": str(error)}]}],
|
|
364
|
+
"ci": [], "hooks": [], "notes": []}
|
|
365
|
+
result.update({"cwd": str(cwd), "git": git_facts(cwd, args.diff_base)})
|
|
366
|
+
only = {item.strip() for item in args.only.split(",")} if args.only else None
|
|
367
|
+
failures = 0
|
|
368
|
+
for index, gate in enumerate(result["gates"]):
|
|
369
|
+
if only and gate["name"] not in only:
|
|
370
|
+
gate["status"] = "skipped"
|
|
371
|
+
elif not args.run:
|
|
372
|
+
gate["status"] = "not_run"
|
|
373
|
+
elif gate.get("invalid"):
|
|
374
|
+
gate["status"] = "fail"
|
|
375
|
+
failures += 1
|
|
376
|
+
elif gate.get("mode") == "differential":
|
|
377
|
+
gate = differential_gate(gate, cwd, args.diff_base, args.timeout)
|
|
378
|
+
result["gates"][index] = gate
|
|
379
|
+
failures += gate["status"] == "fail"
|
|
380
|
+
else:
|
|
381
|
+
code, output = sh(gate["command"], cwd, args.timeout)
|
|
382
|
+
gate.update({"exit_code": code, "output_tail": "\n".join(output.splitlines()[-args.tail:]), "status": "pass" if code == 0 else "fail"})
|
|
383
|
+
failures += code != 0
|
|
384
|
+
result["verdict"] = "no_gates" if not result["gates"] else ("not_run" if not args.run else ("fail" if failures else "pass"))
|
|
385
|
+
result["requirements"] = []
|
|
386
|
+
if result["git"]["frontend_touched"]:
|
|
387
|
+
result["requirements"].append("frontend files changed: AGENTS.md requires Playwright validation before completion")
|
|
388
|
+
if not result["git"]["tree_clean"]:
|
|
389
|
+
result["requirements"].append("working tree is dirty: uncommitted changes are not part of the delivery")
|
|
390
|
+
if args.json:
|
|
391
|
+
print(json.dumps(result, indent=2))
|
|
392
|
+
else:
|
|
393
|
+
print(f"cwd: {cwd} branch: {result['git'].get('branch')} tree_clean: {result['git']['tree_clean']}")
|
|
394
|
+
for gate in result["gates"]:
|
|
395
|
+
print(f" [{gate['status']}] {gate['name']}: {gate['command']}")
|
|
396
|
+
print(f"verdict: {result['verdict']}")
|
|
397
|
+
return {"pass": 0, "fail": 1, "no_gates": 2, "not_run": 0}[result["verdict"]]
|
|
398
|
+
|
|
399
|
+
|
|
400
|
+
if __name__ == "__main__":
|
|
401
|
+
sys.exit(main())
|