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 +7 -0
- archsteer/cli.py +383 -0
- archsteer/docs.py +73 -0
- archsteer/engine/__init__.py +1 -0
- archsteer/engine/baseline.py +53 -0
- archsteer/engine/conformance.py +166 -0
- archsteer/engine/decisions.py +137 -0
- archsteer/engine/evolution.py +192 -0
- archsteer/engine/intent.py +66 -0
- archsteer/engine/mapper.py +133 -0
- archsteer/engine/model.py +130 -0
- archsteer/engine/parser.py +243 -0
- archsteer/packs/express_to_next/adr/0001-repository-pattern.md +18 -0
- archsteer/packs/express_to_next/adr/0002-nextjs-route-handlers.md +17 -0
- archsteer/packs/express_to_next/architecture.yaml +45 -0
- archsteer/report.py +151 -0
- archsteer/steer.py +105 -0
- archsteer/workspace.py +47 -0
- archsteer-0.1.0.dist-info/METADATA +124 -0
- archsteer-0.1.0.dist-info/RECORD +23 -0
- archsteer-0.1.0.dist-info/WHEEL +5 -0
- archsteer-0.1.0.dist-info/entry_points.txt +2 -0
- archsteer-0.1.0.dist-info/top_level.txt +1 -0
archsteer/__init__.py
ADDED
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
|
+
)
|