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.
Files changed (55) hide show
  1. package/.claude-plugin/marketplace.json +27 -0
  2. package/.claude-plugin/plugin.json +25 -0
  3. package/LICENSE +21 -0
  4. package/README.md +165 -0
  5. package/bin/cli.mjs +261 -0
  6. package/package.json +64 -0
  7. package/src/core/detect-stack.mjs +181 -0
  8. package/src/core/doctor.mjs +106 -0
  9. package/src/core/patch-package-json.mjs +53 -0
  10. package/src/core/render-templates.mjs +277 -0
  11. package/src/core/upgrade.mjs +274 -0
  12. package/src/templates/.claude/agents/api-consistency-reviewer.md +33 -0
  13. package/src/templates/.claude/agents/architecture-reviewer.md.hbs +41 -0
  14. package/src/templates/.claude/agents/performance-reviewer.md +35 -0
  15. package/src/templates/.claude/agents/reliability-reviewer.md +38 -0
  16. package/src/templates/.claude/agents/security-reviewer.md +39 -0
  17. package/src/templates/.claude/hooks/hooks.json.hbs +39 -0
  18. package/src/templates/.claude/settings.json.hbs +25 -0
  19. package/src/templates/.claude/skills/add-adr/SKILL.md +60 -0
  20. package/src/templates/.claude/skills/add-feature/SKILL.md.hbs +50 -0
  21. package/src/templates/.claude/skills/debug-flow/SKILL.md.hbs +38 -0
  22. package/src/templates/.claude/skills/doc-drift-scan/SKILL.md +43 -0
  23. package/src/templates/.claude/skills/eval-runner/SKILL.md +55 -0
  24. package/src/templates/.claude/skills/garbage-collection/SKILL.md.hbs +49 -0
  25. package/src/templates/.claude/skills/inspect-app/SKILL.md +57 -0
  26. package/src/templates/.claude/skills/inspect-module/SKILL.md.hbs +53 -0
  27. package/src/templates/.claude/skills/propose-harness-improvement/SKILL.md +43 -0
  28. package/src/templates/.claude/skills/structural-test-author/SKILL.md.hbs +46 -0
  29. package/src/templates/.claude/skills/write-skill/SKILL.md +39 -0
  30. package/src/templates/CLAUDE.md.hbs +70 -0
  31. package/src/templates/_adapter-python/.importlinter +14 -0
  32. package/src/templates/_adapter-python/harness/__init__.py +0 -0
  33. package/src/templates/_adapter-python/harness/eval_runner.py +281 -0
  34. package/src/templates/_adapter-python/harness/structural_test.py +195 -0
  35. package/src/templates/_adapter-typescript/.dependency-cruiser.cjs +27 -0
  36. package/src/templates/_adapter-typescript/eslint.config.mjs +38 -0
  37. package/src/templates/_adapter-typescript/harness/eval-runner.mjs +322 -0
  38. package/src/templates/_adapter-typescript/harness/structural-test.mjs +125 -0
  39. package/src/templates/_ci/.github/workflows/eval-nightly.yml +59 -0
  40. package/src/templates/_ci/.github/workflows/harness.yml +55 -0
  41. package/src/templates/docs/adr/0001-use-agent-harness-kit.md.hbs +56 -0
  42. package/src/templates/docs/agent-failures.md +25 -0
  43. package/src/templates/docs/architecture.md.hbs +47 -0
  44. package/src/templates/docs/core-beliefs.md.hbs +41 -0
  45. package/src/templates/docs/golden-principles.md.hbs +80 -0
  46. package/src/templates/docs/tech-debt-tracker.md +30 -0
  47. package/src/templates/feature_list.json.hbs +29 -0
  48. package/src/templates/harness.config.json.hbs +40 -0
  49. package/src/templates/scripts/dev-up.sh.hbs +51 -0
  50. package/src/templates/scripts/harness-report.mjs +189 -0
  51. package/src/templates/scripts/install-git-hooks.sh +18 -0
  52. package/src/templates/scripts/pre-push.sh +21 -0
  53. package/src/templates/scripts/precompletion-checklist.sh.hbs +99 -0
  54. package/src/templates/scripts/structural-test-on-edit.sh.hbs +53 -0
  55. 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
+ ];