archsteer 0.4.0__tar.gz → 0.4.2__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 (32) hide show
  1. {archsteer-0.4.0 → archsteer-0.4.2}/PKG-INFO +38 -11
  2. {archsteer-0.4.0 → archsteer-0.4.2}/README.md +36 -10
  3. {archsteer-0.4.0 → archsteer-0.4.2}/archsteer/__init__.py +1 -1
  4. archsteer-0.4.2/archsteer/engine/mapper.py +217 -0
  5. {archsteer-0.4.0 → archsteer-0.4.2}/archsteer/steer.py +17 -4
  6. {archsteer-0.4.0 → archsteer-0.4.2}/archsteer.egg-info/PKG-INFO +38 -11
  7. {archsteer-0.4.0 → archsteer-0.4.2}/archsteer.egg-info/requires.txt +1 -0
  8. {archsteer-0.4.0 → archsteer-0.4.2}/pyproject.toml +9 -3
  9. {archsteer-0.4.0 → archsteer-0.4.2}/tests/test_engine.py +61 -0
  10. archsteer-0.4.0/archsteer/engine/mapper.py +0 -133
  11. {archsteer-0.4.0 → archsteer-0.4.2}/LICENSE +0 -0
  12. {archsteer-0.4.0 → archsteer-0.4.2}/archsteer/cli.py +0 -0
  13. {archsteer-0.4.0 → archsteer-0.4.2}/archsteer/docs.py +0 -0
  14. {archsteer-0.4.0 → archsteer-0.4.2}/archsteer/engine/__init__.py +0 -0
  15. {archsteer-0.4.0 → archsteer-0.4.2}/archsteer/engine/baseline.py +0 -0
  16. {archsteer-0.4.0 → archsteer-0.4.2}/archsteer/engine/conformance.py +0 -0
  17. {archsteer-0.4.0 → archsteer-0.4.2}/archsteer/engine/decisions.py +0 -0
  18. {archsteer-0.4.0 → archsteer-0.4.2}/archsteer/engine/evolution.py +0 -0
  19. {archsteer-0.4.0 → archsteer-0.4.2}/archsteer/engine/intent.py +0 -0
  20. {archsteer-0.4.0 → archsteer-0.4.2}/archsteer/engine/model.py +0 -0
  21. {archsteer-0.4.0 → archsteer-0.4.2}/archsteer/engine/parser.py +0 -0
  22. {archsteer-0.4.0 → archsteer-0.4.2}/archsteer/mcp_server.py +0 -0
  23. {archsteer-0.4.0 → archsteer-0.4.2}/archsteer/packs/express_to_next/adr/0001-repository-pattern.md +0 -0
  24. {archsteer-0.4.0 → archsteer-0.4.2}/archsteer/packs/express_to_next/adr/0002-nextjs-route-handlers.md +0 -0
  25. {archsteer-0.4.0 → archsteer-0.4.2}/archsteer/packs/express_to_next/architecture.yaml +0 -0
  26. {archsteer-0.4.0 → archsteer-0.4.2}/archsteer/report.py +0 -0
  27. {archsteer-0.4.0 → archsteer-0.4.2}/archsteer/workspace.py +0 -0
  28. {archsteer-0.4.0 → archsteer-0.4.2}/archsteer.egg-info/SOURCES.txt +0 -0
  29. {archsteer-0.4.0 → archsteer-0.4.2}/archsteer.egg-info/dependency_links.txt +0 -0
  30. {archsteer-0.4.0 → archsteer-0.4.2}/archsteer.egg-info/entry_points.txt +0 -0
  31. {archsteer-0.4.0 → archsteer-0.4.2}/archsteer.egg-info/top_level.txt +0 -0
  32. {archsteer-0.4.0 → archsteer-0.4.2}/setup.cfg +0 -0
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: archsteer
3
- Version: 0.4.0
3
+ Version: 0.4.2
4
4
  Summary: Living Architecture Control Plane for the AI-Dev Era
5
5
  Author-email: ArchSteer <founders@archsteer.io>
6
6
  License: MIT
@@ -20,6 +20,7 @@ Requires-Dist: pydantic>=2.7.0
20
20
  Requires-Dist: ruamel.yaml>=0.18.0
21
21
  Requires-Dist: jinja2>=3.1.0
22
22
  Requires-Dist: rich>=13.7.0
23
+ Requires-Dist: mcp>=1.2.0
23
24
  Provides-Extra: treesitter
24
25
  Requires-Dist: tree-sitter<0.22.0,>=0.21.0; extra == "treesitter"
25
26
  Requires-Dist: tree-sitter-languages>=1.10.0; extra == "treesitter"
@@ -31,8 +32,14 @@ Requires-Dist: black>=24.0.0; extra == "dev"
31
32
  Requires-Dist: isort>=5.13.0; extra == "dev"
32
33
  Dynamic: license-file
33
34
 
35
+ <!-- mcp-name: io.github.einvoice-dev1/archsteer -->
36
+
34
37
  # ArchSteer
35
38
 
