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 +5 -0
- archagent/check.py +244 -0
- archagent/cli.py +448 -0
- archagent/cochange.py +79 -0
- archagent/config.py +88 -0
- archagent/configscan.py +62 -0
- archagent/connscan.py +140 -0
- archagent/datamap.py +79 -0
- archagent/deployscan.py +119 -0
- archagent/drift.py +574 -0
- archagent/evaluate.py +745 -0
- archagent/generate.py +337 -0
- archagent/init.py +204 -0
- archagent/invariants.py +84 -0
- archagent/invscan.py +118 -0
- archagent/mdutil.py +42 -0
- archagent/obsscan.py +42 -0
- archagent/rules.py +104 -0
- archagent/templates/agent/AGENTS.md +25 -0
- archagent/templates/agent/phases/check.md +14 -0
- archagent/templates/agent/phases/describe.md +168 -0
- archagent/templates/agent/phases/evaluate.md +78 -0
- archagent/templates/agent/phases/help.md +27 -0
- archagent/templates/agent/phases/invariant.md +33 -0
- archagent/templates/architecture/constitution.md +21 -0
- archagent/templates/architecture/decisions/0001-record-architecture-decisions.md +18 -0
- archagent/templates/architecture/deployment.md +35 -0
- archagent/templates/architecture/index.md +9 -0
- archagent/templates/architecture/invariants.md +30 -0
- archagent/templates/architecture/log.md +6 -0
- archagent/templates/architecture/subsystems/_TEMPLATE.md +62 -0
- archagent/webapi.py +183 -0
- archagent-0.1.0.dist-info/METADATA +373 -0
- archagent-0.1.0.dist-info/RECORD +37 -0
- archagent-0.1.0.dist-info/WHEEL +4 -0
- archagent-0.1.0.dist-info/entry_points.txt +2 -0
- archagent-0.1.0.dist-info/licenses/LICENSE +201 -0
archagent/__init__.py
ADDED
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
|