agent-harness-kit 0.3.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/.claude-plugin/marketplace.json +27 -0
- package/.claude-plugin/plugin.json +25 -0
- package/LICENSE +21 -0
- package/README.md +165 -0
- package/bin/cli.mjs +261 -0
- package/package.json +64 -0
- package/src/core/detect-stack.mjs +181 -0
- package/src/core/doctor.mjs +106 -0
- package/src/core/patch-package-json.mjs +53 -0
- package/src/core/render-templates.mjs +277 -0
- package/src/core/upgrade.mjs +274 -0
- package/src/templates/.claude/agents/api-consistency-reviewer.md +33 -0
- package/src/templates/.claude/agents/architecture-reviewer.md.hbs +41 -0
- package/src/templates/.claude/agents/performance-reviewer.md +35 -0
- package/src/templates/.claude/agents/reliability-reviewer.md +38 -0
- package/src/templates/.claude/agents/security-reviewer.md +39 -0
- package/src/templates/.claude/hooks/hooks.json.hbs +39 -0
- package/src/templates/.claude/settings.json.hbs +25 -0
- package/src/templates/.claude/skills/add-adr/SKILL.md +60 -0
- package/src/templates/.claude/skills/add-feature/SKILL.md.hbs +50 -0
- package/src/templates/.claude/skills/debug-flow/SKILL.md.hbs +38 -0
- package/src/templates/.claude/skills/doc-drift-scan/SKILL.md +43 -0
- package/src/templates/.claude/skills/eval-runner/SKILL.md +55 -0
- package/src/templates/.claude/skills/garbage-collection/SKILL.md.hbs +49 -0
- package/src/templates/.claude/skills/inspect-app/SKILL.md +57 -0
- package/src/templates/.claude/skills/inspect-module/SKILL.md.hbs +53 -0
- package/src/templates/.claude/skills/propose-harness-improvement/SKILL.md +43 -0
- package/src/templates/.claude/skills/structural-test-author/SKILL.md.hbs +46 -0
- package/src/templates/.claude/skills/write-skill/SKILL.md +39 -0
- package/src/templates/CLAUDE.md.hbs +70 -0
- package/src/templates/_adapter-python/.importlinter +14 -0
- package/src/templates/_adapter-python/harness/__init__.py +0 -0
- package/src/templates/_adapter-python/harness/eval_runner.py +281 -0
- package/src/templates/_adapter-python/harness/structural_test.py +195 -0
- package/src/templates/_adapter-typescript/.dependency-cruiser.cjs +27 -0
- package/src/templates/_adapter-typescript/eslint.config.mjs +38 -0
- package/src/templates/_adapter-typescript/harness/eval-runner.mjs +322 -0
- package/src/templates/_adapter-typescript/harness/structural-test.mjs +125 -0
- package/src/templates/_ci/.github/workflows/eval-nightly.yml +59 -0
- package/src/templates/_ci/.github/workflows/harness.yml +55 -0
- package/src/templates/docs/adr/0001-use-agent-harness-kit.md.hbs +56 -0
- package/src/templates/docs/agent-failures.md +25 -0
- package/src/templates/docs/architecture.md.hbs +47 -0
- package/src/templates/docs/core-beliefs.md.hbs +41 -0
- package/src/templates/docs/golden-principles.md.hbs +80 -0
- package/src/templates/docs/tech-debt-tracker.md +30 -0
- package/src/templates/feature_list.json.hbs +29 -0
- package/src/templates/harness.config.json.hbs +40 -0
- package/src/templates/scripts/dev-up.sh.hbs +51 -0
- package/src/templates/scripts/harness-report.mjs +189 -0
- package/src/templates/scripts/install-git-hooks.sh +18 -0
- package/src/templates/scripts/pre-push.sh +21 -0
- package/src/templates/scripts/precompletion-checklist.sh.hbs +99 -0
- package/src/templates/scripts/structural-test-on-edit.sh.hbs +53 -0
- package/src/templates/scripts/telemetry-on-skill.sh +26 -0
|
@@ -0,0 +1,281 @@
|
|
|
1
|
+
"""Drive Claude Code through .harness/eval/tasks/*.json and grade each on
|
|
2
|
+
outcome / process / style / efficiency.
|
|
3
|
+
|
|
4
|
+
Per-task JSONL row goes to .harness/eval/results/<sha>.jsonl. On regression
|
|
5
|
+
(any task failing in CI), exit 1 so the workflow blocks merge.
|
|
6
|
+
|
|
7
|
+
Transports:
|
|
8
|
+
--transport=claude-cli spawn `claude -p` and capture stream-json (default)
|
|
9
|
+
--transport=mock synthetic transcript — use in CI smoke-tests, no API key needed
|
|
10
|
+
|
|
11
|
+
Sets:
|
|
12
|
+
--quick first 3 tasks (~$0.30, ~2 min on Sonnet)
|
|
13
|
+
--full all tasks (~$2, ~15 min)
|
|
14
|
+
--tasks <glob> custom set
|
|
15
|
+
|
|
16
|
+
Usage::
|
|
17
|
+
|
|
18
|
+
python -m harness.eval_runner --quick
|
|
19
|
+
python -m harness.eval_runner --full --transport=mock
|
|
20
|
+
python -m harness.eval_runner --tasks 01-trivial-endpoint.json
|
|
21
|
+
"""
|
|
22
|
+
from __future__ import annotations
|
|
23
|
+
|
|
24
|
+
import argparse
|
|
25
|
+
import json
|
|
26
|
+
import os
|
|
27
|
+
import subprocess
|
|
28
|
+
import sys
|
|
29
|
+
from datetime import datetime, timezone
|
|
30
|
+
from pathlib import Path
|
|
31
|
+
from typing import Callable
|
|
32
|
+
|
|
33
|
+
ROOT = Path.cwd()
|
|
34
|
+
TASKS_DIR = ROOT / ".harness" / "eval" / "tasks"
|
|
35
|
+
RESULTS_DIR = ROOT / ".harness" / "eval" / "results"
|
|
36
|
+
|
|
37
|
+
|
|
38
|
+
def _git_sha() -> str:
|
|
39
|
+
try:
|
|
40
|
+
return subprocess.check_output(
|
|
41
|
+
["git", "rev-parse", "--short", "HEAD"], stderr=subprocess.DEVNULL
|
|
42
|
+
).decode().strip()
|
|
43
|
+
except Exception:
|
|
44
|
+
return "no-git"
|
|
45
|
+
|
|
46
|
+
|
|
47
|
+
def _load_tasks(args: argparse.Namespace) -> list[dict]:
|
|
48
|
+
if not TASKS_DIR.exists():
|
|
49
|
+
print(f"No tasks directory at {TASKS_DIR}. Run `agent-harness-kit init` first.", file=sys.stderr)
|
|
50
|
+
sys.exit(1)
|
|
51
|
+
files = sorted(p for p in TASKS_DIR.glob("*.json"))
|
|
52
|
+
if args.tasks:
|
|
53
|
+
files = [p for p in files if args.tasks == p.name or args.tasks in p.name]
|
|
54
|
+
elif args.quick:
|
|
55
|
+
files = files[:3]
|
|
56
|
+
out = []
|
|
57
|
+
for f in files:
|
|
58
|
+
t = json.loads(f.read_text())
|
|
59
|
+
t["_file"] = str(f)
|
|
60
|
+
out.append(t)
|
|
61
|
+
return out
|
|
62
|
+
|
|
63
|
+
|
|
64
|
+
# ---- transports ----
|
|
65
|
+
|
|
66
|
+
def _transport_claude_cli(task: dict) -> dict:
|
|
67
|
+
"""Spawn `claude -p` with stream-json output and flatten the wire format
|
|
68
|
+
into the same shape the mock transport produces (so the graders don't
|
|
69
|
+
have to know about both shapes).
|
|
70
|
+
|
|
71
|
+
Real wire format (Claude Code 2.1.x)::
|
|
72
|
+
|
|
73
|
+
{type:"assistant", message:{content:[{type:"tool_use", name, input}]}}
|
|
74
|
+
{type:"result", usage:{input_tokens, output_tokens, cache_*}}
|
|
75
|
+
|
|
76
|
+
Flat shape graders consume::
|
|
77
|
+
|
|
78
|
+
{type:"tool_use", tool:<name>, path:<input.file_path|input.path>}
|
|
79
|
+
{type:"token_usage", total:<sum of all token fields>}
|
|
80
|
+
"""
|
|
81
|
+
proc = subprocess.run(
|
|
82
|
+
[
|
|
83
|
+
"claude",
|
|
84
|
+
"-p",
|
|
85
|
+
task["input"],
|
|
86
|
+
"--output-format",
|
|
87
|
+
"stream-json",
|
|
88
|
+
"--verbose",
|
|
89
|
+
"--max-turns",
|
|
90
|
+
"20",
|
|
91
|
+
],
|
|
92
|
+
capture_output=True,
|
|
93
|
+
text=True,
|
|
94
|
+
check=False,
|
|
95
|
+
)
|
|
96
|
+
events: list[dict] = []
|
|
97
|
+
for line in proc.stdout.splitlines():
|
|
98
|
+
line = line.strip()
|
|
99
|
+
if not line:
|
|
100
|
+
continue
|
|
101
|
+
try:
|
|
102
|
+
raw = json.loads(line)
|
|
103
|
+
except json.JSONDecodeError:
|
|
104
|
+
continue
|
|
105
|
+
events.append({"type": raw.get("type"), "raw": raw})
|
|
106
|
+
# Flatten tool_use blocks from assistant messages.
|
|
107
|
+
if raw.get("type") == "assistant" and raw.get("message", {}).get("content"):
|
|
108
|
+
for block in raw["message"]["content"]:
|
|
109
|
+
if block.get("type") != "tool_use":
|
|
110
|
+
continue
|
|
111
|
+
# /skill invocations come in as the Skill tool with input.skill.
|
|
112
|
+
if block.get("name") == "Skill" and block.get("input", {}).get("skill"):
|
|
113
|
+
events.append({"type": "tool_use", "tool": block["input"]["skill"]})
|
|
114
|
+
path = block.get("input", {}).get("file_path") or block.get("input", {}).get("path")
|
|
115
|
+
events.append({"type": "tool_use", "tool": block.get("name"), "path": path})
|
|
116
|
+
# Final result has aggregated usage.
|
|
117
|
+
if raw.get("type") == "result" and raw.get("usage"):
|
|
118
|
+
u = raw["usage"]
|
|
119
|
+
total = (
|
|
120
|
+
u.get("input_tokens", 0)
|
|
121
|
+
+ u.get("output_tokens", 0)
|
|
122
|
+
+ u.get("cache_creation_input_tokens", 0)
|
|
123
|
+
+ u.get("cache_read_input_tokens", 0)
|
|
124
|
+
)
|
|
125
|
+
events.append({"type": "token_usage", "total": total})
|
|
126
|
+
if proc.returncode != 0:
|
|
127
|
+
return {"events": events, "stderr": proc.stderr[:500], "error": True}
|
|
128
|
+
return {"events": events, "stderr": proc.stderr}
|
|
129
|
+
|
|
130
|
+
|
|
131
|
+
def _transport_mock(task: dict) -> dict:
|
|
132
|
+
"""Synthetic transcript that satisfies the default expectations."""
|
|
133
|
+
expected = task.get("expected", {})
|
|
134
|
+
events = []
|
|
135
|
+
for skill in expected.get("skillsInvoked", []):
|
|
136
|
+
events.append({"type": "tool_use", "tool": skill})
|
|
137
|
+
min_files = (expected.get("filesChanged") or {}).get("min", 1)
|
|
138
|
+
for i in range(min_files):
|
|
139
|
+
events.append({"type": "tool_use", "tool": "Write", "path": f"app/mock_{i}.py"})
|
|
140
|
+
events.append({"type": "token_usage", "total": min(expected.get("tokensMax", 5000), 5000)})
|
|
141
|
+
return {"events": events, "stderr": ""}
|
|
142
|
+
|
|
143
|
+
|
|
144
|
+
TRANSPORTS: dict[str, Callable[[dict], dict]] = {
|
|
145
|
+
"claude-cli": _transport_claude_cli,
|
|
146
|
+
"mock": _transport_mock,
|
|
147
|
+
}
|
|
148
|
+
|
|
149
|
+
|
|
150
|
+
# ---- graders ----
|
|
151
|
+
|
|
152
|
+
def _grade_outcome(task: dict) -> dict | None:
|
|
153
|
+
if task.get("expected", {}).get("structuralTest") != "pass":
|
|
154
|
+
return None
|
|
155
|
+
rc = subprocess.run(
|
|
156
|
+
["python", "-m", "harness.structural_test"],
|
|
157
|
+
stdout=subprocess.DEVNULL, stderr=subprocess.DEVNULL,
|
|
158
|
+
).returncode
|
|
159
|
+
return {
|
|
160
|
+
"dim": "outcome",
|
|
161
|
+
"score": 1 if rc == 0 else 0,
|
|
162
|
+
"info": "structural test passed" if rc == 0 else "structural test failed",
|
|
163
|
+
}
|
|
164
|
+
|
|
165
|
+
|
|
166
|
+
def _grade_process(task: dict, transcript: dict) -> dict | None:
|
|
167
|
+
expected = task.get("expected", {}).get("skillsInvoked", [])
|
|
168
|
+
if not expected:
|
|
169
|
+
return None
|
|
170
|
+
invoked = {e.get("tool") for e in transcript["events"] if e.get("type") == "tool_use"}
|
|
171
|
+
missing = [s for s in expected if s not in invoked]
|
|
172
|
+
return {
|
|
173
|
+
"dim": "process",
|
|
174
|
+
"score": 1 if not missing else 0,
|
|
175
|
+
"info": "all expected skills invoked" if not missing else f"missing skills: {', '.join(missing)}",
|
|
176
|
+
}
|
|
177
|
+
|
|
178
|
+
|
|
179
|
+
def _grade_style(task: dict, transcript: dict) -> dict | None:
|
|
180
|
+
rng = task.get("expected", {}).get("filesChanged")
|
|
181
|
+
if not rng:
|
|
182
|
+
return None
|
|
183
|
+
writes = [
|
|
184
|
+
e for e in transcript["events"]
|
|
185
|
+
if e.get("type") == "tool_use" and e.get("tool") in ("Write", "Edit", "MultiEdit")
|
|
186
|
+
]
|
|
187
|
+
distinct = len({e.get("path") for e in writes if e.get("path")})
|
|
188
|
+
ok = rng["min"] <= distinct <= rng["max"]
|
|
189
|
+
return {
|
|
190
|
+
"dim": "style",
|
|
191
|
+
"score": 1 if ok else 0,
|
|
192
|
+
"info": f"{distinct} files changed (expected {rng['min']}-{rng['max']})",
|
|
193
|
+
}
|
|
194
|
+
|
|
195
|
+
|
|
196
|
+
def _grade_efficiency(task: dict, transcript: dict) -> dict | None:
|
|
197
|
+
cap = task.get("expected", {}).get("tokensMax")
|
|
198
|
+
if not cap:
|
|
199
|
+
return None
|
|
200
|
+
tokens = sum(e.get("total", 0) for e in transcript["events"] if e.get("type") == "token_usage")
|
|
201
|
+
return {
|
|
202
|
+
"dim": "efficiency",
|
|
203
|
+
"score": 1 if tokens <= cap else 0,
|
|
204
|
+
"info": f"{tokens} tokens (cap {cap})",
|
|
205
|
+
}
|
|
206
|
+
|
|
207
|
+
|
|
208
|
+
def run_eval(args: argparse.Namespace) -> dict:
|
|
209
|
+
tasks = _load_tasks(args)
|
|
210
|
+
if not tasks:
|
|
211
|
+
print("No tasks matched.", file=sys.stderr)
|
|
212
|
+
return {"results": [], "passed": 0}
|
|
213
|
+
transport = TRANSPORTS.get(args.transport)
|
|
214
|
+
if transport is None:
|
|
215
|
+
print(
|
|
216
|
+
f"Unknown transport: {args.transport}. Try: {', '.join(TRANSPORTS)}",
|
|
217
|
+
file=sys.stderr,
|
|
218
|
+
)
|
|
219
|
+
sys.exit(2)
|
|
220
|
+
|
|
221
|
+
sha = _git_sha()
|
|
222
|
+
out_path = Path(args.out) if args.out else RESULTS_DIR / f"{sha}.jsonl"
|
|
223
|
+
out_path.parent.mkdir(parents=True, exist_ok=True)
|
|
224
|
+
|
|
225
|
+
results: list[dict] = []
|
|
226
|
+
for task in tasks:
|
|
227
|
+
try:
|
|
228
|
+
transcript = transport(task)
|
|
229
|
+
except Exception as exc:
|
|
230
|
+
transcript = {"events": [], "stderr": str(exc), "error": True}
|
|
231
|
+
grades = [
|
|
232
|
+
g for g in (
|
|
233
|
+
_grade_outcome(task),
|
|
234
|
+
_grade_process(task, transcript),
|
|
235
|
+
_grade_style(task, transcript),
|
|
236
|
+
_grade_efficiency(task, transcript),
|
|
237
|
+
) if g is not None
|
|
238
|
+
]
|
|
239
|
+
passed = bool(grades) and all(g["score"] == 1 for g in grades)
|
|
240
|
+
row = {
|
|
241
|
+
"taskId": task["id"],
|
|
242
|
+
"sha": sha,
|
|
243
|
+
"ts": datetime.now(timezone.utc).isoformat(),
|
|
244
|
+
"grades": grades,
|
|
245
|
+
"passed": passed,
|
|
246
|
+
}
|
|
247
|
+
results.append(row)
|
|
248
|
+
with out_path.open("a") as fh:
|
|
249
|
+
fh.write(json.dumps(row) + "\n")
|
|
250
|
+
|
|
251
|
+
return {"results": results, "passed": sum(1 for r in results if r["passed"]), "outPath": str(out_path), "sha": sha}
|
|
252
|
+
|
|
253
|
+
|
|
254
|
+
def _summarize(summary: dict) -> None:
|
|
255
|
+
print(f"\nEval run {summary['sha']} — {summary['passed']}/{len(summary['results'])} passed ({summary['outPath']})")
|
|
256
|
+
for r in summary["results"]:
|
|
257
|
+
mark = "✓" if r["passed"] else "✗"
|
|
258
|
+
print(f" {mark} {r['taskId']}")
|
|
259
|
+
for g in r["grades"]:
|
|
260
|
+
m = "✓" if g["score"] == 1 else "✗"
|
|
261
|
+
print(f" {m} {g['dim']}: {g['info']}")
|
|
262
|
+
|
|
263
|
+
|
|
264
|
+
def _main() -> int:
|
|
265
|
+
ap = argparse.ArgumentParser(description="Drive eval tasks against Claude Code.")
|
|
266
|
+
ap.add_argument("--quick", action="store_true", help="run first 3 tasks only")
|
|
267
|
+
ap.add_argument("--full", action="store_true", help="run all tasks")
|
|
268
|
+
ap.add_argument("--tasks", help="filename or substring to filter tasks")
|
|
269
|
+
ap.add_argument("--transport", default="claude-cli", choices=list(TRANSPORTS), help="transport to use")
|
|
270
|
+
ap.add_argument("--out", help="results path (default .harness/eval/results/<sha>.jsonl)")
|
|
271
|
+
args = ap.parse_args()
|
|
272
|
+
|
|
273
|
+
summary = run_eval(args)
|
|
274
|
+
_summarize(summary)
|
|
275
|
+
if os.environ.get("CI") == "true" and summary["passed"] < len(summary["results"]):
|
|
276
|
+
return 1
|
|
277
|
+
return 0
|
|
278
|
+
|
|
279
|
+
|
|
280
|
+
if __name__ == "__main__":
|
|
281
|
+
raise SystemExit(_main())
|
|
@@ -0,0 +1,195 @@
|
|
|
1
|
+
"""Forward-only layer enforcement for Python projects.
|
|
2
|
+
|
|
3
|
+
Reads ``harness.config.json``. For each domain, parses every source file's
|
|
4
|
+
imports (via libcst) and asserts that no import goes "backward" through the
|
|
5
|
+
layer order. New violations on existing code are baselined into
|
|
6
|
+
``.harness/structural-baseline.json`` on first run.
|
|
7
|
+
|
|
8
|
+
Exit codes:
|
|
9
|
+
0 -- clean (or only baselined violations)
|
|
10
|
+
2 -- new violations found (Claude Code reads stderr and re-prompts)
|
|
11
|
+
|
|
12
|
+
Usage::
|
|
13
|
+
|
|
14
|
+
python -m harness.structural_test # full repo
|
|
15
|
+
python -m harness.structural_test --file F # single file (PostToolUse hook)
|
|
16
|
+
"""
|
|
17
|
+
from __future__ import annotations
|
|
18
|
+
|
|
19
|
+
import argparse
|
|
20
|
+
import json
|
|
21
|
+
import sys
|
|
22
|
+
from pathlib import Path
|
|
23
|
+
|
|
24
|
+
try:
|
|
25
|
+
import libcst as cst
|
|
26
|
+
except ImportError: # pragma: no cover -- helpful error for first-time users
|
|
27
|
+
print(
|
|
28
|
+
"libcst is not installed. Run `pip install libcst` (or add it to your dev deps).",
|
|
29
|
+
file=sys.stderr,
|
|
30
|
+
)
|
|
31
|
+
sys.exit(1)
|
|
32
|
+
|
|
33
|
+
ROOT = Path.cwd()
|
|
34
|
+
CFG_PATH = ROOT / "harness.config.json"
|
|
35
|
+
BASELINE_PATH = ROOT / ".harness" / "structural-baseline.json"
|
|
36
|
+
|
|
37
|
+
|
|
38
|
+
def _load_cfg() -> dict:
|
|
39
|
+
return json.loads(CFG_PATH.read_text())
|
|
40
|
+
|
|
41
|
+
|
|
42
|
+
def _layer_of(path: Path, cfg: dict) -> tuple[str, dict] | None:
|
|
43
|
+
rel = str(path.relative_to(ROOT)) if path.is_absolute() else str(path)
|
|
44
|
+
for d in cfg["domains"]:
|
|
45
|
+
if not rel.startswith(d["root"]):
|
|
46
|
+
continue
|
|
47
|
+
for layer in d["layers"]:
|
|
48
|
+
if f"/{layer}/" in rel or rel.endswith(f"/{layer}.py"):
|
|
49
|
+
return layer, d
|
|
50
|
+
return None
|
|
51
|
+
|
|
52
|
+
|
|
53
|
+
def _resolve_imported_module(module: str, cfg: dict) -> Path | None:
|
|
54
|
+
"""Convert a dotted import path into the file path it most likely resolves to."""
|
|
55
|
+
parts = module.split(".")
|
|
56
|
+
candidate_pkg = ROOT / Path(*parts) / "__init__.py"
|
|
57
|
+
candidate_mod = ROOT / Path(*parts).with_suffix(".py")
|
|
58
|
+
if candidate_pkg.exists():
|
|
59
|
+
return candidate_pkg
|
|
60
|
+
if candidate_mod.exists():
|
|
61
|
+
return candidate_mod
|
|
62
|
+
return None
|
|
63
|
+
|
|
64
|
+
|
|
65
|
+
class _ImportCollector(cst.CSTVisitor):
|
|
66
|
+
METADATA_DEPENDENCIES = (cst.metadata.PositionProvider,)
|
|
67
|
+
|
|
68
|
+
def __init__(self) -> None:
|
|
69
|
+
self.imports: list[tuple[int, str]] = []
|
|
70
|
+
|
|
71
|
+
def visit_Import(self, node: cst.Import) -> None:
|
|
72
|
+
for alias in node.names:
|
|
73
|
+
line = self.get_metadata(cst.metadata.PositionProvider, node).start.line
|
|
74
|
+
full = self._dotted(alias.name)
|
|
75
|
+
if full:
|
|
76
|
+
self.imports.append((line, full))
|
|
77
|
+
|
|
78
|
+
def visit_ImportFrom(self, node: cst.ImportFrom) -> None:
|
|
79
|
+
if node.module is None:
|
|
80
|
+
return
|
|
81
|
+
line = self.get_metadata(cst.metadata.PositionProvider, node).start.line
|
|
82
|
+
full = self._dotted(node.module)
|
|
83
|
+
if full:
|
|
84
|
+
self.imports.append((line, full))
|
|
85
|
+
|
|
86
|
+
@staticmethod
|
|
87
|
+
def _dotted(node: cst.CSTNode) -> str | None:
|
|
88
|
+
parts: list[str] = []
|
|
89
|
+
cur = node
|
|
90
|
+
while isinstance(cur, cst.Attribute):
|
|
91
|
+
if isinstance(cur.attr, cst.Name):
|
|
92
|
+
parts.insert(0, cur.attr.value)
|
|
93
|
+
cur = cur.value
|
|
94
|
+
if isinstance(cur, cst.Name):
|
|
95
|
+
parts.insert(0, cur.value)
|
|
96
|
+
return ".".join(parts) if parts else None
|
|
97
|
+
|
|
98
|
+
|
|
99
|
+
def collect_violations(scoped_file: Path | None = None) -> list[dict]:
|
|
100
|
+
cfg = _load_cfg()
|
|
101
|
+
out: list[dict] = []
|
|
102
|
+
sources = list((ROOT / cfg["domains"][0]["root"]).rglob("*.py"))
|
|
103
|
+
for src_path in sources:
|
|
104
|
+
if scoped_file and src_path.resolve() != scoped_file.resolve():
|
|
105
|
+
continue
|
|
106
|
+
src = _layer_of(src_path, cfg)
|
|
107
|
+
if not src:
|
|
108
|
+
continue
|
|
109
|
+
src_layer, src_domain = src
|
|
110
|
+
src_idx = src_domain["layers"].index(src_layer)
|
|
111
|
+
try:
|
|
112
|
+
wrapper = cst.MetadataWrapper(cst.parse_module(src_path.read_text()))
|
|
113
|
+
except cst.ParserSyntaxError:
|
|
114
|
+
continue
|
|
115
|
+
col = _ImportCollector()
|
|
116
|
+
wrapper.visit(col)
|
|
117
|
+
for line, imported in col.imports:
|
|
118
|
+
tgt_path = _resolve_imported_module(imported, cfg)
|
|
119
|
+
if tgt_path is None:
|
|
120
|
+
continue
|
|
121
|
+
tgt = _layer_of(tgt_path, cfg)
|
|
122
|
+
if not tgt:
|
|
123
|
+
continue
|
|
124
|
+
tgt_layer, tgt_domain = tgt
|
|
125
|
+
if tgt_domain["name"] != src_domain["name"]:
|
|
126
|
+
continue
|
|
127
|
+
tgt_idx = tgt_domain["layers"].index(tgt_layer)
|
|
128
|
+
if src_idx < tgt_idx:
|
|
129
|
+
out.append(
|
|
130
|
+
{
|
|
131
|
+
"file": str(src_path),
|
|
132
|
+
"line": line,
|
|
133
|
+
"from": src_layer,
|
|
134
|
+
"to": tgt_layer,
|
|
135
|
+
"key": f"{src_path}::{imported}",
|
|
136
|
+
}
|
|
137
|
+
)
|
|
138
|
+
return out
|
|
139
|
+
|
|
140
|
+
|
|
141
|
+
def _main() -> int:
|
|
142
|
+
ap = argparse.ArgumentParser()
|
|
143
|
+
ap.add_argument("--file", type=Path, default=None)
|
|
144
|
+
args = ap.parse_args()
|
|
145
|
+
|
|
146
|
+
cfg = _load_cfg()
|
|
147
|
+
baseline = (
|
|
148
|
+
set(json.loads(BASELINE_PATH.read_text()))
|
|
149
|
+
if BASELINE_PATH.exists()
|
|
150
|
+
else None
|
|
151
|
+
)
|
|
152
|
+
violations = [
|
|
153
|
+
v for v in collect_violations(args.file)
|
|
154
|
+
if baseline is None or v["key"] not in baseline
|
|
155
|
+
]
|
|
156
|
+
|
|
157
|
+
# First-run baseline.
|
|
158
|
+
if baseline is None and violations:
|
|
159
|
+
BASELINE_PATH.parent.mkdir(parents=True, exist_ok=True)
|
|
160
|
+
BASELINE_PATH.write_text(
|
|
161
|
+
json.dumps([v["key"] for v in violations], indent=2) + "\n"
|
|
162
|
+
)
|
|
163
|
+
print(
|
|
164
|
+
f"✓ structural test: baselined {len(violations)} existing violations "
|
|
165
|
+
f"(.harness/structural-baseline.json)."
|
|
166
|
+
)
|
|
167
|
+
print(
|
|
168
|
+
" New violations introduced after this point will block. "
|
|
169
|
+
"Existing ones can be fixed incrementally."
|
|
170
|
+
)
|
|
171
|
+
return 0
|
|
172
|
+
|
|
173
|
+
if not violations:
|
|
174
|
+
print("✓ structural test passed")
|
|
175
|
+
return 0
|
|
176
|
+
|
|
177
|
+
for v in violations:
|
|
178
|
+
print(
|
|
179
|
+
f"✖ {v['file']}:{v['line']} layer={v['from']} → {v['to']} (must be forward-only)",
|
|
180
|
+
file=sys.stderr,
|
|
181
|
+
)
|
|
182
|
+
print(
|
|
183
|
+
f"\n{len(violations)} new layer violation(s). Fix the import direction.",
|
|
184
|
+
file=sys.stderr,
|
|
185
|
+
)
|
|
186
|
+
print(
|
|
187
|
+
f"Layer order for domain \"{cfg['domains'][0]['name']}\": "
|
|
188
|
+
f"{' → '.join(cfg['domains'][0]['layers'])}",
|
|
189
|
+
file=sys.stderr,
|
|
190
|
+
)
|
|
191
|
+
return 2
|
|
192
|
+
|
|
193
|
+
|
|
194
|
+
if __name__ == "__main__":
|
|
195
|
+
raise SystemExit(_main())
|
|
@@ -0,0 +1,27 @@
|
|
|
1
|
+
// dependency-cruiser — third structural sensor (after ts-morph + eslint).
|
|
2
|
+
// Catches circular imports and orphan modules in addition to layer violations.
|
|
3
|
+
|
|
4
|
+
module.exports = {
|
|
5
|
+
forbidden: [
|
|
6
|
+
{
|
|
7
|
+
name: "no-backward-layer",
|
|
8
|
+
severity: "error",
|
|
9
|
+
from: { path: "^src/[^/]+/types/" },
|
|
10
|
+
to: { path: "^src/[^/]+/(config|repo|service|runtime|ui)/" },
|
|
11
|
+
},
|
|
12
|
+
{ name: "no-circular", severity: "error", from: {}, to: { circular: true } },
|
|
13
|
+
{
|
|
14
|
+
name: "no-orphan",
|
|
15
|
+
severity: "warn",
|
|
16
|
+
from: { orphan: true, pathNot: "(\\.spec|\\.test|/__tests__/|/__mocks__/)" },
|
|
17
|
+
to: {},
|
|
18
|
+
},
|
|
19
|
+
],
|
|
20
|
+
options: {
|
|
21
|
+
tsConfig: { fileName: "tsconfig.json" },
|
|
22
|
+
enhancedResolveOptions: {
|
|
23
|
+
exportsFields: ["exports"],
|
|
24
|
+
conditionNames: ["import", "require", "node"],
|
|
25
|
+
},
|
|
26
|
+
},
|
|
27
|
+
};
|
|
@@ -0,0 +1,38 @@
|
|
|
1
|
+
// eslint-plugin-boundaries — defense in depth alongside the structural test.
|
|
2
|
+
// The structural test is the source of truth; this catches the same violations
|
|
3
|
+
// in the editor for faster feedback.
|
|
4
|
+
//
|
|
5
|
+
// Note: requires `eslint-plugin-boundaries` ≥ 5. Install if missing:
|
|
6
|
+
// npm i -D eslint-plugin-boundaries
|
|
7
|
+
|
|
8
|
+
import boundaries from "eslint-plugin-boundaries";
|
|
9
|
+
|
|
10
|
+
export default [
|
|
11
|
+
{
|
|
12
|
+
plugins: { boundaries },
|
|
13
|
+
settings: {
|
|
14
|
+
"boundaries/elements": [
|
|
15
|
+
{ type: "types", pattern: "src/*/types/**" },
|
|
16
|
+
{ type: "config", pattern: "src/*/config/**" },
|
|
17
|
+
{ type: "repo", pattern: "src/*/repo/**" },
|
|
18
|
+
{ type: "service", pattern: "src/*/service/**" },
|
|
19
|
+
{ type: "runtime", pattern: "src/*/runtime/**" },
|
|
20
|
+
{ type: "ui", pattern: "src/*/ui/**" },
|
|
21
|
+
],
|
|
22
|
+
"boundaries/include": ["src/**/*"],
|
|
23
|
+
},
|
|
24
|
+
rules: {
|
|
25
|
+
"boundaries/dependencies": [2, {
|
|
26
|
+
default: "disallow",
|
|
27
|
+
rules: [
|
|
28
|
+
{ from: { type: "ui" }, allow: { to: { type: ["runtime","service","config","types"] } } },
|
|
29
|
+
{ from: { type: "runtime" }, allow: { to: { type: ["service","repo","config","types"] } } },
|
|
30
|
+
{ from: { type: "service" }, allow: { to: { type: ["repo","config","types"] } } },
|
|
31
|
+
{ from: { type: "repo" }, allow: { to: { type: ["config","types"] } } },
|
|
32
|
+
{ from: { type: "config" }, allow: { to: { type: ["types"] } } },
|
|
33
|
+
{ from: { type: "types" }, disallow: { to: { type: "*" } } },
|
|
34
|
+
],
|
|
35
|
+
}],
|
|
36
|
+
},
|
|
37
|
+
},
|
|
38
|
+
];
|