39
+ [![ArchSteer conformance](https://img.shields.io/endpoint?url=https%3A%2F%2Fwww.archsteer.com%2Fapi%2Fbadge%2Farchsteer)](https://www.archsteer.com)
40
+ [![PyPI](https://img.shields.io/pypi/v/archsteer)](https://pypi.org/project/archsteer/)
41
+ [![License: MIT](https://img.shields.io/badge/License-MIT-green.svg)](LICENSE)
42
+
36
43
  **Living Architecture Control Plane for the AI-Dev Era.**
37
44
 
38
45
  AI agents now write code faster than any architect can review, document, or govern it.
@@ -57,11 +64,13 @@ Everything is a projection of one code-derived model — `.archsteer/model.json`
57
64
  ## Install
58
65
 
59
66
  ```bash
60
- pip install archsteer # regex engine, zero native deps
67
+ pip install archsteer # regex engine + the local MCP server, zero native deps
61
68
  pip install "archsteer[treesitter]" # optional native acceleration
62
- pip install "archsteer[mcp]" # optional: the local MCP server (below)
63
69
  ```
64
70
 
71
+ (Since 0.4.1 the MCP server ships in the base install; `pip install "archsteer[mcp]"` still
72
+ works as a no-op alias.)
73
+
65
74
  ## Quickstart
66
75
 
67
76
  ```bash
@@ -83,9 +92,9 @@ archsteer report # self-contained .archsteer/report.html
83
92
  2. **Conservative, architect-in-the-loop ADRs.** Only external-boundary changes (new
84
93
  dependency, new datastore, new layer) draft an ADR — never internal reshuffles, never
85
94
  auto-committed.
86
- 3. **Sharp agent steering.** Guardrails injected into `CLAUDE.md` / `AGENTS.md` are scoped to
87
- the files in play and point at the governing ADR — they don't dump the whole model into the
88
- context window.
95
+ 3. **Sharp agent steering.** Guardrails injected into `CLAUDE.md`, `AGENTS.md`, and
96
+ `.cursor/rules/archsteer.mdc` (an always-on Cursor rule) are scoped to the files in play and
97
+ point at the governing ADR — they don't dump the whole model into the context window.
89
98
 
90
99
  ## Declaring intent — `.archsteer/architecture.yaml`
91
100
 
@@ -121,18 +130,37 @@ Add it to Claude Code:
121
130
  claude mcp add archsteer -- archsteer mcp
122
131
  ```
123
132
 
124
- Or to any MCP-compatible client's config (Cursor, etc.):
133
+ Add it to Cursor with one click:
134
+ [**Install in Cursor →**](cursor://anysphere.cursor-deeplink/mcp/install?name=archsteer&config=eyJjb21tYW5kIjoiYXJjaHN0ZWVyIiwiYXJncyI6WyJtY3AiXX0%3D)
135
+
136
+ Or to any MCP-compatible client's config:
125
137
 
126
138
  ```json
127
139
  { "mcpServers": { "archsteer": { "command": "archsteer", "args": ["mcp"] } } }
128
140
  ```
129
141
 
142
+ Also published to the [official MCP registry](https://registry.modelcontextprotocol.io) as
143
+ `io.github.einvoice-dev1/archsteer` (runnable via `uvx archsteer mcp`).
144
+
130
145
  ## CI / pre-commit
131
146
 
132
147
  - GitHub Action: `.github/workflows/archsteer.yml` (maps, drafts ADRs, runs the net-new gate,
133
148
  uploads `report.html`).
134
149
  - Git hook: `cp hooks/pre-commit .git/hooks/pre-commit && chmod +x .git/hooks/pre-commit`.
135
150
 
151
+ ## Conformance badge
152
+
153
+ If your repo pushes snapshots to the situation room (`archsteer push`), its latest
154
+ conformance score is a live badge — the one at the top of this README is this repo
155
+ governing itself:
156
+
157
+ ```markdown
158
+ [![ArchSteer conformance](https://img.shields.io/endpoint?url=https%3A%2F%2Fwww.archsteer.com%2Fapi%2Fbadge%2FYOUR-REPO)](https://www.archsteer.com)
159
+ ```
160
+
161
+ Replace `YOUR-REPO` with the repo name `archsteer push` reports. Green at ≥90%, grey while
162
+ you're still x-ray-only (no `architecture.yaml` declared yet).
163
+
136
164
  ## Try the demo
137
165
 
138
166
  ```bash
@@ -144,10 +172,9 @@ archsteer init && archsteer map && archsteer report # open .archsteer/report.h
144
172
 
145
173
  - **Shipped** — cloud control plane (Next.js + Supabase): multi-repo situation room with
146
174
  drift/decision time-series. `archsteer mcp`: a local MCP server so agents query the live
147
- model + intent mid-edit.
148
- - **Next** an org-wide, hosted MCP server (Team tier) so agents can ask cross-repo questions
149
- against the situation room "what's our drift index," "which repos have pending ADRs" —
150
- the same data as the dashboard, over MCP.
175
+ model + intent mid-edit. An org-wide, hosted MCP server (Team tier) so agents can ask
176
+ cross-repo questions against the situation room "what's our drift index," "which repos
177
+ have pending ADRs"the same data as the dashboard, over MCP.
151
178
  - **Later** — auth, org/repo model, billing.
152
179
 
153
180
  ## Development
@@ -1,5 +1,11 @@
1
+ <!-- mcp-name: io.github.einvoice-dev1/archsteer -->
2
+
1
3
  # ArchSteer
2
4
 
5
+ [![ArchSteer conformance](https://img.shields.io/endpoint?url=https%3A%2F%2Fwww.archsteer.com%2Fapi%2Fbadge%2Farchsteer)](https://www.archsteer.com)
6
+ [![PyPI](https://img.shields.io/pypi/v/archsteer)](https://pypi.org/project/archsteer/)
7
+ [![License: MIT](https://img.shields.io/badge/License-MIT-green.svg)](LICENSE)
8
+
3
9
  **Living Architecture Control Plane for the AI-Dev Era.**
4
10
 
5
11
  AI agents now write code faster than any architect can review, document, or govern it.
@@ -24,11 +30,13 @@ Everything is a projection of one code-derived model — `.archsteer/model.json`
24
30
  ## Install
25
31
 
26
32
  ```bash
27
- pip install archsteer # regex engine, zero native deps
33
+ pip install archsteer # regex engine + the local MCP server, zero native deps
28
34
  pip install "archsteer[treesitter]" # optional native acceleration
29
- pip install "archsteer[mcp]" # optional: the local MCP server (below)
30
35
  ```
31
36
 
37
+ (Since 0.4.1 the MCP server ships in the base install; `pip install "archsteer[mcp]"` still
38
+ works as a no-op alias.)
39
+
32
40
  ## Quickstart
33
41
 
34
42
  ```bash
@@ -50,9 +58,9 @@ archsteer report # self-contained .archsteer/report.html
50
58
  2. **Conservative, architect-in-the-loop ADRs.** Only external-boundary changes (new
51
59
  dependency, new datastore, new layer) draft an ADR — never internal reshuffles, never
52
60
  auto-committed.
53
- 3. **Sharp agent steering.** Guardrails injected into `CLAUDE.md` / `AGENTS.md` are scoped to
54
- the files in play and point at the governing ADR — they don't dump the whole model into the
55
- context window.
61
+ 3. **Sharp agent steering.** Guardrails injected into `CLAUDE.md`, `AGENTS.md`, and
62
+ `.cursor/rules/archsteer.mdc` (an always-on Cursor rule) are scoped to the files in play and
63
+ point at the governing ADR — they don't dump the whole model into the context window.
56
64
 
57
65
  ## Declaring intent — `.archsteer/architecture.yaml`
58
66
 
@@ -88,18 +96,37 @@ Add it to Claude Code:
88
96
  claude mcp add archsteer -- archsteer mcp
89
97
  ```
90
98
 
91
- Or to any MCP-compatible client's config (Cursor, etc.):
99
+ Add it to Cursor with one click:
100
+ [**Install in Cursor →**](cursor://anysphere.cursor-deeplink/mcp/install?name=archsteer&config=eyJjb21tYW5kIjoiYXJjaHN0ZWVyIiwiYXJncyI6WyJtY3AiXX0%3D)
101
+
102
+ Or to any MCP-compatible client's config:
92
103
 
93
104
  ```json
94
105
  { "mcpServers": { "archsteer": { "command": "archsteer", "args": ["mcp"] } } }
95
106
  ```
96
107
 
108
+ Also published to the [official MCP registry](https://registry.modelcontextprotocol.io) as
109
+ `io.github.einvoice-dev1/archsteer` (runnable via `uvx archsteer mcp`).
110
+
97
111
  ## CI / pre-commit
98
112
 
99
113
  - GitHub Action: `.github/workflows/archsteer.yml` (maps, drafts ADRs, runs the net-new gate,
100
114
  uploads `report.html`).
101
115
  - Git hook: `cp hooks/pre-commit .git/hooks/pre-commit && chmod +x .git/hooks/pre-commit`.
102
116
 
117
+ ## Conformance badge
118
+
119
+ If your repo pushes snapshots to the situation room (`archsteer push`), its latest
120
+ conformance score is a live badge — the one at the top of this README is this repo
121
+ governing itself:
122
+
123
+ ```markdown
124
+ [![ArchSteer conformance](https://img.shields.io/endpoint?url=https%3A%2F%2Fwww.archsteer.com%2Fapi%2Fbadge%2FYOUR-REPO)](https://www.archsteer.com)
125
+ ```
126
+
127
+ Replace `YOUR-REPO` with the repo name `archsteer push` reports. Green at ≥90%, grey while
128
+ you're still x-ray-only (no `architecture.yaml` declared yet).
129
+
103
130
  ## Try the demo
104
131
 
105
132
  ```bash
@@ -111,10 +138,9 @@ archsteer init && archsteer map && archsteer report # open .archsteer/report.h
111
138
 
112
139
  - **Shipped** — cloud control plane (Next.js + Supabase): multi-repo situation room with
113
140
  drift/decision time-series. `archsteer mcp`: a local MCP server so agents query the live
114
- model + intent mid-edit.
115
- - **Next** an org-wide, hosted MCP server (Team tier) so agents can ask cross-repo questions
116
- against the situation room "what's our drift index," "which repos have pending ADRs" —
117
- the same data as the dashboard, over MCP.
141
+ model + intent mid-edit. An org-wide, hosted MCP server (Team tier) so agents can ask
142
+ cross-repo questions against the situation room "what's our drift index," "which repos
143
+ have pending ADRs"the same data as the dashboard, over MCP.
118
144
  - **Later** — auth, org/repo model, billing.
119
145
 
120
146
  ## Development
@@ -4,4 +4,4 @@ One code-derived model (``model.json``) powers every pillar:
4
4
  MAP, DOCUMENT, GOVERN, STEER, EVOLVE.
5
5
  """
6
6
 
7
- __version__ = "0.4.0"
7
+ __version__ = "0.4.2"
@@ -0,0 +1,217 @@
1
+ """Walks a repo and assembles the :class:`ArchitectureModel` from parsed files.
2
+
3
+ Resolves relative imports to internal component keys, infers a layer per
4
+ component from path conventions (overridable by intent later), and records the
5
+ declared third-party manifest dependencies that ADR decision detection watches.
6
+ """
7
+
8
+ from __future__ import annotations
9
+
10
+ import json
11
+ import os
12
+ import re
13
+ import subprocess
14
+ from pathlib import Path
15
+ from typing import List, Optional
16
+
17
+ from archsteer.engine.model import ArchitectureModel, DependencyEdge
18
+ from archsteer.engine.parser import CodeParserFacade
19
+
20
+ try: # stdlib on 3.11+; on 3.10 we fall back to a scoped regex
21
+ import tomllib
22
+ except ModuleNotFoundError: # pragma: no cover - Python 3.10 only
23
+ tomllib = None # type: ignore[assignment]
24
+
25
+ IGNORE_DIRS = {
26
+ ".git", ".archsteer", ".venv", "venv", "node_modules", "__pycache__",
27
+ "dist", "build", ".next", ".turbo", "coverage", ".pytest_cache", ".mypy_cache",
28
+ }
29
+ SOURCE_EXTS = {".ts", ".tsx", ".js", ".jsx", ".mjs", ".cjs", ".py"}
30
+
31
+ # Path-segment -> layer. First match wins. Architects refine this in intent.
32
+ LAYER_HINTS = [
33
+ ("repositories", "repository"),
34
+ ("repository", "repository"),
35
+ ("controllers", "controller"),
36
+ ("controller", "controller"),
37
+ ("routes", "route"),
38
+ ("routers", "route"),
39
+ ("services", "service"),
40
+ ("service", "service"),
41
+ ("models", "model"),
42
+ ("entities", "model"),
43
+ ("middleware", "middleware"),
44
+ ("handlers", "handler"),
45
+ ("api", "api"),
46
+ ("lib", "lib"),
47
+ ("utils", "util"),
48
+ ("components", "ui"),
49
+ ]
50
+ _CANDIDATE_EXTS = ["", ".ts", ".tsx", ".js", ".jsx", ".mjs", ".cjs", ".py"]
51
+
52
+
53
+ def _infer_layer(rel_path: str) -> Optional[str]:
54
+ segments = {s.lower() for s in Path(rel_path).parts}
55
+ for hint, layer in LAYER_HINTS:
56
+ if hint in segments:
57
+ return layer
58
+ return None
59
+
60
+
61
+ def _git_sha(root: Path) -> Optional[str]:
62
+ try:
63
+ out = subprocess.run(
64
+ ["git", "-C", str(root), "rev-parse", "--short", "HEAD"],
65
+ capture_output=True, text=True, timeout=5,
66
+ )
67
+ return out.stdout.strip() or None if out.returncode == 0 else None
68
+ except Exception: # noqa: BLE001
69
+ return None
70
+
71
+
72
+ def _read_manifest_deps(root: Path) -> List[str]:
73
+ """Declared RUNTIME dependency names from package.json / pyproject.toml.
74
+
75
+ devDependencies and dev extras are deliberately excluded — tooling churn
76
+ (linters, test runners) would flood ADR decision detection with
77
+ meaningless "new dependency" entries.
78
+ """
79
+ deps: set[str] = set()
80
+ pkg = root / "package.json"
81
+ if pkg.exists():
82
+ try:
83
+ data = json.loads(pkg.read_text(encoding="utf-8"))
84
+ for key in ("dependencies", "peerDependencies"):
85
+ deps.update((data.get(key) or {}).keys())
86
+ except (json.JSONDecodeError, OSError):
87
+ pass
88
+ pyproject = root / "pyproject.toml"
89
+ if pyproject.exists():
90
+ text = pyproject.read_text(encoding="utf-8")
91
+ specs: List[str] = []
92
+ if tomllib is not None:
93
+ try:
94
+ specs = list((tomllib.loads(text).get("project") or {}).get("dependencies") or [])
95
+ except tomllib.TOMLDecodeError:
96
+ pass
97
+ else: # Python 3.10: no stdlib TOML parser; scope the regex to dep arrays
98
+ for block in re.finditer(r"^dependencies\s*=\s*\[(.*?)\]", text, re.DOTALL | re.MULTILINE):
99
+ specs += re.findall(r"""["']([^"']+)["']""", block.group(1))
100
+ for spec in specs: # PEP 508: name is the leading token
101
+ m = re.match(r"\s*([A-Za-z0-9_.\-]+)", spec)
102
+ if m:
103
+ deps.add(m.group(1))
104
+ return sorted(deps)
105
+
106
+
107
+ def _resolve_internal(importer_rel: str, target: str, components: dict) -> Optional[str]:
108
+ """Resolve a relative JS/TS import (``./x``) to a component key, or None."""
109
+ if not target.startswith("."):
110
+ return None
111
+ raw = (Path(importer_rel).parent / target).as_posix()
112
+ base_posix = os.path.normpath(raw).replace(os.sep, "/") # collapse ../ segments
113
+ for ext in _CANDIDATE_EXTS:
114
+ cand = f"{base_posix}{ext}"
115
+ if cand in components:
116
+ return cand
117
+ idx = f"{base_posix}/index{ext}" if ext else None
118
+ if idx and idx in components:
119
+ return idx
120
+ return None
121
+
122
+
123
+ def _resolve_python(
124
+ importer_rel: str, target: str, symbols: List[str], components: dict, root_name: str
125
+ ) -> List[str]:
126
+ """Resolve a Python import to component keys (possibly several).
127
+
128
+ Handles the shapes the JS resolver can't:
129
+ - relative imports: ``from .utils import x`` / ``from ..pkg.mod import y``
130
+ (dotted prefix = directory levels up from the importer)
131
+ - same-package absolute imports: ``from fastapi import routing`` resolves
132
+ whether the x-ray root is the repo (components keyed ``fastapi/...``) or
133
+ the package dir itself (components keyed ``routing.py`` — first segment
134
+ matches the root dir name and is stripped).
135
+ - submodule imports through a package: ``from pkg import mod_a, mod_b``
136
+ resolves to each symbol that is itself a module file, so the real edges
137
+ aren't collapsed onto ``pkg/__init__.py``.
138
+
139
+ A stdlib/third-party name shadowed by a local module resolves to the local
140
+ file, mirroring how Python's own import system can shadow.
141
+ """
142
+ def probe(mod_path: str) -> Optional[str]:
143
+ mod_path = os.path.normpath(mod_path).replace(os.sep, "/")
144
+ if mod_path in (".", ""):
145
+ mod_path = "__init__" # `from . import x` in a root-level module
146
+ for cand in (f"{mod_path}.py", f"{mod_path}/__init__.py"):
147
+ if cand in components:
148
+ return cand
149
+ return None
150
+
151
+ if target.startswith("."):
152
+ dots = len(target) - len(target.lstrip("."))
153
+ rest = target.lstrip(".")
154
+ base = Path(importer_rel).parent
155
+ for _ in range(dots - 1):
156
+ base = base.parent
157
+ base_path = (base / rest.replace(".", "/")).as_posix() if rest else base.as_posix()
158
+ else:
159
+ parts = target.split(".")
160
+ if probe("/".join(parts)) is None and parts[0] == root_name:
161
+ parts = parts[1:]
162
+ base_path = "/".join(parts)
163
+
164
+ hit = probe(base_path)
165
+ if hit is None:
166
+ return []
167
+ if not hit.endswith("__init__.py"):
168
+ return [hit]
169
+ # Package import: symbols may be submodules — resolve each that is one.
170
+ pkg_dir = hit[: -len("__init__.py")]
171
+ sub_hits = [h for s in symbols if (h := probe(f"{pkg_dir}{s}"))]
172
+ return sub_hits or [hit]
173
+
174
+
175
+ def build_model(root_dir: str | Path) -> ArchitectureModel:
176
+ root = Path(root_dir).resolve()
177
+ parser = CodeParserFacade()
178
+ model = ArchitectureModel(
179
+ repo_name=root.name,
180
+ commit_sha=_git_sha(root),
181
+ manifest_dependencies=_read_manifest_deps(root),
182
+ )
183
+
184
+ for path in sorted(root.rglob("*")):
185
+ if not path.is_file() or path.suffix not in SOURCE_EXTS:
186
+ continue
187
+ if any(part in IGNORE_DIRS for part in path.relative_to(root).parts):
188
+ continue
189
+ comp = parser.parse_file(path, root)
190
+ comp.layer = _infer_layer(comp.file_path)
191
+ model.components[comp.file_path] = comp
192
+
193
+ # Second pass: resolve internal edges now that all components are known.
194
+ # Python deps are probed even when the parser flagged them external:
195
+ # `from mypkg import mod` is textually indistinguishable from a third-party
196
+ # import, so resolution against the actual component set is the arbiter.
197
+ for rel, comp in model.components.items():
198
+ extra: List[DependencyEdge] = []
199
+ for dep in comp.dependencies:
200
+ if comp.language == "python":
201
+ hits = _resolve_python(rel, dep.target, dep.symbols, model.components, root.name)
202
+ elif dep.external:
203
+ continue
204
+ else:
205
+ one = _resolve_internal(rel, dep.target, model.components)
206
+ hits = [one] if one else []
207
+ if hits:
208
+ dep.target = hits[0]
209
+ dep.external = False
210
+ # `from pkg import mod_a, mod_b`: one statement, several real edges.
211
+ extra += [
212
+ dep.model_copy(update={"target": h, "symbols": []}) for h in hits[1:]
213
+ ]
214
+ # else: keep as-is (unresolved relative imports stay internal,
215
+ # unresolved absolute imports stay external)
216
+ comp.dependencies.extend(extra)
217
+ return model
@@ -20,7 +20,10 @@ from archsteer.engine.model import ArchitectureModel
20
20
  START_MARKER = "<!-- BEGIN ARCHSTEER GUARDRAILS (auto-generated; do not edit) -->"
21
21
  END_MARKER = "<!-- END ARCHSTEER GUARDRAILS -->"
22
22
 
23
- DEFAULT_TARGETS = ["CLAUDE.md", "AGENTS.md", ".cursor/rules"]
23
+ DEFAULT_TARGETS = ["CLAUDE.md", "AGENTS.md", ".cursor/rules/archsteer.mdc"]
24
+ # Files ArchSteer owns outright and will create on first run. AGENTS.md is a
25
+ # shared multi-tool convention we only append to if the project already has one.
26
+ _AUTO_CREATE = ("CLAUDE.md", ".cursor/rules/archsteer.mdc")
24
27
 
25
28
 
26
29
  def _rule_applies_to_files(rule: Rule, files: List[str], model: ArchitectureModel) -> bool:
@@ -83,8 +86,9 @@ class AgentSteeringEngine:
83
86
  written: List[Path] = []
84
87
  for target in (targets or DEFAULT_TARGETS):
85
88
  path = self.root / target
86
- if not path.exists() and target not in ("CLAUDE.md",):
87
- # Only auto-create the canonical CLAUDE.md; respect existing others.
89
+ if not path.exists() and target not in _AUTO_CREATE:
90
+ # Respect files/dirs the project doesn't already use, except
91
+ # for the files ArchSteer owns outright (see _AUTO_CREATE).
88
92
  if not path.parent.exists():
89
93
  continue
90
94
  written.append(self._inject(path, payload))
@@ -92,14 +96,23 @@ class AgentSteeringEngine:
92
96
 
93
97
  @staticmethod
94
98
  def _inject(path: Path, payload: str) -> Path:
99
+ # `.cursor/rules` is a directory by Cursor convention (may already
100
+ # exist as one in real repos) — never write to it directly. We only
101
+ # ever target a specific file inside it (see DEFAULT_TARGETS).
102
+ if path.is_dir():
103
+ raise ValueError(f"steer target must be a file, not a directory: {path}")
95
104
  content = path.read_text(encoding="utf-8") if path.exists() else ""
105
+ if path.suffix == ".mdc" and not content.lstrip().startswith("---"):
106
+ # Cursor rule frontmatter: alwaysApply=true keeps architecture
107
+ # guardrails in every agent session, matching CLAUDE.md/AGENTS.md.
108
+ content = f"---\nalwaysApply: true\n---\n\n{content}" if content else "---\nalwaysApply: true\n---\n"
96
109
  pattern = re.compile(
97
110
  re.escape(START_MARKER) + r".*?" + re.escape(END_MARKER), re.DOTALL
98
111
  )
99
112
  if pattern.search(content):
100
113
  updated = pattern.sub(payload, content)
101
114
  else:
102
- updated = f"{content.rstrip()}\n\n{payload}\n" if content else payload + "\n"
115
+ updated = f"{content.rstrip()}\n\n{payload}\n" if content.strip() else f"{content}{payload}\n"
103
116
  path.parent.mkdir(parents=True, exist_ok=True)
104
117
  path.write_text(updated.strip() + "\n", encoding="utf-8")
105
118
  return path
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: archsteer
3
- Version: 0.4.0
3
+ Version: 0.4.2
4
4
  Summary: Living Architecture Control Plane for the AI-Dev Era
5
5
  Author-email: ArchSteer <founders@archsteer.io>
6
6
  License: MIT
@@ -20,6 +20,7 @@ Requires-Dist: pydantic>=2.7.0
20
20
  Requires-Dist: ruamel.yaml>=0.18.0
21
21
  Requires-Dist: jinja2>=3.1.0
22
22
  Requires-Dist: rich>=13.7.0
23
+ Requires-Dist: mcp>=1.2.0
23
24
  Provides-Extra: treesitter
24
25
  Requires-Dist: tree-sitter<0.22.0,>=0.21.0; extra == "treesitter"
25
26
  Requires-Dist: tree-sitter-languages>=1.10.0; extra == "treesitter"
@@ -31,8 +32,14 @@ Requires-Dist: black>=24.0.0; extra == "dev"
31
32
  Requires-Dist: isort>=5.13.0; extra == "dev"
32
33
  Dynamic: license-file
33
34
 
35
+ <!-- mcp-name: io.github.einvoice-dev1/archsteer -->
36
+
34
37
  # ArchSteer
35
38
 
39
+ [![ArchSteer conformance](https://img.shields.io/endpoint?url=https%3A%2F%2Fwww.archsteer.com%2Fapi%2Fbadge%2Farchsteer)](https://www.archsteer.com)
40
+ [![PyPI](https://img.shields.io/pypi/v/archsteer)](https://pypi.org/project/archsteer/)
41
+ [![License: MIT](https://img.shields.io/badge/License-MIT-green.svg)](LICENSE)
42
+
36
43
  **Living Architecture Control Plane for the AI-Dev Era.**
37
44
 
38
45
  AI agents now write code faster than any architect can review, document, or govern it.
@@ -57,11 +64,13 @@ Everything is a projection of one code-derived model — `.archsteer/model.json`
57
64
  ## Install
58
65
 
59
66
  ```bash
60
- pip install archsteer # regex engine, zero native deps
67
+ pip install archsteer # regex engine + the local MCP server, zero native deps
61
68
  pip install "archsteer[treesitter]" # optional native acceleration
62
- pip install "archsteer[mcp]" # optional: the local MCP server (below)
63
69
  ```
64
70
 
71
+ (Since 0.4.1 the MCP server ships in the base install; `pip install "archsteer[mcp]"` still
72
+ works as a no-op alias.)
73
+
65
74
  ## Quickstart
66
75
 
67
76
  ```bash
@@ -83,9 +92,9 @@ archsteer report # self-contained .archsteer/report.html
83
92
  2. **Conservative, architect-in-the-loop ADRs.** Only external-boundary changes (new
84
93
  dependency, new datastore, new layer) draft an ADR — never internal reshuffles, never
85
94
  auto-committed.
86
- 3. **Sharp agent steering.** Guardrails injected into `CLAUDE.md` / `AGENTS.md` are scoped to
87
- the files in play and point at the governing ADR — they don't dump the whole model into the
88
- context window.
95
+ 3. **Sharp agent steering.** Guardrails injected into `CLAUDE.md`, `AGENTS.md`, and
96
+ `.cursor/rules/archsteer.mdc` (an always-on Cursor rule) are scoped to the files in play and
97
+ point at the governing ADR — they don't dump the whole model into the context window.
89
98
 
90
99
  ## Declaring intent — `.archsteer/architecture.yaml`
91
100
 
@@ -121,18 +130,37 @@ Add it to Claude Code:
121
130
  claude mcp add archsteer -- archsteer mcp
122
131
  ```
123
132
 
124
- Or to any MCP-compatible client's config (Cursor, etc.):
133
+ Add it to Cursor with one click:
134
+ [**Install in Cursor →**](cursor://anysphere.cursor-deeplink/mcp/install?name=archsteer&config=eyJjb21tYW5kIjoiYXJjaHN0ZWVyIiwiYXJncyI6WyJtY3AiXX0%3D)
135
+
136
+ Or to any MCP-compatible client's config:
125
137
 
126
138
  ```json
127
139
  { "mcpServers": { "archsteer": { "command": "archsteer", "args": ["mcp"] } } }
128
140
  ```
129
141
 
142
+ Also published to the [official MCP registry](https://registry.modelcontextprotocol.io) as
143
+ `io.github.einvoice-dev1/archsteer` (runnable via `uvx archsteer mcp`).
144
+
130
145
  ## CI / pre-commit
131
146
 
132
147
  - GitHub Action: `.github/workflows/archsteer.yml` (maps, drafts ADRs, runs the net-new gate,
133
148
  uploads `report.html`).
134
149
  - Git hook: `cp hooks/pre-commit .git/hooks/pre-commit && chmod +x .git/hooks/pre-commit`.
135
150
 
151
+ ## Conformance badge
152
+
153
+ If your repo pushes snapshots to the situation room (`archsteer push`), its latest
154
+ conformance score is a live badge — the one at the top of this README is this repo
155
+ governing itself:
156
+
157
+ ```markdown
158
+ [![ArchSteer conformance](https://img.shields.io/endpoint?url=https%3A%2F%2Fwww.archsteer.com%2Fapi%2Fbadge%2FYOUR-REPO)](https://www.archsteer.com)
159
+ ```
160
+
161
+ Replace `YOUR-REPO` with the repo name `archsteer push` reports. Green at ≥90%, grey while
162
+ you're still x-ray-only (no `architecture.yaml` declared yet).
163
+
136
164
  ## Try the demo
137
165
 
138
166
  ```bash
@@ -144,10 +172,9 @@ archsteer init && archsteer map && archsteer report # open .archsteer/report.h
144
172
 
145
173
  - **Shipped** — cloud control plane (Next.js + Supabase): multi-repo situation room with
146
174
  drift/decision time-series. `archsteer mcp`: a local MCP server so agents query the live
147
- model + intent mid-edit.
148
- - **Next** an org-wide, hosted MCP server (Team tier) so agents can ask cross-repo questions
149
- against the situation room "what's our drift index," "which repos have pending ADRs" —
150
- the same data as the dashboard, over MCP.
175
+ model + intent mid-edit. An org-wide, hosted MCP server (Team tier) so agents can ask
176
+ cross-repo questions against the situation room "what's our drift index," "which repos
177
+ have pending ADRs"the same data as the dashboard, over MCP.
151
178
  - **Later** — auth, org/repo model, billing.
152
179
 
153
180
  ## Development
@@ -3,6 +3,7 @@ pydantic>=2.7.0
3
3
  ruamel.yaml>=0.18.0
4
4
  jinja2>=3.1.0
5
5
  rich>=13.7.0
6
+ mcp>=1.2.0
6
7
 
7
8
  [dev]
8
9
  pytest>=8.0.0
@@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta"
4
4
 
5
5
  [project]
6
6
  name = "archsteer"
7
- version = "0.4.0"
7
+ version = "0.4.2"
8
8
  description = "Living Architecture Control Plane for the AI-Dev Era"
9
9
  readme = "README.md"
10
10
  requires-python = ">=3.10"
@@ -21,6 +21,11 @@ dependencies = [
21
21
  "ruamel.yaml>=0.18.0",
22
22
  "jinja2>=3.1.0",
23
23
  "rich>=13.7.0",
24
+ # mcp moved from an extra to a base dep in 0.4.1: MCP registries and
25
+ # runners (`uvx archsteer`) can't express extras, and `archsteer mcp`
26
+ # failing after a plain install was a broken first-run for exactly the
27
+ # AI-agent users the command exists for.
28
+ "mcp>=1.2.0",
24
29
  ]
25
30
 
26
31
  [project.optional-dependencies]
@@ -31,8 +36,9 @@ treesitter = [
31
36
  "tree-sitter>=0.21.0,<0.22.0",
32
37
  "tree-sitter-languages>=1.10.0",
33
38
  ]
34
- # `archsteer mcp` a local MCP server for AI agents. Optional so the base
35
- # install stays dependency-light; only pulled in by people who use it.
39
+ # Kept as a no-op alias for compatibility: docs published before 0.4.1 say
40
+ # `pip install "archsteer[mcp]"` and that must keep working. The mcp
41
+ # dependency itself is in the base install now (see dependencies above).
36
42
  mcp = [
37
43
  "mcp>=1.2.0",
38
44
  ]
@@ -118,6 +118,27 @@ def test_steer_is_idempotent(tmp_path: Path):
118
118
  assert target.read_text().count(START_MARKER) == 1
119
119
 
120
120
 
121
+ def test_steer_writes_cursor_mdc_with_frontmatter(tmp_path: Path):
122
+ # Real repos already have .cursor/rules as a directory (Cursor's own
123
+ # convention) — writing must target a file inside it, not the dir itself.
124
+ (tmp_path / ".cursor" / "rules").mkdir(parents=True)
125
+ _legacy_repo(tmp_path)
126
+ model = build_model(tmp_path)
127
+ engine = AgentSteeringEngine(tmp_path)
128
+ payload = engine.synthesize(_intent(), model, files=["src/controllers/payment_controller.js"])
129
+ written = engine.write(payload)
130
+ mdc = tmp_path / ".cursor" / "rules" / "archsteer.mdc"
131
+ assert mdc in written
132
+ text = mdc.read_text()
133
+ assert text.startswith("---\nalwaysApply: true\n---")
134
+ assert START_MARKER in text
135
+ # Idempotent: re-running doesn't duplicate the marker or the frontmatter.
136
+ engine.write(engine.synthesize(_intent(), model, files=["src/controllers/payment_controller.js"]))
137
+ text2 = mdc.read_text()
138
+ assert text2.count(START_MARKER) == 1
139
+ assert text2.count("alwaysApply: true") == 1
140
+
141
+
121
142
  def test_docs_render_is_deterministic(tmp_path: Path):
122
143
  _legacy_repo(tmp_path)
123
144
  model = build_model(tmp_path)
@@ -160,3 +181,43 @@ def test_first_feed_is_baseline(tmp_path: Path):
160
181
  _legacy_repo(tmp_path)
161
182
  feed = compute_feed(None, build_model(tmp_path))
162
183
  assert feed.is_first and feed.changes == []
184
+
185
+
186
+ def test_python_import_resolution(tmp_path: Path) -> None:
187
+ """Relative and same-package absolute Python imports resolve to internal edges."""
188
+ _write(tmp_path, "mypkg/__init__.py", "")
189
+ _write(tmp_path, "mypkg/routing.py", "from .utils import helper\nclass Router: pass\n")
190
+ _write(tmp_path, "mypkg/utils.py", "import json\ndef helper(): pass\n")
191
+ _write(tmp_path, "mypkg/sub/deep.py", "from ..routing import Router\nfrom mypkg import utils\n")
192
+ model = build_model(tmp_path)
193
+ edges = set(model.internal_edges())
194
+ assert ("mypkg/routing.py", "mypkg/utils.py") in edges # relative .utils
195
+ assert ("mypkg/sub/deep.py", "mypkg/routing.py") in edges # relative ..routing
196
+ assert ("mypkg/sub/deep.py", "mypkg/utils.py") in edges # absolute mypkg.utils
197
+ # stdlib import stays external
198
+ utils = model.components["mypkg/utils.py"]
199
+ assert all(d.external for d in utils.dependencies if d.target == "json")
200
+
201
+
202
+ def test_python_resolution_at_package_root(tmp_path: Path) -> None:
203
+ """X-raying the package dir itself resolves pkg-prefixed absolute imports."""
204
+ pkg = tmp_path / "mypkg"
205
+ _write(tmp_path, "mypkg/__init__.py", "")
206
+ _write(tmp_path, "mypkg/a.py", "from mypkg.b import thing\n")
207
+ _write(tmp_path, "mypkg/b.py", "def thing(): pass\n")
208
+ model = build_model(pkg)
209
+ assert ("a.py", "b.py") in set(model.internal_edges())
210
+
211
+
212
+ def test_manifest_deps_runtime_only(tmp_path: Path) -> None:
213
+ """Manifest reader takes runtime deps only, not dev tooling or stray strings."""
214
+ _write(tmp_path, "package.json",
215
+ '{"dependencies":{"express":"^4"},"devDependencies":{"eslint":"^9"},"peerDependencies":{"react":"^19"}}')
216
+ _write(tmp_path, "pyproject.toml",
217
+ '[project]\nname = "x"\nlicense = "BSD-3-Clause"\n'
218
+ 'dependencies = ["pydantic>=2.7.0", "rich>=13"]\n'
219
+ '[project.optional-dependencies]\ndev = ["pytest>=8"]\n'
220
+ '[tool.pytest.ini_options]\naddopts = "--strict-markers"\n')
221
+ deps = set(build_model(tmp_path).manifest_dependencies)
222
+ assert {"express", "react", "pydantic", "rich"} <= deps
223
+ assert not {"eslint", "pytest", "BSD-3-Clause", "--strict-markers"} & deps
@@ -1,133 +0,0 @@
1
- """Walks a repo and assembles the :class:`ArchitectureModel` from parsed files.
2
-
3
- Resolves relative imports to internal component keys, infers a layer per
4
- component from path conventions (overridable by intent later), and records the
5
- declared third-party manifest dependencies that ADR decision detection watches.
6
- """
7
-
8
- from __future__ import annotations
9
-
10
- import json
11
- import os
12
- import re
13
- import subprocess
14
- from pathlib import Path
15
- from typing import List, Optional
16
-
17
- from archsteer.engine.model import ArchitectureModel
18
- from archsteer.engine.parser import CodeParserFacade
19
-
20
- IGNORE_DIRS = {
21
- ".git", ".archsteer", ".venv", "venv", "node_modules", "__pycache__",
22
- "dist", "build", ".next", ".turbo", "coverage", ".pytest_cache", ".mypy_cache",
23
- }
24
- SOURCE_EXTS = {".ts", ".tsx", ".js", ".jsx", ".mjs", ".cjs", ".py"}
25
-
26
- # Path-segment -> layer. First match wins. Architects refine this in intent.
27
- LAYER_HINTS = [
28
- ("repositories", "repository"),
29
- ("repository", "repository"),
30
- ("controllers", "controller"),
31
- ("controller", "controller"),
32
- ("routes", "route"),
33
- ("routers", "route"),
34
- ("services", "service"),
35
- ("service", "service"),
36
- ("models", "model"),
37
- ("entities", "model"),
38
- ("middleware", "middleware"),
39
- ("handlers", "handler"),
40
- ("api", "api"),
41
- ("lib", "lib"),
42
- ("utils", "util"),
43
- ("components", "ui"),
44
- ]
45
- _CANDIDATE_EXTS = ["", ".ts", ".tsx", ".js", ".jsx", ".mjs", ".cjs", ".py"]
46
-
47
-
48
- def _infer_layer(rel_path: str) -> Optional[str]:
49
- segments = {s.lower() for s in Path(rel_path).parts}
50
- for hint, layer in LAYER_HINTS:
51
- if hint in segments:
52
- return layer
53
- return None
54
-
55
-
56
- def _git_sha(root: Path) -> Optional[str]:
57
- try:
58
- out = subprocess.run(
59
- ["git", "-C", str(root), "rev-parse", "--short", "HEAD"],
60
- capture_output=True, text=True, timeout=5,
61
- )
62
- return out.stdout.strip() or None if out.returncode == 0 else None
63
- except Exception: # noqa: BLE001
64
- return None
65
-
66
-
67
- def _read_manifest_deps(root: Path) -> List[str]:
68
- deps: set[str] = set()
69
- pkg = root / "package.json"
70
- if pkg.exists():
71
- try:
72
- data = json.loads(pkg.read_text(encoding="utf-8"))
73
- for key in ("dependencies", "devDependencies", "peerDependencies"):
74
- deps.update((data.get(key) or {}).keys())
75
- except (json.JSONDecodeError, OSError):
76
- pass
77
- pyproject = root / "pyproject.toml"
78
- if pyproject.exists():
79
- # Lightweight extraction; avoids a hard tomli dependency on 3.10.
80
- text = pyproject.read_text(encoding="utf-8")
81
- for m in re.finditer(r"""["']([A-Za-z0-9_.\-]+)\s*(?:[><=!~]|["'])""", text):
82
- name = m.group(1)
83
- if name and not name.startswith("."):
84
- deps.add(name)
85
- return sorted(deps)
86
-
87
-
88
- def _resolve_internal(importer_rel: str, target: str, components: dict) -> Optional[str]:
89
- """Resolve a relative JS/TS import (``./x``) to a component key, or None."""
90
- if not target.startswith("."):
91
- return None
92
- raw = (Path(importer_rel).parent / target).as_posix()
93
- base_posix = os.path.normpath(raw).replace(os.sep, "/") # collapse ../ segments
94
- for ext in _CANDIDATE_EXTS:
95
- cand = f"{base_posix}{ext}"
96
- if cand in components:
97
- return cand
98
- idx = f"{base_posix}/index{ext}" if ext else None
99
- if idx and idx in components:
100
- return idx
101
- return None
102
-
103
-
104
- def build_model(root_dir: str | Path) -> ArchitectureModel:
105
- root = Path(root_dir).resolve()
106
- parser = CodeParserFacade()
107
- model = ArchitectureModel(
108
- repo_name=root.name,
109
- commit_sha=_git_sha(root),
110
- manifest_dependencies=_read_manifest_deps(root),
111
- )
112
-
113
- for path in sorted(root.rglob("*")):
114
- if not path.is_file() or path.suffix not in SOURCE_EXTS:
115
- continue
116
- if any(part in IGNORE_DIRS for part in path.relative_to(root).parts):
117
- continue
118
- comp = parser.parse_file(path, root)
119
- comp.layer = _infer_layer(comp.file_path)
120
- model.components[comp.file_path] = comp
121
-
122
- # Second pass: resolve internal edges now that all components are known.
123
- for rel, comp in model.components.items():
124
- for dep in comp.dependencies:
125
- if dep.external:
126
- continue
127
- resolved = _resolve_internal(rel, dep.target, model.components)
128
- if resolved:
129
- dep.target = resolved
130
- else:
131
- # relative import we couldn't resolve to a file: keep as-is, internal
132
- pass
133
- return model
File without changes
File without changes
File without changes
File without changes
File without changes