archsteer 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.
archsteer/__init__.py ADDED
@@ -0,0 +1,7 @@
1
+ """ArchSteer — Living Architecture Control Plane for the AI-Dev Era.
2
+
3
+ One code-derived model (``model.json``) powers every pillar:
4
+ MAP, DOCUMENT, GOVERN, STEER, EVOLVE.
5
+ """
6
+
7
+ __version__ = "0.1.0"
archsteer/cli.py ADDED
@@ -0,0 +1,383 @@
1
+ """ArchSteer CLI — every command is a projection of the one shared model.
2
+
3
+ archsteer init scaffold .archsteer/ + seed intent
4
+ archsteer map build model.json from source
5
+ archsteer docs regenerate living architecture.md
6
+ archsteer adr detect structural decisions -> draft ADRs
7
+ archsteer govern show conformance / drift
8
+ archsteer baseline snapshot accepted violations (the ratchet)
9
+ archsteer check fail on NET-NEW violations only (CI / pre-commit)
10
+ archsteer steer write agent guardrails into CLAUDE.md / AGENTS.md
11
+ archsteer report build the self-contained report.html
12
+ """
13
+
14
+ from __future__ import annotations
15
+
16
+ import json
17
+ import os
18
+ import shutil
19
+ import urllib.error
20
+ import urllib.request
21
+ from pathlib import Path
22
+ from typing import List, Optional
23
+
24
+ import typer
25
+ from rich.console import Console
26
+ from rich.table import Table
27
+
28
+ from archsteer import __version__
29
+ from archsteer.docs import render_architecture_md
30
+ from archsteer.engine.baseline import Baseline
31
+ from archsteer.engine.conformance import ConformanceReport, evaluate
32
+ from archsteer.engine.decisions import DecisionEngine
33
+ from archsteer.engine.evolution import History, compute_feed
34
+ from archsteer.engine.intent import Intent
35
+ from archsteer.engine.mapper import build_model
36
+ from archsteer.engine.model import ArchitectureModel
37
+ from archsteer.report import render_report_html
38
+ from archsteer.steer import AgentSteeringEngine
39
+ from archsteer.workspace import Workspace
40
+
41
+ app = typer.Typer(add_completion=False, help="ArchSteer — Living Architecture Control Plane.")
42
+ console = Console()
43
+ PACK_DIR = Path(__file__).parent / "packs" / "express_to_next"
44
+
45
+
46
+ def _ws(path: Optional[str]) -> Workspace:
47
+ return Workspace(Path(path or "."))
48
+
49
+
50
+ def _require_init(ws: Workspace) -> None:
51
+ if not ws.initialized:
52
+ console.print("[red]Not initialized.[/red] Run [bold]archsteer init[/bold] first.")
53
+ raise typer.Exit(1)
54
+
55
+
56
+ def _load_model(ws: Workspace) -> ArchitectureModel:
57
+ model = ArchitectureModel.load_if_exists(ws.model)
58
+ if model is None:
59
+ console.print("[yellow]No model found — run [bold]archsteer map[/bold] first.[/yellow]")
60
+ raise typer.Exit(1)
61
+ return model
62
+
63
+
64
+ def _conformance(ws: Workspace, model: ArchitectureModel) -> ConformanceReport:
65
+ """Evaluate intent if present; otherwise return an empty report (X-ray mode)."""
66
+ intent = Intent.load_if_exists(ws.intent)
67
+ if intent is None:
68
+ return ConformanceReport()
69
+ return evaluate(model, intent)
70
+
71
+
72
+ def _record_snapshot(ws: Workspace, model: ArchitectureModel) -> None:
73
+ conf = _conformance(ws, model)
74
+ has_intent = ws.intent.exists()
75
+ History(ws.history_dir).record(
76
+ model,
77
+ conformance_score=conf.conformance_score if has_intent else None,
78
+ drift_score=conf.drift_score if has_intent else None,
79
+ open_violations=len(conf.all_violations) if has_intent else None,
80
+ )
81
+
82
+
83
+ @app.command()
84
+ def version() -> None:
85
+ """Print the ArchSteer version."""
86
+ console.print(f"ArchSteer {__version__}")
87
+
88
+
89
+ @app.command()
90
+ def init(path: Optional[str] = typer.Option(None, help="Repo root (default: cwd).")) -> None:
91
+ """Scaffold .archsteer/ and seed the starter intent + ADRs."""
92
+ ws = _ws(path)
93
+ if ws.initialized:
94
+ console.print(f"[yellow]Already initialized at {ws.dir}[/yellow]")
95
+ raise typer.Exit(0)
96
+ ws.dir.mkdir(parents=True, exist_ok=True)
97
+ ws.adr_dir.mkdir(parents=True, exist_ok=True)
98
+ shutil.copyfile(PACK_DIR / "architecture.yaml", ws.intent)
99
+ for adr in (PACK_DIR / "adr").glob("*.md"):
100
+ shutil.copyfile(adr, ws.adr_dir / adr.name)
101
+ console.print(f"[green]✓[/green] Initialized [bold]{ws.dir}[/bold]")
102
+ console.print(" • seeded [cyan]architecture.yaml[/cyan] (Express → Next.js + repository)")
103
+ console.print(" • seeded baseline ADRs in [cyan].archsteer/adr/[/cyan]")
104
+ console.print("\nNext: [bold]archsteer map[/bold] then [bold]archsteer report[/bold]")
105
+
106
+
107
+ @app.command()
108
+ def map(path: Optional[str] = typer.Option(None, help="Repo root (default: cwd).")) -> None:
109
+ """Build the architecture model (.archsteer/model.json) from source."""
110
+ ws = _ws(path)
111
+ _require_init(ws)
112
+ if ws.model.exists():
113
+ shutil.copyfile(ws.model, ws.model_prev) # keep prior snapshot for `adr`
114
+ model = build_model(ws.root)
115
+ model.save(ws.model)
116
+ _record_snapshot(ws, model)
117
+ console.print(
118
+ f"[green]✓[/green] Mapped [bold]{len(model.components)}[/bold] components · "
119
+ f"layers: {', '.join(sorted(model.get_layers())) or '—'} · "
120
+ f"data stores: {', '.join(sorted(model.get_all_data_stores())) or '—'}"
121
+ )
122
+
123
+
124
+ @app.command()
125
+ def docs(path: Optional[str] = typer.Option(None)) -> None:
126
+ """Regenerate living architecture docs (deterministic)."""
127
+ ws = _ws(path)
128
+ _require_init(ws)
129
+ model = _load_model(ws)
130
+ ws.architecture_md.write_text(render_architecture_md(model), encoding="utf-8")
131
+ console.print(f"[green]✓[/green] Wrote [bold]{ws.architecture_md}[/bold]")
132
+
133
+
134
+ @app.command()
135
+ def adr(path: Optional[str] = typer.Option(None)) -> None:
136
+ """Detect structural decisions since the last map and draft ADRs."""
137
+ ws = _ws(path)
138
+ _require_init(ws)
139
+ model = _load_model(ws)
140
+ prev = ArchitectureModel.load_if_exists(ws.model_prev)
141
+ engine = DecisionEngine(ws.adr_dir)
142
+ drafts = engine.analyze_diff(prev, model)
143
+ written = engine.write_drafts(drafts)
144
+ if not written:
145
+ console.print("[green]✓[/green] No new architectural decisions to record.")
146
+ return
147
+ console.print(f"[yellow]📝 {len(written)} draft ADR(s) need architect review:[/yellow]")
148
+ for p in written:
149
+ console.print(f" • {p.relative_to(ws.root)}")
150
+
151
+
152
+ @app.command()
153
+ def govern(path: Optional[str] = typer.Option(None)) -> None:
154
+ """Show conformance and drift against declared intent."""
155
+ ws = _ws(path)
156
+ _require_init(ws)
157
+ model = _load_model(ws)
158
+ report = _conformance(ws, model)
159
+ table = Table(title=f"Conformance — {report.target or ws.root.name}")
160
+ table.add_column("Rule"); table.add_column("Sev"); table.add_column("Progress", justify="right")
161
+ table.add_column("Open", justify="right")
162
+ for r in report.results:
163
+ table.add_row(r.rule_id, r.severity, f"{r.progress}%", str(len(r.violations)))
164
+ console.print(table)
165
+ console.print(
166
+ f"Overall conformance: [bold]{report.conformance_score}%[/bold] · "
167
+ f"drift: [bold]{report.drift_score}%[/bold]"
168
+ )
169
+
170
+
171
+ @app.command()
172
+ def baseline(path: Optional[str] = typer.Option(None)) -> None:
173
+ """Snapshot current violations as accepted debt (the ratchet)."""
174
+ ws = _ws(path)
175
+ _require_init(ws)
176
+ model = _load_model(ws)
177
+ report = _conformance(ws, model)
178
+ bl = Baseline.from_report(report)
179
+ bl.save(ws.baseline)
180
+ console.print(f"[green]✓[/green] Baselined [bold]{len(bl.fingerprints)}[/bold] existing violation(s).")
181
+ console.print("New violations will now be blocked by [bold]archsteer check[/bold].")
182
+
183
+
184
+ @app.command()
185
+ def check(
186
+ path: Optional[str] = typer.Option(None),
187
+ remap: bool = typer.Option(True, help="Rebuild the model before checking."),
188
+ ) -> None:
189
+ """Fail (exit 1) on NET-NEW violations only — for CI / pre-commit."""
190
+ ws = _ws(path)
191
+ _require_init(ws)
192
+ if remap:
193
+ model = build_model(ws.root)
194
+ model.save(ws.model)
195
+ else:
196
+ model = _load_model(ws)
197
+ report = _conformance(ws, model)
198
+ bl = Baseline.load_if_exists(ws.baseline)
199
+ if bl is None:
200
+ console.print("[yellow]No baseline — treating ALL violations as net-new.[/yellow]")
201
+ net_new = report.all_violations
202
+ fixed = 0
203
+ else:
204
+ net_new = bl.net_new(report)
205
+ fixed = bl.fixed(report)
206
+ if fixed:
207
+ console.print(f"[green]↑ {fixed} baselined violation(s) resolved — nice.[/green]")
208
+ blocking = [v for v in net_new if v.severity == "error"]
209
+ for v in net_new:
210
+ tag = "[red]✗[/red]" if v.severity == "error" else "[yellow]△[/yellow]"
211
+ console.print(f"{tag} {v.file}:{v.loc} [dim]{v.rule_id}[/dim] — {v.message}")
212
+ if blocking:
213
+ console.print(f"\n[red]✗ {len(blocking)} net-new error violation(s) block this change.[/red]")
214
+ raise typer.Exit(1)
215
+ console.print("\n[green]✓ No net-new blocking violations.[/green]")
216
+
217
+
218
+ @app.command()
219
+ def steer(
220
+ path: Optional[str] = typer.Option(None),
221
+ files: Optional[List[str]] = typer.Option(None, "--files", "-f", help="Files in scope."),
222
+ task: Optional[str] = typer.Option(None, "--task", "-t", help="What the agent is about to do."),
223
+ targets: Optional[List[str]] = typer.Option(None, "--target", help="Agent files to write."),
224
+ ) -> None:
225
+ """Inject sharp, model-grounded guardrails into agent context files."""
226
+ ws = _ws(path)
227
+ _require_init(ws)
228
+ model = _load_model(ws)
229
+ intent = Intent.load(ws.intent)
230
+ engine = AgentSteeringEngine(ws.root)
231
+ payload = engine.synthesize(intent, model, files=files, task=task)
232
+ written = engine.write(payload, targets=targets)
233
+ console.print(f"[green]✓[/green] Steered: {', '.join(str(p.relative_to(ws.root)) for p in written) or '(no targets)'}")
234
+
235
+
236
+ def _render_report(ws: Workspace, model: ArchitectureModel) -> None:
237
+ conf = _conformance(ws, model)
238
+ governed = ws.intent.exists()
239
+ prev = ArchitectureModel.load_if_exists(ws.model_prev)
240
+ pending = [d.title for d in DecisionEngine(ws.adr_dir).analyze_diff(prev, model)]
241
+ bl = Baseline.load_if_exists(ws.baseline)
242
+ fixed = bl.fixed(conf) if bl else 0
243
+ hist = History(ws.history_dir)
244
+ metas = hist.metas()
245
+ old_meta, new_meta = hist.latest_two()
246
+ old_model = hist.load_model(old_meta) if old_meta else None
247
+ feed = compute_feed(old_model, model, old_meta, new_meta)
248
+ ws.report_html.write_text(
249
+ render_report_html(
250
+ model, conf, pending, fixed_count=fixed,
251
+ feed=feed, history=metas, governed=governed,
252
+ ),
253
+ encoding="utf-8",
254
+ )
255
+
256
+
257
+ @app.command()
258
+ def report(path: Optional[str] = typer.Option(None)) -> None:
259
+ """Build the self-contained report.html (map + evolution + conformance + decisions)."""
260
+ ws = _ws(path)
261
+ _require_init(ws)
262
+ _render_report(ws, _load_model(ws))
263
+ console.print(f"[green]✓[/green] Wrote [bold]{ws.report_html}[/bold] — open it in a browser.")
264
+
265
+
266
+ @app.command()
267
+ def xray(path: Optional[str] = typer.Option(None, help="Repo root (default: cwd).")) -> None:
268
+ """Zero-config read-only X-ray: map + docs + evolution + report, no intent needed.
269
+
270
+ The universal wedge — point it at ANY repo and instantly see what the
271
+ architecture is and how it changed, without declaring any rules.
272
+ """
273
+ ws = _ws(path)
274
+ ws.dir.mkdir(parents=True, exist_ok=True)
275
+ if ws.model.exists():
276
+ shutil.copyfile(ws.model, ws.model_prev)
277
+ model = build_model(ws.root)
278
+ model.save(ws.model)
279
+ _record_snapshot(ws, model)
280
+ ws.architecture_md.write_text(render_architecture_md(model), encoding="utf-8")
281
+ # Draft ADRs for any structural change since last snapshot (architect-in-the-loop).
282
+ DecisionEngine(ws.adr_dir).write_drafts(
283
+ DecisionEngine(ws.adr_dir).analyze_diff(
284
+ ArchitectureModel.load_if_exists(ws.model_prev), model
285
+ )
286
+ )
287
+ _render_report(ws, model)
288
+ old_meta, new_meta = History(ws.history_dir).latest_two()
289
+ feed = compute_feed(
290
+ History(ws.history_dir).load_model(old_meta) if old_meta else None,
291
+ model, old_meta, new_meta,
292
+ )
293
+ console.print(
294
+ f"[green]✓[/green] X-ray of [bold]{ws.root.name}[/bold]: "
295
+ f"{len(model.components)} components, {len(model.get_layers())} layers."
296
+ )
297
+ console.print(f" {feed.summary()}")
298
+ console.print(f" → [bold]{ws.architecture_md.relative_to(ws.root)}[/bold] and "
299
+ f"[bold]{ws.report_html.relative_to(ws.root)}[/bold] (open in a browser)")
300
+
301
+
302
+ @app.command()
303
+ def push(
304
+ path: Optional[str] = typer.Option(None),
305
+ url: Optional[str] = typer.Option(None, envvar="ARCHSTEER_URL", help="Ingest endpoint."),
306
+ token: Optional[str] = typer.Option(None, envvar="ARCHSTEER_TOKEN", help="Org API token."),
307
+ org: Optional[str] = typer.Option(None, envvar="ARCHSTEER_ORG", help="Organization slug."),
308
+ ) -> None:
309
+ """Push the latest snapshot + conformance to the cloud situation room."""
310
+ ws = _ws(path)
311
+ _require_init(ws)
312
+ model = _load_model(ws)
313
+ conf = _conformance(ws, model)
314
+ governed = ws.intent.exists()
315
+ prev = ArchitectureModel.load_if_exists(ws.model_prev)
316
+ pending = len(DecisionEngine(ws.adr_dir).analyze_diff(prev, model))
317
+ hist = History(ws.history_dir)
318
+ old_meta, new_meta = hist.latest_two()
319
+ feed = compute_feed(hist.load_model(old_meta) if old_meta else None, model, old_meta, new_meta)
320
+
321
+ payload = {
322
+ "repo": model.repo_name,
323
+ "org": org,
324
+ "commit": model.commit_sha,
325
+ "timestamp": model.timestamp,
326
+ "components": len(model.components),
327
+ "layers": sorted(model.get_layers()),
328
+ "external_dependencies": len(model.get_all_external_dependencies()),
329
+ "data_stores": len([s for s in model.get_all_data_stores() if s != "raw_sql"]),
330
+ "conformance_score": conf.conformance_score if governed else None,
331
+ "drift_score": conf.drift_score if governed else None,
332
+ "open_violations": len(conf.all_violations) if governed else None,
333
+ "pending_decisions": pending,
334
+ "changes": [c.model_dump() for c in feed.changes],
335
+ }
336
+ endpoint = url or "https://archsteer.dev/api/ingest"
337
+ req = urllib.request.Request(
338
+ endpoint, data=json.dumps(payload).encode("utf-8"),
339
+ headers={"Content-Type": "application/json",
340
+ **({"Authorization": f"Bearer {token}"} if token else {})},
341
+ method="POST",
342
+ )
343
+ try:
344
+ with urllib.request.urlopen(req, timeout=15) as resp:
345
+ body = json.loads(resp.read().decode("utf-8"))
346
+ console.print(
347
+ f"[green]✓[/green] Pushed [bold]{model.repo_name}[/bold] → situation room "
348
+ f"({endpoint}) · {body.get('snapshots', '?')} snapshot(s)."
349
+ )
350
+ except urllib.error.HTTPError as e:
351
+ console.print(f"[red]✗ Push failed ({e.code}): {e.reason}[/red]")
352
+ raise typer.Exit(1)
353
+ except urllib.error.URLError as e:
354
+ console.print(f"[red]✗ Could not reach {endpoint}: {e.reason}[/red]")
355
+ raise typer.Exit(1)
356
+
357
+
358
+ @app.command()
359
+ def evolution(
360
+ path: Optional[str] = typer.Option(None),
361
+ limit: int = typer.Option(15, help="Max changes to show."),
362
+ ) -> None:
363
+ """Show the Architecture Evolution Feed between the two latest snapshots."""
364
+ ws = _ws(path)
365
+ hist = History(ws.history_dir)
366
+ old_meta, new_meta = hist.latest_two()
367
+ if new_meta is None:
368
+ console.print("[yellow]No history yet — run [bold]archsteer map[/bold] or [bold]xray[/bold] first.[/yellow]")
369
+ raise typer.Exit(1)
370
+ old_model = hist.load_model(old_meta) if old_meta else None
371
+ new_model = hist.load_model(new_meta)
372
+ feed = compute_feed(old_model, new_model, old_meta, new_meta)
373
+ console.print(f"[bold]Architecture Evolution[/bold] — {feed.summary()}")
374
+ if feed.drift_delta is not None:
375
+ arrow = "↓ improved" if feed.drift_delta < 0 else ("↑ worsened" if feed.drift_delta > 0 else "unchanged")
376
+ console.print(f"Drift Index: {arrow} ({'+' if feed.drift_delta > 0 else ''}{feed.drift_delta} pts)")
377
+ for c in feed.changes[:limit]:
378
+ icon = {"positive": "[green]✓[/green]", "negative": "[red]✗[/red]"}.get(c.direction, "•")
379
+ console.print(f" {icon} {c.text}")
380
+
381
+
382
+ if __name__ == "__main__":
383
+ app()
archsteer/docs.py ADDED
@@ -0,0 +1,73 @@
1
+ """Living architecture docs generated from the model (deterministic / idempotent).
2
+
3
+ Intentionally excludes wall-clock timestamps so regeneration is byte-identical when
4
+ the model is unchanged (verification relies on this). Produces architecture.md with
5
+ a Mermaid layer diagram and a component catalog.
6
+ """
7
+
8
+ from __future__ import annotations
9
+
10
+ from collections import defaultdict
11
+ from typing import Dict, List, Set, Tuple
12
+
13
+ from archsteer.engine.model import ArchitectureModel
14
+
15
+
16
+ def _layer_edges(model: ArchitectureModel) -> Set[Tuple[str, str]]:
17
+ edges: Set[Tuple[str, str]] = set()
18
+ for src, dst in model.internal_edges():
19
+ sl = model.components[src].layer or "unassigned"
20
+ dl = model.components[dst].layer or "unassigned"
21
+ if sl != dl:
22
+ edges.add((sl, dl))
23
+ return edges
24
+
25
+
26
+ def _mermaid(model: ArchitectureModel) -> str:
27
+ edges = sorted(_layer_edges(model))
28
+ layers = sorted(model.get_layers() | {"unassigned"})
29
+ lines = ["```mermaid", "graph LR"]
30
+ if not edges:
31
+ for layer in layers:
32
+ lines.append(f" {layer}[{layer}]")
33
+ for sl, dl in edges:
34
+ lines.append(f" {sl}[{sl}] --> {dl}[{dl}]")
35
+ lines.append("```")
36
+ return "\n".join(lines)
37
+
38
+
39
+ def render_architecture_md(model: ArchitectureModel) -> str:
40
+ layer_counts: Dict[str, int] = defaultdict(int)
41
+ for comp in model.components.values():
42
+ layer_counts[comp.layer or "unassigned"] += 1
43
+
44
+ ext_deps = sorted(model.get_all_external_dependencies())
45
+ stores = sorted(model.get_all_data_stores())
46
+
47
+ out: List[str] = [
48
+ f"# Architecture — {model.repo_name}",
49
+ "",
50
+ "> Auto-generated by ArchSteer from source. Do not edit by hand; run `archsteer docs`.",
51
+ "",
52
+ "## Overview",
53
+ f"- **Components:** {len(model.components)}",
54
+ f"- **Layers:** {', '.join(sorted(model.get_layers())) or '—'}",
55
+ f"- **Data stores:** {', '.join(stores) or '—'}",
56
+ f"- **External call sites:** {sum(len(c.external_calls) for c in model.components.values())}",
57
+ "",
58
+ "## Layer map",
59
+ _mermaid(model),
60
+ "",
61
+ "## Components by layer",
62
+ ]
63
+ for layer in sorted(layer_counts):
64
+ out.append(f"- **{layer}** — {layer_counts[layer]} component(s)")
65
+ out += ["", "## Component catalog", "", "| Component | Layer | Exports | Data access | External |", "|---|---|---|---|---|"]
66
+ for path in sorted(model.components):
67
+ c = model.components[path]
68
+ exports = ", ".join(c.exported_apis[:4]) or "—"
69
+ da = ", ".join(sorted({d.entity for d in c.data_access})) or "—"
70
+ ext = str(len(c.external_calls)) if c.external_calls else "—"
71
+ out.append(f"| `{path}` | {c.layer or '—'} | {exports} | {da} | {ext} |")
72
+ out.append("")
73
+ return "\n".join(out)
@@ -0,0 +1 @@
1
+ """ArchSteer engine: the single source of truth (model) and its projections."""
@@ -0,0 +1,53 @@
1
+ """The ratchet: accept existing debt, block only net-new violations.
2
+
3
+ Teams mid-migration can't freeze features or fix all debt at once. ``baseline``
4
+ snapshots the currently-accepted violation fingerprints; ``check`` then fails only
5
+ on fingerprints not in that snapshot. Architecture can only improve from here.
6
+ """
7
+
8
+ from __future__ import annotations
9
+
10
+ import json
11
+ from datetime import datetime, timezone
12
+ from pathlib import Path
13
+ from typing import List
14
+
15
+ from archsteer.engine.conformance import ConformanceReport, Violation
16
+
17
+
18
+ class Baseline:
19
+ def __init__(self, fingerprints: set[str], created: str | None = None):
20
+ self.fingerprints = fingerprints
21
+ self.created = created or datetime.now(timezone.utc).isoformat()
22
+
23
+ @classmethod
24
+ def from_report(cls, report: ConformanceReport) -> "Baseline":
25
+ return cls({v.fingerprint for v in report.all_violations})
26
+
27
+ @classmethod
28
+ def load(cls, path: Path) -> "Baseline":
29
+ data = json.loads(Path(path).read_text(encoding="utf-8"))
30
+ return cls(set(data.get("fingerprints", [])), data.get("created"))
31
+
32
+ @classmethod
33
+ def load_if_exists(cls, path: Path) -> "Baseline | None":
34
+ p = Path(path)
35
+ return cls.load(p) if p.exists() else None
36
+
37
+ def save(self, path: Path) -> None:
38
+ Path(path).parent.mkdir(parents=True, exist_ok=True)
39
+ Path(path).write_text(
40
+ json.dumps(
41
+ {"created": self.created, "fingerprints": sorted(self.fingerprints)},
42
+ indent=2,
43
+ ),
44
+ encoding="utf-8",
45
+ )
46
+
47
+ def net_new(self, report: ConformanceReport) -> List[Violation]:
48
+ return [v for v in report.all_violations if v.fingerprint not in self.fingerprints]
49
+
50
+ def fixed(self, report: ConformanceReport) -> int:
51
+ """Count of baselined violations that are now resolved (progress!)."""
52
+ current = {v.fingerprint for v in report.all_violations}
53
+ return len(self.fingerprints - current)
@@ -0,0 +1,166 @@
1
+ """Evaluate declared intent against the model — fitness functions + drift score.
2
+
3
+ Produces stable-fingerprinted violations (line-number independent, so cosmetic
4
+ edits don't churn the ratchet baseline) and a per-rule migration/conformance %.
5
+ """
6
+
7
+ from __future__ import annotations
8
+
9
+ import fnmatch
10
+ import hashlib
11
+ import re
12
+ from typing import Dict, List, Optional
13
+
14
+ from pydantic import BaseModel
15
+
16
+ from archsteer.engine.intent import Intent, Rule
17
+ from archsteer.engine.model import ArchitectureComponent, ArchitectureModel
18
+
19
+
20
+ class Violation(BaseModel):
21
+ rule_id: str
22
+ severity: str
23
+ file: str
24
+ message: str
25
+ loc: int = 0
26
+ fingerprint: str = ""
27
+
28
+ def with_fingerprint(self) -> "Violation":
29
+ # Line-independent identity: rule + file + message-shape.
30
+ key = f"{self.rule_id}|{self.file}|{self.message}".encode("utf-8")
31
+ self.fingerprint = hashlib.sha1(key).hexdigest()[:12]
32
+ return self
33
+
34
+
35
+ class RuleResult(BaseModel):
36
+ rule_id: str
37
+ description: str
38
+ severity: str
39
+ scoped: int
40
+ compliant: int
41
+ violations: List[Violation] = []
42
+
43
+ @property
44
+ def progress(self) -> float:
45
+ if self.scoped == 0:
46
+ return 100.0
47
+ return round(100.0 * self.compliant / self.scoped, 1)
48
+
49
+
50
+ class ConformanceReport(BaseModel):
51
+ target: Optional[str] = None
52
+ results: List[RuleResult] = []
53
+
54
+ @property
55
+ def all_violations(self) -> List[Violation]:
56
+ return [v for r in self.results for v in r.violations]
57
+
58
+ @property
59
+ def conformance_score(self) -> float:
60
+ """Overall % of (rule, component) checks that pass."""
61
+ scoped = sum(r.scoped for r in self.results)
62
+ compliant = sum(r.compliant for r in self.results)
63
+ if scoped == 0:
64
+ return 100.0
65
+ return round(100.0 * compliant / scoped, 1)
66
+
67
+ @property
68
+ def drift_score(self) -> float:
69
+ return round(100.0 - self.conformance_score, 1)
70
+
71
+
72
+ def _in_scope(rule: Rule, comp: ArchitectureComponent) -> bool:
73
+ if rule.scope_layer is not None:
74
+ return comp.layer == rule.scope_layer
75
+ if rule.scope:
76
+ return fnmatch.fnmatch(comp.file_path, rule.scope)
77
+ return True # whole repo
78
+
79
+
80
+ def _eval_rule(rule: Rule, model: ArchitectureModel) -> RuleResult:
81
+ scoped = 0
82
+ compliant = 0
83
+ violations: List[Violation] = []
84
+
85
+ for comp in model.components.values():
86
+ if not _in_scope(rule, comp):
87
+ continue
88
+ scoped += 1
89
+ v = _violations_for(rule, comp, model)
90
+ if v:
91
+ violations.extend(v)
92
+ else:
93
+ compliant += 1
94
+
95
+ return RuleResult(
96
+ rule_id=rule.id,
97
+ description=rule.description,
98
+ severity=rule.severity,
99
+ scoped=scoped,
100
+ compliant=compliant,
101
+ violations=violations,
102
+ )
103
+
104
+
105
+ def _violations_for(
106
+ rule: Rule, comp: ArchitectureComponent, model: ArchitectureModel
107
+ ) -> List[Violation]:
108
+ out: List[Violation] = []
109
+ ops = {o.upper() for o in rule.operations}
110
+
111
+ if rule.type == "required_layer_for_data_access":
112
+ if comp.layer in rule.allowed_layers:
113
+ return out
114
+ for da in comp.data_access:
115
+ if not ops or (da.operations & ops):
116
+ out.append(
117
+ Violation(
118
+ rule_id=rule.id, severity=rule.severity, file=comp.file_path,
119
+ loc=da.loc,
120
+ message=f"data access to '{da.entity}' ({'/'.join(sorted(da.operations))}) outside allowed layers {rule.allowed_layers}",
121
+ ).with_fingerprint()
122
+ )
123
+
124
+ elif rule.type == "forbidden_data_access":
125
+ for da in comp.data_access:
126
+ if not ops or (da.operations & ops):
127
+ out.append(
128
+ Violation(
129
+ rule_id=rule.id, severity=rule.severity, file=comp.file_path,
130
+ loc=da.loc,
131
+ message=f"forbidden data access to '{da.entity}' ({'/'.join(sorted(da.operations))})",
132
+ ).with_fingerprint()
133
+ )
134
+
135
+ elif rule.type == "forbidden_import" and rule.pattern:
136
+ rx = re.compile(rule.pattern)
137
+ for dep in comp.dependencies:
138
+ if rx.search(dep.target):
139
+ out.append(
140
+ Violation(
141
+ rule_id=rule.id, severity=rule.severity, file=comp.file_path,
142
+ loc=dep.loc,
143
+ message=f"forbidden import '{dep.target}' (matches /{rule.pattern}/)",
144
+ ).with_fingerprint()
145
+ )
146
+
147
+ elif rule.type == "forbidden_layer_edge":
148
+ for dep in comp.dependencies:
149
+ tgt = model.components.get(dep.target)
150
+ if tgt and comp.layer == rule.from_layer and tgt.layer == rule.to_layer:
151
+ out.append(
152
+ Violation(
153
+ rule_id=rule.id, severity=rule.severity, file=comp.file_path,
154
+ loc=dep.loc,
155
+ message=f"{rule.from_layer} -> {rule.to_layer} dependency on '{dep.target}' is forbidden",
156
+ ).with_fingerprint()
157
+ )
158
+
159
+ return out
160
+
161
+
162
+ def evaluate(model: ArchitectureModel, intent: Intent) -> ConformanceReport:
163
+ return ConformanceReport(
164
+ target=intent.target,
165
+ results=[_eval_rule(rule, model) for rule in intent.rules],
166
+ )