archsteer 0.1.0__tar.gz

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 (29) hide show
  1. archsteer-0.1.0/PKG-INFO +124 -0
  2. archsteer-0.1.0/README.md +100 -0
  3. archsteer-0.1.0/archsteer/__init__.py +7 -0
  4. archsteer-0.1.0/archsteer/cli.py +383 -0
  5. archsteer-0.1.0/archsteer/docs.py +73 -0
  6. archsteer-0.1.0/archsteer/engine/__init__.py +1 -0
  7. archsteer-0.1.0/archsteer/engine/baseline.py +53 -0
  8. archsteer-0.1.0/archsteer/engine/conformance.py +166 -0
  9. archsteer-0.1.0/archsteer/engine/decisions.py +137 -0
  10. archsteer-0.1.0/archsteer/engine/evolution.py +192 -0
  11. archsteer-0.1.0/archsteer/engine/intent.py +66 -0
  12. archsteer-0.1.0/archsteer/engine/mapper.py +133 -0
  13. archsteer-0.1.0/archsteer/engine/model.py +130 -0
  14. archsteer-0.1.0/archsteer/engine/parser.py +243 -0
  15. archsteer-0.1.0/archsteer/packs/express_to_next/adr/0001-repository-pattern.md +18 -0
  16. archsteer-0.1.0/archsteer/packs/express_to_next/adr/0002-nextjs-route-handlers.md +17 -0
  17. archsteer-0.1.0/archsteer/packs/express_to_next/architecture.yaml +45 -0
  18. archsteer-0.1.0/archsteer/report.py +151 -0
  19. archsteer-0.1.0/archsteer/steer.py +105 -0
  20. archsteer-0.1.0/archsteer/workspace.py +47 -0
  21. archsteer-0.1.0/archsteer.egg-info/PKG-INFO +124 -0
  22. archsteer-0.1.0/archsteer.egg-info/SOURCES.txt +27 -0
  23. archsteer-0.1.0/archsteer.egg-info/dependency_links.txt +1 -0
  24. archsteer-0.1.0/archsteer.egg-info/entry_points.txt +2 -0
  25. archsteer-0.1.0/archsteer.egg-info/requires.txt +14 -0
  26. archsteer-0.1.0/archsteer.egg-info/top_level.txt +1 -0
  27. archsteer-0.1.0/pyproject.toml +47 -0
  28. archsteer-0.1.0/setup.cfg +4 -0
  29. archsteer-0.1.0/tests/test_engine.py +162 -0
