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.
- {archsteer-0.4.0 → archsteer-0.4.2}/PKG-INFO +38 -11
- {archsteer-0.4.0 → archsteer-0.4.2}/README.md +36 -10
- {archsteer-0.4.0 → archsteer-0.4.2}/archsteer/__init__.py +1 -1
- archsteer-0.4.2/archsteer/engine/mapper.py +217 -0
- {archsteer-0.4.0 → archsteer-0.4.2}/archsteer/steer.py +17 -4
- {archsteer-0.4.0 → archsteer-0.4.2}/archsteer.egg-info/PKG-INFO +38 -11
- {archsteer-0.4.0 → archsteer-0.4.2}/archsteer.egg-info/requires.txt +1 -0
- {archsteer-0.4.0 → archsteer-0.4.2}/pyproject.toml +9 -3
- {archsteer-0.4.0 → archsteer-0.4.2}/tests/test_engine.py +61 -0
- archsteer-0.4.0/archsteer/engine/mapper.py +0 -133
- {archsteer-0.4.0 → archsteer-0.4.2}/LICENSE +0 -0
- {archsteer-0.4.0 → archsteer-0.4.2}/archsteer/cli.py +0 -0
- {archsteer-0.4.0 → archsteer-0.4.2}/archsteer/docs.py +0 -0
- {archsteer-0.4.0 → archsteer-0.4.2}/archsteer/engine/__init__.py +0 -0
- {archsteer-0.4.0 → archsteer-0.4.2}/archsteer/engine/baseline.py +0 -0
- {archsteer-0.4.0 → archsteer-0.4.2}/archsteer/engine/conformance.py +0 -0
- {archsteer-0.4.0 → archsteer-0.4.2}/archsteer/engine/decisions.py +0 -0
- {archsteer-0.4.0 → archsteer-0.4.2}/archsteer/engine/evolution.py +0 -0
- {archsteer-0.4.0 → archsteer-0.4.2}/archsteer/engine/intent.py +0 -0
- {archsteer-0.4.0 → archsteer-0.4.2}/archsteer/engine/model.py +0 -0
- {archsteer-0.4.0 → archsteer-0.4.2}/archsteer/engine/parser.py +0 -0
- {archsteer-0.4.0 → archsteer-0.4.2}/archsteer/mcp_server.py +0 -0
- {archsteer-0.4.0 → archsteer-0.4.2}/archsteer/packs/express_to_next/adr/0001-repository-pattern.md +0 -0
- {archsteer-0.4.0 → archsteer-0.4.2}/archsteer/packs/express_to_next/adr/0002-nextjs-route-handlers.md +0 -0
- {archsteer-0.4.0 → archsteer-0.4.2}/archsteer/packs/express_to_next/architecture.yaml +0 -0
- {archsteer-0.4.0 → archsteer-0.4.2}/archsteer/report.py +0 -0
- {archsteer-0.4.0 → archsteer-0.4.2}/archsteer/workspace.py +0 -0
- {archsteer-0.4.0 → archsteer-0.4.2}/archsteer.egg-info/SOURCES.txt +0 -0
- {archsteer-0.4.0 → archsteer-0.4.2}/archsteer.egg-info/dependency_links.txt +0 -0
- {archsteer-0.4.0 → archsteer-0.4.2}/archsteer.egg-info/entry_points.txt +0 -0
- {archsteer-0.4.0 → archsteer-0.4.2}/archsteer.egg-info/top_level.txt +0 -0
- {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.
|
|
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
|
+
[](https://www.archsteer.com)
|
|
40
|
+
[](https://pypi.org/project/archsteer/)
|
|
41
|
+
[](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
|
|
87
|
-
|
|
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
|
-
|
|
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
|
+
[](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
|
-
-
|
|
149
|
-
|
|
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
|
+
[](https://www.archsteer.com)
|
|
6
|
+
[](https://pypi.org/project/archsteer/)
|
|
7
|
+
[](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
|
|
54
|
-
|
|
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
|
-
|
|
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
|
+
[](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
|
-
-
|
|
116
|
-
|
|
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
|
|
@@ -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
|
|
87
|
-
#
|
|
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
|
|
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.
|
|
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
|
+
[](https://www.archsteer.com)
|
|
40
|
+
[](https://pypi.org/project/archsteer/)
|
|
41
|
+
[](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
|
|
87
|
-
|
|
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
|
-
|
|
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
|
+
[](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
|
-
-
|
|
149
|
-
|
|
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
|
|
@@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta"
|
|
|
4
4
|
|
|
5
5
|
[project]
|
|
6
6
|
name = "archsteer"
|
|
7
|
-
version = "0.4.
|
|
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
|
-
#
|
|
35
|
-
# install
|
|
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
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
{archsteer-0.4.0 → archsteer-0.4.2}/archsteer/packs/express_to_next/adr/0001-repository-pattern.md
RENAMED
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|