archagent 0.1.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.
archagent/__init__.py ADDED
@@ -0,0 +1,5 @@
1
+ """archagent — keep code adherent to a described architecture."""
2
+
3
+ from .cli import main
4
+
5
+ __all__ = ["main"]
archagent/check.py ADDED
@@ -0,0 +1,244 @@
1
+ """Run the generated checkers and map their results back to invariant IDs.
2
+
3
+ The LLM proposes invariants; these deterministic tools are the trusted checkers.
4
+ A unified report is produced so a violation always points at a specific invariant.
5
+ """
6
+
7
+ from __future__ import annotations
8
+
9
+ import json
10
+ import os
11
+ import re
12
+ import shlex
13
+ import shutil
14
+ import subprocess
15
+ import sys
16
+ from dataclasses import dataclass, field
17
+ from pathlib import Path
18
+
19
+ from .config import Config
20
+ from .invariants import Invariant
21
+ from .rules import parse_property
22
+
23
+
24
+ @dataclass
25
+ class Finding:
26
+ file: str
27
+ line: int
28
+ detail: str
29
+
30
+
31
+ @dataclass
32
+ class CheckResult:
33
+ invariant_id: str
34
+ checker: str
35
+ passed: bool
36
+ severity: str = "error"
37
+ skipped_reason: str | None = None
38
+ findings: list[Finding] = field(default_factory=list)
39
+
40
+
41
+ def run_checks(
42
+ invariants: list[Invariant],
43
+ config: Config,
44
+ il_ids: list[str],
45
+ dc_ids: list[str],
46
+ sg_ids: list[str],
47
+ pbt_ids: list[str],
48
+ ) -> list[CheckResult]:
49
+ by_id = {inv.id: inv for inv in invariants}
50
+ results: list[CheckResult] = []
51
+ if il_ids:
52
+ results.extend(_run_import_linter(il_ids, config, by_id))
53
+ if dc_ids:
54
+ results.extend(_run_dependency_cruiser(dc_ids, config, by_id))
55
+ if sg_ids:
56
+ results.extend(_run_ast_grep(sg_ids, config, by_id))
57
+ if pbt_ids:
58
+ results.extend(_run_pbt(pbt_ids, config, by_id))
59
+ return results
60
+
61
+
62
+ # --- import-linter -------------------------------------------------------
63
+
64
+ def _run_import_linter(ids, config, by_id) -> list[CheckResult]:
65
+ il_config = config.generated_dir / ".importlinter"
66
+ tool = _tool_path("lint-imports")
67
+ env = dict(os.environ)
68
+ src = os.pathsep.join(str((config.project_root / p).resolve()) for p in config.python.source_paths)
69
+ env["PYTHONPATH"] = src + (os.pathsep + env["PYTHONPATH"] if env.get("PYTHONPATH") else "")
70
+ proc = subprocess.run(
71
+ [tool, "--config", str(il_config)],
72
+ cwd=config.project_root, env=env, capture_output=True, text=True,
73
+ )
74
+ out = proc.stdout + "\n" + proc.stderr
75
+ statuses = dict(re.findall(r"^(\S+)\s+(KEPT|BROKEN)\s*$", out, re.MULTILINE))
76
+ if not statuses: # import-linter errored before checking (e.g. an invalid contract)
77
+ lines = [ln.strip() for ln in out.splitlines() if ln.strip() and not set(ln.strip()) <= set("╔╗╚╝║═╠╣╩╦╬▶◀│└┐┘┌ ")]
78
+ reason = (lines[-1] if lines else "import-linter produced no results")[:100]
79
+ return [CheckResult(i, "import-linter", True, by_id[i].severity, skipped_reason=reason) for i in ids]
80
+ details = _parse_broken_details(out)
81
+ return [
82
+ CheckResult(
83
+ invariant_id=i, checker="import-linter",
84
+ passed=statuses.get(i) == "KEPT", severity=by_id[i].severity,
85
+ findings=details.get(i, []),
86
+ )
87
+ for i in ids
88
+ ]
89
+
90
+
91
+ def _parse_broken_details(out: str) -> dict[str, list[Finding]]:
92
+ details: dict[str, list[Finding]] = {}
93
+ current: str | None = None
94
+ in_broken = False
95
+ for line in out.splitlines():
96
+ if line.strip() == "Broken contracts":
97
+ in_broken = True
98
+ continue
99
+ if not in_broken:
100
+ continue
101
+ stripped = line.strip()
102
+ if re.fullmatch(r"\S+", stripped) and not stripped.startswith("-"):
103
+ current = stripped
104
+ details.setdefault(current, [])
105
+ elif stripped.startswith("- ") and current:
106
+ m = re.search(r"\(l\.(\d+)\)", stripped)
107
+ details[current].append(Finding("", int(m.group(1)) if m else 0, stripped[2:]))
108
+ return {k: v for k, v in details.items() if v}
109
+
110
+
111
+ # --- dependency-cruiser --------------------------------------------------
112
+
113
+ def _run_dependency_cruiser(ids, config, by_id) -> list[CheckResult]:
114
+ cfg = config.generated_dir / ".dependency-cruiser.cjs"
115
+ npx = _tool_path("npx", required=False)
116
+ if not npx:
117
+ return [CheckResult(i, "dependency-cruiser", True, by_id[i].severity,
118
+ skipped_reason="npx/node not found") for i in ids]
119
+ cmd = [npx, "--yes", "--package=dependency-cruiser", "--package=typescript",
120
+ "depcruise", "--config", str(cfg), "--output-type", "json", *config.ts.source_paths]
121
+ proc = subprocess.run(cmd, cwd=config.project_root, capture_output=True, text=True, timeout=180)
122
+ try:
123
+ data = json.loads(proc.stdout or "{}")
124
+ except json.JSONDecodeError:
125
+ reason = (proc.stderr or proc.stdout or "depcruise produced no JSON").strip().splitlines()[-1:][0:1]
126
+ return [CheckResult(i, "dependency-cruiser", True, by_id[i].severity,
127
+ skipped_reason=(reason[0] if reason else "depcruise error")[:80]) for i in ids]
128
+ by: dict[str, list[Finding]] = {i: [] for i in ids}
129
+ for v in data.get("summary", {}).get("violations", []):
130
+ name = v.get("rule", {}).get("name")
131
+ if name in by:
132
+ by[name].append(Finding(v.get("from", ""), 0, f'{v.get("from")} -> {v.get("to")}'))
133
+ return [CheckResult(i, "dependency-cruiser", not by[i], by_id[i].severity, findings=by[i]) for i in ids]
134
+
135
+
136
+ # --- ast-grep ------------------------------------------------------------
137
+
138
+ def _run_ast_grep(ids, config, by_id) -> list[CheckResult]:
139
+ sgconfig = config.generated_dir / "sgconfig.yml"
140
+ tool = _tool_path("ast-grep")
141
+ paths = [p for p in config.all_source_paths() if (config.project_root / p).exists()]
142
+ proc = subprocess.run(
143
+ [tool, "scan", "-c", str(sgconfig), "--json", *paths],
144
+ cwd=config.project_root, capture_output=True, text=True,
145
+ )
146
+ by: dict[str, list[Finding]] = {i: [] for i in ids}
147
+ try:
148
+ matches = json.loads(proc.stdout or "[]")
149
+ except json.JSONDecodeError:
150
+ matches = []
151
+ for m in matches:
152
+ rid = m.get("ruleId")
153
+ if rid in by:
154
+ start = m.get("range", {}).get("start", {})
155
+ by[rid].append(Finding(
156
+ _rel(m.get("file", ""), config.project_root),
157
+ int(start.get("line", 0)) + 1,
158
+ (m.get("text", "") or "").splitlines()[0][:80],
159
+ ))
160
+ return [CheckResult(i, "ast-grep", not by[i], by_id[i].severity, findings=by[i]) for i in ids]
161
+
162
+
163
+ # --- property-based tests (pbt) ------------------------------------------
164
+
165
+ _JS_EXTS = (".ts", ".tsx", ".js", ".jsx", ".mjs", ".cjs")
166
+
167
+
168
+ def _run_pbt(ids, config, by_id) -> list[CheckResult]:
169
+ # Behavioral properties execute the project's code, so they run in the TARGET's
170
+ # environment (the language's test_command), not archagent's venv.
171
+ env = dict(os.environ)
172
+ env.pop("VIRTUAL_ENV", None) # let the target's runner pick its own env
173
+ results = []
174
+ for i in ids:
175
+ target = parse_property(by_id[i].rule).target
176
+ rel = target.split("::", 1)[0]
177
+ if rel.endswith(_JS_EXTS):
178
+ results.append(_run_pbt_js(i, target, rel, config, by_id, env))
179
+ else:
180
+ results.append(_run_pbt_py(i, target, config, by_id, env))
181
+ return results
182
+
183
+
184
+ def _run_pbt_py(i, target, config, by_id, env) -> CheckResult:
185
+ test_cmd = shlex.split(config.python.test_command) # e.g. "uv run pytest"
186
+ try:
187
+ proc = subprocess.run(
188
+ [*test_cmd, target, "-q", "--no-header", "-p", "no:cacheprovider"],
189
+ cwd=config.project_root, env=env, capture_output=True, text=True, timeout=300,
190
+ )
191
+ except FileNotFoundError:
192
+ return CheckResult(i, "pbt", True, by_id[i].severity, skipped_reason=f"test runner not found: {test_cmd[0]}")
193
+ out = proc.stdout + "\n" + proc.stderr
194
+ if proc.returncode == 0:
195
+ return CheckResult(i, "pbt", True, by_id[i].severity)
196
+ if proc.returncode == 5: # pytest: no tests collected
197
+ return CheckResult(i, "pbt", True, by_id[i].severity, skipped_reason=f"property not found: {target}")
198
+ return CheckResult(i, "pbt", False, by_id[i].severity, findings=[Finding("", 0, _pbt_failure(out))])
199
+
200
+
201
+ def _run_pbt_js(i, target, rel, config, by_id, env) -> CheckResult:
202
+ test_cmd = shlex.split(config.ts.test_command) # e.g. "npx vitest run"
203
+ name = target.split("::", 1)[1] if "::" in target else None
204
+ cmd = [*test_cmd, rel, *(["-t", name] if name else [])] # vitest & jest both accept -t <name>
205
+ try:
206
+ proc = subprocess.run(cmd, cwd=config.project_root, env=env, capture_output=True, text=True, timeout=300)
207
+ except FileNotFoundError:
208
+ return CheckResult(i, "pbt", True, by_id[i].severity, skipped_reason=f"test runner not found: {test_cmd[0]}")
209
+ out = proc.stdout + "\n" + proc.stderr
210
+ if proc.returncode == 0:
211
+ return CheckResult(i, "pbt", True, by_id[i].severity)
212
+ return CheckResult(i, "pbt", False, by_id[i].severity, findings=[Finding("", 0, _pbt_failure(out))])
213
+
214
+
215
+ def _pbt_failure(out: str) -> str:
216
+ for line in out.splitlines():
217
+ if "Falsifying example" in line or "Counterexample" in line: # Hypothesis / fast-check
218
+ return line.strip()[:120]
219
+ for line in reversed(out.splitlines()):
220
+ s = line.strip()
221
+ if s.startswith(("E ", "assert", "FAILED")) or "Error" in s or "Property failed" in s:
222
+ return s[:120]
223
+ return "property failed"
224
+
225
+
226
+ # --- helpers -------------------------------------------------------------
227
+
228
+ def _rel(path: str, root: Path) -> str:
229
+ try:
230
+ return str(Path(path).resolve().relative_to(root.resolve()))
231
+ except ValueError:
232
+ return path
233
+
234
+
235
+ def _tool_path(name: str, required: bool = True) -> str | None:
236
+ found = shutil.which(name)
237
+ if found:
238
+ return found
239
+ candidate = Path(sys.executable).parent / name
240
+ if candidate.exists():
241
+ return str(candidate)
242
+ if required:
243
+ raise FileNotFoundError(f"Could not find '{name}' on PATH or next to the interpreter")
244
+ return None