@@ -0,0 +1,124 @@
1
+ Metadata-Version: 2.4
2
+ Name: archsteer
3
+ Version: 0.1.0
4
+ Summary: Living Architecture Control Plane for the AI-Dev Era
5
+ Author-email: ArchSteer <founders@archsteer.io>
6
+ License: MIT
7
+ Classifier: Programming Language :: Python :: 3.10
8
+ Classifier: Programming Language :: Python :: 3.11
9
+ Classifier: Programming Language :: Python :: 3.12
10
+ Requires-Python: >=3.10
11
+ Description-Content-Type: text/markdown
12
+ Requires-Dist: typer>=0.12.0
13
+ Requires-Dist: pydantic>=2.7.0
14
+ Requires-Dist: ruamel.yaml>=0.18.0
15
+ Requires-Dist: jinja2>=3.1.0
16
+ Requires-Dist: rich>=13.7.0
17
+ Provides-Extra: treesitter
18
+ Requires-Dist: tree-sitter<0.22.0,>=0.21.0; extra == "treesitter"
19
+ Requires-Dist: tree-sitter-languages>=1.10.0; extra == "treesitter"
20
+ Provides-Extra: dev
21
+ Requires-Dist: pytest>=8.0.0; extra == "dev"
22
+ Requires-Dist: black>=24.0.0; extra == "dev"
23
+ Requires-Dist: isort>=5.13.0; extra == "dev"
24
+
25
+ # ArchSteer
26
+
27
+ **Living Architecture Control Plane for the AI-Dev Era.**
28
+
29
+ AI agents now write code faster than any architect can review, document, or govern it.
30
+ Docs rot instantly, the *real* architecture is invisible, structural decisions get made
31
+ silently, and intended architecture drifts with every edit. ArchSteer is the always-current
32
+ architecture **system of record + governance plane**: it derives the real architecture from
33
+ code, keeps living docs and ADRs auto-built, surfaces every major decision for the architect
34
+ to ratify, enforces declared intent as code-level fitness functions, and steers AI agents to
35
+ conform instead of replicating local slop.
36
+
37
+ Everything is a projection of one code-derived model — `.archsteer/model.json`.
38
+
39
+ ```
40
+ .archsteer/model.json (single source of truth)
41
+
42
+ MAP ──── DOCUMENT ──── GOVERN ──── STEER ──── EVOLVE
43
+ model living docs fitness agent report.html
44
+ from + auto ADRs functions guardrails (drift/
45
+ source + diagrams + ratchet + (MCP) decisions)
46
+ ```
47
+
48
+ ## Install
49
+
50
+ ```bash
51
+ pip install archsteer # regex engine, zero native deps
52
+ pip install "archsteer[treesitter]" # optional native acceleration
53
+ ```
54
+
55
+ ## Quickstart
56
+
57
+ ```bash
58
+ archsteer init # scaffold .archsteer/ + seed intent (Express → Next.js + repository)
59
+ archsteer map # build model.json from source
60
+ archsteer docs # regenerate .archsteer/architecture.md (deterministic, Mermaid)
61
+ archsteer govern # conformance + drift score by rule
62
+ archsteer adr # draft ADRs for new structural decisions (architect-in-the-loop)
63
+ archsteer baseline # accept current debt — the ratchet
64
+ archsteer steer -f src/controllers/payment.js -t "add refund endpoint"
65
+ archsteer check # CI/pre-commit: fail on NET-NEW violations only
66
+ archsteer report # self-contained .archsteer/report.html
67
+ ```
68
+
69
+ ## The three design guarantees
70
+
71
+ 1. **Ratchet, not freeze.** `archsteer check` blocks only *net-new* violations against a
72
+ baseline — teams keep shipping features while debt can only shrink.
73
+ 2. **Conservative, architect-in-the-loop ADRs.** Only external-boundary changes (new
74
+ dependency, new datastore, new layer) draft an ADR — never internal reshuffles, never
75
+ auto-committed.
76
+ 3. **Sharp agent steering.** Guardrails injected into `CLAUDE.md` / `AGENTS.md` are scoped to
77
+ the files in play and point at the governing ADR — they don't dump the whole model into the
78
+ context window.
79
+
80
+ ## Declaring intent — `.archsteer/architecture.yaml`
81
+
82
+ ```yaml
83
+ target: "Migrate Express + raw SQL to Next.js route handlers + the repository pattern"
84
+ layers: [route, controller, service, repository, model]
85
+ rules:
86
+ - id: no-raw-sql-outside-repository
87
+ type: required_layer_for_data_access
88
+ allowed_layers: [repository]
89
+ operations: [RAW]
90
+ severity: error
91
+ adr: .archsteer/adr/0001-repository-pattern.md
92
+ steer: "Wrap all queries in a repository under src/repositories/. No raw SQL elsewhere."
93
+ ```
94
+
95
+ Rule types: `required_layer_for_data_access`, `forbidden_import`, `forbidden_data_access`,
96
+ `forbidden_layer_edge`.
97
+
98
+ ## CI / pre-commit
99
+
100
+ - GitHub Action: `.github/workflows/archsteer.yml` (maps, drafts ADRs, runs the net-new gate,
101
+ uploads `report.html`).
102
+ - Git hook: `cp hooks/pre-commit .git/hooks/pre-commit && chmod +x .git/hooks/pre-commit`.
103
+
104
+ ## Try the demo
105
+
106
+ ```bash
107
+ cd examples/demo-repo
108
+ archsteer init && archsteer map && archsteer report # open .archsteer/report.html
109
+ ```
110
+
111
+ ## Roadmap
112
+
113
+ - **Phase 2** — cloud control plane (Next.js + Supabase): multi-repo situation room with
114
+ drift/decision time-series.
115
+ - **Phase 3** — `archsteer mcp`: an MCP server so agents query the live model + intent mid-edit.
116
+ - **Phase 4** — auth, org/repo model, billing.
117
+
118
+ ## Development
119
+
120
+ ```bash
121
+ python3.11 -m venv .venv && source .venv/bin/activate
122
+ pip install -e ".[dev]"
123
+ pytest -q
124
+ ```
@@ -0,0 +1,100 @@
1
+ # ArchSteer
2
+
3
+ **Living Architecture Control Plane for the AI-Dev Era.**
4
+
5
+ AI agents now write code faster than any architect can review, document, or govern it.
6
+ Docs rot instantly, the *real* architecture is invisible, structural decisions get made
7
+ silently, and intended architecture drifts with every edit. ArchSteer is the always-current
8
+ architecture **system of record + governance plane**: it derives the real architecture from
9
+ code, keeps living docs and ADRs auto-built, surfaces every major decision for the architect
10
+ to ratify, enforces declared intent as code-level fitness functions, and steers AI agents to
11
+ conform instead of replicating local slop.
12
+
13
+ Everything is a projection of one code-derived model — `.archsteer/model.json`.
14
+
15
+ ```
16
+ .archsteer/model.json (single source of truth)
17
+
18
+ MAP ──── DOCUMENT ──── GOVERN ──── STEER ──── EVOLVE
19
+ model living docs fitness agent report.html
20
+ from + auto ADRs functions guardrails (drift/
21
+ source + diagrams + ratchet + (MCP) decisions)
22
+ ```
23
+
24
+ ## Install
25
+
26
+ ```bash
27
+ pip install archsteer # regex engine, zero native deps
28
+ pip install "archsteer[treesitter]" # optional native acceleration
29
+ ```
30
+
31
+ ## Quickstart
32
+
33
+ ```bash
34
+ archsteer init # scaffold .archsteer/ + seed intent (Express → Next.js + repository)
35
+ archsteer map # build model.json from source
36
+ archsteer docs # regenerate .archsteer/architecture.md (deterministic, Mermaid)
37
+ archsteer govern # conformance + drift score by rule
38
+ archsteer adr # draft ADRs for new structural decisions (architect-in-the-loop)
39
+ archsteer baseline # accept current debt — the ratchet
40
+ archsteer steer -f src/controllers/payment.js -t "add refund endpoint"
41
+ archsteer check # CI/pre-commit: fail on NET-NEW violations only
42
+ archsteer report # self-contained .archsteer/report.html
43
+ ```
44
+
45
+ ## The three design guarantees
46
+
47
+ 1. **Ratchet, not freeze.** `archsteer check` blocks only *net-new* violations against a
48
+ baseline — teams keep shipping features while debt can only shrink.
49
+ 2. **Conservative, architect-in-the-loop ADRs.** Only external-boundary changes (new
50
+ dependency, new datastore, new layer) draft an ADR — never internal reshuffles, never
51
+ auto-committed.
52
+ 3. **Sharp agent steering.** Guardrails injected into `CLAUDE.md` / `AGENTS.md` are scoped to
53
+ the files in play and point at the governing ADR — they don't dump the whole model into the
54
+ context window.
55
+
56
+ ## Declaring intent — `.archsteer/architecture.yaml`
57
+
58
+ ```yaml
59
+ target: "Migrate Express + raw SQL to Next.js route handlers + the repository pattern"
60
+ layers: [route, controller, service, repository, model]
61
+ rules:
62
+ - id: no-raw-sql-outside-repository
63
+ type: required_layer_for_data_access
64
+ allowed_layers: [repository]
65
+ operations: [RAW]
66
+ severity: error
67
+ adr: .archsteer/adr/0001-repository-pattern.md
68
+ steer: "Wrap all queries in a repository under src/repositories/. No raw SQL elsewhere."
69
+ ```
70
+
71
+ Rule types: `required_layer_for_data_access`, `forbidden_import`, `forbidden_data_access`,
72
+ `forbidden_layer_edge`.
73
+
74
+ ## CI / pre-commit
75
+
76
+ - GitHub Action: `.github/workflows/archsteer.yml` (maps, drafts ADRs, runs the net-new gate,
77
+ uploads `report.html`).
78
+ - Git hook: `cp hooks/pre-commit .git/hooks/pre-commit && chmod +x .git/hooks/pre-commit`.
79
+
80
+ ## Try the demo
81
+
82
+ ```bash
83
+ cd examples/demo-repo
84
+ archsteer init && archsteer map && archsteer report # open .archsteer/report.html
85
+ ```
86
+
87
+ ## Roadmap
88
+
89
+ - **Phase 2** — cloud control plane (Next.js + Supabase): multi-repo situation room with
90
+ drift/decision time-series.
91
+ - **Phase 3** — `archsteer mcp`: an MCP server so agents query the live model + intent mid-edit.
92
+ - **Phase 4** — auth, org/repo model, billing.
93
+
94
+ ## Development
95
+
96
+ ```bash
97
+ python3.11 -m venv .venv && source .venv/bin/activate
98
+ pip install -e ".[dev]"
99
+ pytest -q
100
+ ```
@@ -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"
@@ -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()
@@ -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)