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.
- archsteer-0.1.0/PKG-INFO +124 -0
- archsteer-0.1.0/README.md +100 -0
- archsteer-0.1.0/archsteer/__init__.py +7 -0
- archsteer-0.1.0/archsteer/cli.py +383 -0
- archsteer-0.1.0/archsteer/docs.py +73 -0
- archsteer-0.1.0/archsteer/engine/__init__.py +1 -0
- archsteer-0.1.0/archsteer/engine/baseline.py +53 -0
- archsteer-0.1.0/archsteer/engine/conformance.py +166 -0
- archsteer-0.1.0/archsteer/engine/decisions.py +137 -0
- archsteer-0.1.0/archsteer/engine/evolution.py +192 -0
- archsteer-0.1.0/archsteer/engine/intent.py +66 -0
- archsteer-0.1.0/archsteer/engine/mapper.py +133 -0
- archsteer-0.1.0/archsteer/engine/model.py +130 -0
- archsteer-0.1.0/archsteer/engine/parser.py +243 -0
- archsteer-0.1.0/archsteer/packs/express_to_next/adr/0001-repository-pattern.md +18 -0
- archsteer-0.1.0/archsteer/packs/express_to_next/adr/0002-nextjs-route-handlers.md +17 -0
- archsteer-0.1.0/archsteer/packs/express_to_next/architecture.yaml +45 -0
- archsteer-0.1.0/archsteer/report.py +151 -0
- archsteer-0.1.0/archsteer/steer.py +105 -0
- archsteer-0.1.0/archsteer/workspace.py +47 -0
- archsteer-0.1.0/archsteer.egg-info/PKG-INFO +124 -0
- archsteer-0.1.0/archsteer.egg-info/SOURCES.txt +27 -0
- archsteer-0.1.0/archsteer.egg-info/dependency_links.txt +1 -0
- archsteer-0.1.0/archsteer.egg-info/entry_points.txt +2 -0
- archsteer-0.1.0/archsteer.egg-info/requires.txt +14 -0
- archsteer-0.1.0/archsteer.egg-info/top_level.txt +1 -0
- archsteer-0.1.0/pyproject.toml +47 -0
- archsteer-0.1.0/setup.cfg +4 -0
- archsteer-0.1.0/tests/test_engine.py +162 -0
archsteer-0.1.0/PKG-INFO
ADDED
|
@@ -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,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)
|