universal-ast-mapper 1.11.0 → 1.18.0
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.
- package/CHANGELOG.md +60 -38
- package/README.md +128 -17
- package/dist/cli.js +182 -0
- package/dist/contextpack.js +79 -0
- package/dist/coupling.js +35 -0
- package/dist/gitdiff.js +178 -0
- package/dist/graph.js +3 -1
- package/dist/index.js +173 -0
- package/dist/layers.js +36 -0
- package/dist/modulecoupling.js +0 -0
- package/dist/prompts.js +67 -0
- package/dist/registry.js +10 -0
- package/dist/resolver.js +1 -1
- package/dist/sfc.js +27 -0
- package/dist/skeleton.js +10 -3
- package/package.json +1 -1
package/CHANGELOG.md
CHANGED
|
@@ -6,6 +6,65 @@ since 1.0.0, guarantees a stable MCP tool / CLI surface across the 1.x line.
|
|
|
6
6
|
|
|
7
7
|
---
|
|
8
8
|
|
|
9
|
+
## [1.18.0] — 2026-06-09 · Vue & Svelte SFC support
|
|
10
|
+
- `.vue` and `.svelte` **single-file components** are now first-class inputs. The
|
|
11
|
+
`<script>` / `<script setup>` block is lifted out and parsed with the TS/JS extractor
|
|
12
|
+
(grammar chosen from `lang="ts"`), so component symbols and imports are extracted and
|
|
13
|
+
wired into the dependency graph — including edges from a component into a plain `.ts`
|
|
14
|
+
module, and into other components.
|
|
15
|
+
- Offsets are preserved: everything outside the script is blank-padded, so every symbol
|
|
16
|
+
range still points at the exact line/column in the original SFC.
|
|
17
|
+
- New languages `vue` and `svelte` (extensions `.vue`, `.svelte`); resolver now resolves
|
|
18
|
+
imports of `.vue` / `.svelte` files. **14 languages.**
|
|
19
|
+
- Tests: 8 new assertions (127 total) + Vue/Svelte fixtures — symbol extraction, import
|
|
20
|
+
capture, and cross-file graph edges for both.
|
|
21
|
+
|
|
22
|
+
## [1.17.0] — 2026-06-09 · MCP prompts
|
|
23
|
+
- The server now registers **MCP prompts** — named, parameterized workflows a client
|
|
24
|
+
can invoke from its prompt/slash menu, each returning a ready-to-run instruction that
|
|
25
|
+
chains the right tools: `architecture_audit` (dir?), `safe_refactor` (file, symbol),
|
|
26
|
+
`dead_code_cleanup` (dir?), `health_check` (dir?), `onboard_codebase` (dir?).
|
|
27
|
+
- The Cookbook recipes become first-class, discoverable, and one call away — no pasting.
|
|
28
|
+
- New `test/prompts-smoke.mjs` (12 checks): `prompts/list` returns all 5, argument
|
|
29
|
+
interpolation works, and rendered prompts reference real tools. Wired into CI.
|
|
30
|
+
|
|
31
|
+
## [1.16.0] — 2026-06-09 · Module coupling
|
|
32
|
+
- **`get_module_coupling`** + **`ast-map modules`** (alias `mods`): aggregates the
|
|
33
|
+
file-level import graph up to the **directory/module level** — per-module afferent
|
|
34
|
+
(Ca) / efferent (Ce) coupling and instability, plus the weighted inter-module edges.
|
|
35
|
+
Intra-module imports (files importing siblings in the same directory) are ignored;
|
|
36
|
+
only cross-module dependencies count. The architectural view above per-file coupling.
|
|
37
|
+
- Tests: 5 new assertions (119 total) — a three-module ui→api→core gradient with the
|
|
38
|
+
expected stability ordering and edge count.
|
|
39
|
+
|
|
40
|
+
## [1.15.0] — 2026-06-09 · Layer-violation detection
|
|
41
|
+
- **`get_layer_violations`** + **`ast-map layers`** (alias `sdp`): detect violations of
|
|
42
|
+
Robert C. Martin's **Stable Dependencies Principle** — a stable file (low instability)
|
|
43
|
+
that imports a more volatile one (high instability). Such dependencies point "uphill"
|
|
44
|
+
on the stability gradient and drag stable code along every time the volatile file churns.
|
|
45
|
+
Sorted by severity (the instability gap crossed). `minGap` filters small gaps.
|
|
46
|
+
- Builds directly on the v1.14.0 coupling metrics.
|
|
47
|
+
- Tests: 5 new assertions (114 total) — clean fixture yields none, a synthetic
|
|
48
|
+
stable→volatile graph yields exactly one with the correct severity.
|
|
49
|
+
|
|
50
|
+
## [1.14.0] — 2026-06-09 · Coupling metrics
|
|
51
|
+
- **`get_coupling`** + **`ast-map coupling [dir]`**: Robert C. Martin's per-file
|
|
52
|
+
coupling metrics — afferent coupling (Ca, fan-in), efferent coupling (Ce,
|
|
53
|
+
fan-out), and instability I = Ce/(Ca+Ce). High-Ca files are load-bearing (break
|
|
54
|
+
carefully); high-instability files change freely. Derived from the import graph.
|
|
55
|
+
- Tests: 4 new assertions (109 total) verifying stable/unstable/middle files and
|
|
56
|
+
the [0,1] instability bound.
|
|
57
|
+
|
|
58
|
+
## [1.13.0] — 2026-06-08 · Context-pack
|
|
59
|
+
- **`pack_context`** + **`ast-map pack <file> [symbol]`**: the minimal context to
|
|
60
|
+
work on a symbol — its source, the signatures it depends on, and its dependents
|
|
61
|
+
— with a token estimate, instead of reading whole files.
|
|
62
|
+
|
|
63
|
+
## [1.12.0] — 2026-06-08 · Git-aware analysis
|
|
64
|
+
- **`ast-map diff [base]`** + **`get_diff`**: changed symbols since a git ref,
|
|
65
|
+
breaking changes (removed / signature-changed exports), and blast radius.
|
|
66
|
+
- **`ast-map risk`** + **`get_risk_map`**: rank files by churn × complexity.
|
|
67
|
+
|
|
9
68
|
## [1.11.0] — 2026-06-01 · Code-health dashboard
|
|
10
69
|
- **`ast-map report`** writes a premium self-contained HTML dashboard: health
|
|
11
70
|
grade (A–F), stats, language breakdown, complexity hotspots, god nodes, dead
|
|
@@ -93,41 +152,4 @@ since 1.0.0, guarantees a stable MCP tool / CLI surface across the 1.x line.
|
|
|
93
152
|
declared in 2+ files.
|
|
94
153
|
|
|
95
154
|
## [0.8.3] — 2026-05-31 · TSX/React component props
|
|
96
|
-
- Component
|
|
97
|
-
JSX-returning PascalCase functions. MCP server version now read from package.json.
|
|
98
|
-
|
|
99
|
-
## [0.8.2] — 2026-05-30 · Swift cross-file wiring
|
|
100
|
-
- `import <Module>` → that module's files (`Sources/<Module>/`). Completes
|
|
101
|
-
cross-file graph/resolver support for all four v0.8.0 languages.
|
|
102
|
-
|
|
103
|
-
## [0.8.1] — 2026-05-30 · Kotlin + C/C++ cross-file wiring
|
|
104
|
-
- Kotlin FQCN/package index; C/C++ `#include` resolution with header↔impl pairing.
|
|
105
|
-
- Fixes: parse-cache rel-path leak; Kotlin call-graph extraction.
|
|
106
|
-
|
|
107
|
-
---
|
|
108
|
-
|
|
109
|
-
## Earlier (pre-session history)
|
|
110
|
-
|
|
111
|
-
- **0.8.0** — +4 languages: C · C++ · Kotlin · Swift (symbol extraction + imports).
|
|
112
|
-
- **0.7.0** — Go full module resolution; C# reverse `calledBy`; 4-suite test harness.
|
|
113
|
-
- **0.6.0** — +3 languages: Rust · Java · C#; cross-language resolver.
|
|
114
|
-
- **0.5.x** — `/ast-map` skill auto-install; iterative DFS; barrel re-exports; parse cache; call-graph aliases; `.ast-map.config.json`.
|
|
115
|
-
- **0.4.0** — `search_symbol`, `get_file_deps`, `get_top_symbols`, dead-code tiers.
|
|
116
|
-
- **0.3.0** — CLI; `find_dead_code`, `find_circular_deps`, `get_change_impact`, `get_call_graph`.
|
|
117
|
-
- **0.2.0** — import extraction; `resolve_imports`; `build_symbol_graph`.
|
|
118
|
-
- **0.1.0** — `get_skeleton_json`, `generate_skeleton`, `get_symbol_context`, `validate_architecture`.
|
|
119
|
-
|
|
120
|
-
[1.11.0]: https://github.com/6ixthxense/AST-MCP/releases/tag/v1.11.0
|
|
121
|
-
[1.10.0]: https://github.com/6ixthxense/AST-MCP/releases/tag/v1.10.0
|
|
122
|
-
[1.9.0]: https://github.com/6ixthxense/AST-MCP/releases/tag/v1.9.0
|
|
123
|
-
[1.8.0]: https://github.com/6ixthxense/AST-MCP/releases/tag/v1.8.0
|
|
124
|
-
[1.7.0]: https://github.com/6ixthxense/AST-MCP/releases/tag/v1.7.0
|
|
125
|
-
[1.6.0]: https://github.com/6ixthxense/AST-MCP/releases/tag/v1.6.0
|
|
126
|
-
[1.5.0]: https://github.com/6ixthxense/AST-MCP/releases/tag/v1.5.0
|
|
127
|
-
[1.4.0]: https://github.com/6ixthxense/AST-MCP/releases/tag/v1.4.0
|
|
128
|
-
[1.3.0]: https://github.com/6ixthxense/AST-MCP/releases/tag/v1.3.0
|
|
129
|
-
[1.2.0]: https://github.com/6ixthxense/AST-MCP/releases/tag/v1.2.0
|
|
130
|
-
[1.1.0]: https://github.com/6ixthxense/AST-MCP/releases/tag/v1.1.0
|
|
131
|
-
[1.0.0]: https://github.com/6ixthxense/AST-MCP/releases/tag/v1.0.0
|
|
132
|
-
[0.9.0]: https://github.com/6ixthxense/AST-MCP/releases/tag/v0.9.0
|
|
133
|
-
[0.8.1]: https://github.com/6ixthxense/AST-MCP/releases/tag/v0.8.1
|
|
155
|
+
- Component s
|
package/README.md
CHANGED
|
@@ -4,7 +4,9 @@ An **MCP server + CLI tool** that turns source code into structured, machine-rea
|
|
|
4
4
|
|
|
5
5
|
Built on [tree-sitter](https://tree-sitter.github.io/) WASM grammars. Zero regex guessing — real AST parsing.
|
|
6
6
|
|
|
7
|
-
**
|
|
7
|
+
**27 MCP tools / 28 CLI commands / 5 MCP prompts** spanning skeletons, dependency graphs, and deep analysis — dead code, cycles, change-impact, complexity, duplicates, unused params, type-flow, decorators — plus monorepo support, an interactive **graph explorer** (`ast-map explore`), **watch mode**, and a one-page **health dashboard** (`ast-map report`).
|
|
8
|
+
|
|
9
|
+
**Supported languages:** TypeScript · TSX · JavaScript (ESM/CJS) · Python · Go · Rust · Java · C# · C · C++ · Kotlin · Swift · **Vue** · **Svelte** (SFC `<script>`)
|
|
8
10
|
|
|
9
11
|
| Capability | TS/JS | Python | Go | Rust | Java | C# | C | C++ | Kt | Swift |
|
|
10
12
|
|--------------------------|:-----:|:------:|:---:|:----:|:----:|:---:|:---:|:---:|:---:|:-----:|
|
|
@@ -102,6 +104,12 @@ ast-map explore [dir] [-o out.html]
|
|
|
102
104
|
ast-map watch [dir] [-o out.html]
|
|
103
105
|
ast-map sourcemap <file>
|
|
104
106
|
ast-map report [dir] [-o report.html]
|
|
107
|
+
ast-map diff [base] [--dir <d>] # git-aware changed symbols + impact
|
|
108
|
+
ast-map risk [dir] [-n N] # churn × complexity
|
|
109
|
+
ast-map pack <file> [symbol] [--scan <d>] # minimal context pack
|
|
110
|
+
ast-map coupling [dir] [-n N] # Ca / Ce / instability per file
|
|
111
|
+
ast-map layers [dir] [-g gap] # SDP: stable→volatile violations
|
|
112
|
+
ast-map modules [dir] # directory-level coupling + edges
|
|
105
113
|
ast-map search <pattern> [dir] [-m contains|exact|regex] [-k kind] [-e]
|
|
106
114
|
ast-map deps <file> [--scan <dir>]
|
|
107
115
|
ast-map top <dir> [-n 10]
|
|
@@ -348,6 +356,103 @@ Scan a file or directory for **named functions/methods with parameters that are
|
|
|
348
356
|
|
|
349
357
|
---
|
|
350
358
|
|
|
359
|
+
### `read_source_map`
|
|
360
|
+
Given a compiled JS/CSS file with an inline (`data:`) or external `sourceMappingURL`, return the **original source files** it maps back to (honors `sourceRoot`; reports embedded `sourcesContent`).
|
|
361
|
+
|
|
362
|
+
```json
|
|
363
|
+
{ "file": "dist/bundle.js", "mapKind": "inline", "sources": ["../src/app.ts", "../src/util.ts"], "hasContent": true }
|
|
364
|
+
```
|
|
365
|
+
|
|
366
|
+
**Params:** `path`
|
|
367
|
+
|
|
368
|
+
---
|
|
369
|
+
|
|
370
|
+
### `get_codebase_report`
|
|
371
|
+
A one-shot **codebase health summary**: file/symbol counts, language breakdown, a health **grade (A–F)** + score, complexity hotspots, god nodes, dead exports, and circular dependencies. Rendered as a premium HTML dashboard by `ast-map report`.
|
|
372
|
+
|
|
373
|
+
```json
|
|
374
|
+
{ "grade": "B", "score": 82, "fileCount": 120, "symbolCount": 1400,
|
|
375
|
+
"complexity": { "average": 4.1, "max": 22, "hotspots": [ … ] },
|
|
376
|
+
"godNodes": [ … ], "dead": { "count": 3, "items": [ … ] }, "cycles": { "count": 0, "items": [] } }
|
|
377
|
+
```
|
|
378
|
+
|
|
379
|
+
**Params:** `path` (optional, defaults to root)
|
|
380
|
+
|
|
381
|
+
---
|
|
382
|
+
|
|
383
|
+
### `get_diff`
|
|
384
|
+
**Git-aware.** Compare the working tree to a git ref (default `HEAD`) and return which symbols were added/removed/modified per file, which changes are potentially **breaking** (removed or signature-changed exports), and the **blast radius** — files that depend on those breaking changes. Untracked new files count as additions.
|
|
385
|
+
|
|
386
|
+
```json
|
|
387
|
+
{ "summary": { "filesChanged": 2, "added": 1, "removed": 1, "modified": 1, "breaking": 2, "impactedFiles": 1 },
|
|
388
|
+
"breaking": [ { "file": "src/a.ts", "symbol": "foo", "reason": "signature changed" } ],
|
|
389
|
+
"impactedFiles": ["src/b.ts"] }
|
|
390
|
+
```
|
|
391
|
+
|
|
392
|
+
**Params:** `base` (optional), `path` (optional)
|
|
393
|
+
|
|
394
|
+
---
|
|
395
|
+
|
|
396
|
+
### `pack_context`
|
|
397
|
+
**Token-efficient.** Assemble the *minimal* context to understand or change a symbol — its own source, the **signatures** of what it depends on (resolved imports), and the files that depend on it — instead of reading whole files. Returns a token estimate so you can see the savings.
|
|
398
|
+
|
|
399
|
+
```json
|
|
400
|
+
{ "primary": { "symbol": "login", "startLine": 8, "endLine": 12, "source": "…" },
|
|
401
|
+
"dependencies": [ { "file": "utils.ts", "symbols": [ { "name": "hashPassword", "signature": "…" } ] } ],
|
|
402
|
+
"dependents": [ { "file": "router.ts" } ], "tokenEstimate": 56 }
|
|
403
|
+
```
|
|
404
|
+
|
|
405
|
+
**Params:** `path`, `symbol` (optional), `scan` (optional)
|
|
406
|
+
|
|
407
|
+
---
|
|
408
|
+
|
|
409
|
+
### `get_risk_map`
|
|
410
|
+
Rank files by **refactor risk = git churn × max complexity** — the files that are both frequently changed and complex (the best refactor / test targets).
|
|
411
|
+
|
|
412
|
+
```json
|
|
413
|
+
{ "files": [ { "file": "src/callgraph.ts", "churn": 7, "maxComplexity": 69, "risk": 483 } ] }
|
|
414
|
+
```
|
|
415
|
+
|
|
416
|
+
**Params:** `path` (optional)
|
|
417
|
+
|
|
418
|
+
---
|
|
419
|
+
|
|
420
|
+
### `get_coupling`
|
|
421
|
+
Per-file **coupling metrics** (Robert C. Martin): afferent coupling **Ca** (fan-in), efferent coupling **Ce** (fan-out), and **instability** I = Ce/(Ca+Ce) (0 = stable / load-bearing, 1 = unstable / volatile).
|
|
422
|
+
|
|
423
|
+
```json
|
|
424
|
+
{ "files": [ { "file": "src/types.ts", "afferent": 27, "efferent": 0, "instability": 0 } ] }
|
|
425
|
+
```
|
|
426
|
+
|
|
427
|
+
**Params:** `path` (optional)
|
|
428
|
+
|
|
429
|
+
---
|
|
430
|
+
|
|
431
|
+
### `get_layer_violations`
|
|
432
|
+
Find dependencies that point **the wrong way on the stability gradient** — a stable file (low instability) importing a more volatile one (high instability), violating Martin's **Stable Dependencies Principle**. Sorted by severity (the instability gap crossed).
|
|
433
|
+
|
|
434
|
+
```json
|
|
435
|
+
{ "violations": [ { "from": "src/skeleton.ts", "to": "src/registry.ts", "fromInstability": 0.36, "toInstability": 0.6, "severity": 0.24 } ] }
|
|
436
|
+
```
|
|
437
|
+
|
|
438
|
+
**Params:** `path` (optional), `minGap` (optional, 0–1)
|
|
439
|
+
|
|
440
|
+
---
|
|
441
|
+
|
|
442
|
+
### `get_module_coupling`
|
|
443
|
+
Aggregate the import graph up to the **directory/module level** — per-module Ca / Ce / instability plus the weighted inter-module edges. Intra-module imports are ignored, so this is the architectural bird's-eye view above per-file coupling.
|
|
444
|
+
|
|
445
|
+
```json
|
|
446
|
+
{
|
|
447
|
+
"modules": [ { "module": "src/extractors", "files": 11, "afferent": 1, "efferent": 1, "instability": 0.5 } ],
|
|
448
|
+
"edges": [ { "from": "src/extractors", "to": "src", "weight": 75 } ]
|
|
449
|
+
}
|
|
450
|
+
```
|
|
451
|
+
|
|
452
|
+
**Params:** `path` (optional)
|
|
453
|
+
|
|
454
|
+
---
|
|
455
|
+
|
|
351
456
|
### `get_change_impact`
|
|
352
457
|
Given a file + symbol, reverse-traverse the import graph to compute **blast radius**.
|
|
353
458
|
|
|
@@ -578,6 +683,20 @@ Beyond tools, the server exposes the codebase as **browseable MCP resources**, s
|
|
|
578
683
|
|
|
579
684
|
---
|
|
580
685
|
|
|
686
|
+
## MCP prompts — one-call recipes
|
|
687
|
+
|
|
688
|
+
The server also registers **prompts**: named, parameterized workflows an MCP client can invoke directly (they show up in the client's prompt/slash menu). Each returns a ready-to-run instruction that chains the right tools, so you don't paste the recipe by hand.
|
|
689
|
+
|
|
690
|
+
| Prompt | Args | What it does |
|
|
691
|
+
|--------|------|--------------|
|
|
692
|
+
| `architecture_audit` | `dir?` | God Nodes → cycles → rule violations → module coupling → SDP breaks, then a prioritized summary |
|
|
693
|
+
| `safe_refactor` | `file`, `symbol` | blast radius → call graph → minimal context before changing a symbol |
|
|
694
|
+
| `dead_code_cleanup` | `dir?` | unused exports, each verified zero-impact before deletion |
|
|
695
|
+
| `health_check` | `dir?` | grade A–F → risk map → layer violations, with the 3 files to fix first |
|
|
696
|
+
| `onboard_codebase` | `dir?` | languages → structure → core symbols → module map, as a "start here" guide |
|
|
697
|
+
|
|
698
|
+
---
|
|
699
|
+
|
|
581
700
|
## GitHub Action — architecture gate in CI
|
|
582
701
|
|
|
583
702
|
Use AST-MCP as a CI check with the bundled composite action (`action.yml`):
|
|
@@ -621,6 +740,13 @@ Not part of the public API: the internal `src/` module layout and the generated
|
|
|
621
740
|
|
|
622
741
|
| Version | What changed |
|
|
623
742
|
|---------|--------------|
|
|
743
|
+
| **1.18.0** | **Vue & Svelte SFC support** — `.vue` and `.svelte` single-file components are now parsed: the `<script>` / `<script setup>` block is lifted out (TS or JS) and its symbols + imports extracted, with cross-file graph edges into plain modules. Blank-padding keeps every symbol's line numbers pointing at the original SFC. **14 languages**. |
|
|
744
|
+
| **1.17.0** | **MCP prompts** — the server now registers 5 parameterized **prompts** (`architecture_audit`, `safe_refactor`, `dead_code_cleanup`, `health_check`, `onboard_codebase`): named workflows a client can invoke from its prompt menu, each chaining the right tools. The Cookbook recipes, one call away. |
|
|
745
|
+
| **1.16.0** | **Module coupling** — new `get_module_coupling` MCP tool + `ast-map modules` (alias `mods`) CLI: aggregates the import graph to the **directory/module level** — per-module Ca / Ce / instability plus weighted inter-module edges (intra-module imports ignored). The bird's-eye view above per-file coupling. **27 MCP tools**. |
|
|
746
|
+
| **1.15.0** | **Layer-violation detection** — new `get_layer_violations` MCP tool + `ast-map layers` (alias `sdp`) CLI: flags dependencies that break Martin's **Stable Dependencies Principle** — a stable file (low instability) importing a more volatile one — sorted by the instability gap. Builds directly on the coupling metrics. **26 MCP tools**. |
|
|
747
|
+
| **1.14.0** | **Coupling metrics** — new `get_coupling` MCP tool + `ast-map coupling [dir]` CLI: per-file afferent (Ca) / efferent (Ce) coupling and **instability** I = Ce/(Ca+Ce), the way to spot load-bearing files (high Ca) vs. volatile ones (high I). **25 MCP tools**. |
|
|
748
|
+
| **1.13.0** | **Context-pack** — new `pack_context` MCP tool + `ast-map pack <file> [symbol]` CLI: the minimal context to work on a symbol (its source + the signatures it depends on + its dependents) with a token estimate, instead of reading whole files. **24 MCP tools**. |
|
|
749
|
+
| **1.12.0** | **Git-aware analysis** — `ast-map diff [base]` + `get_diff` tool: changed symbols since a ref, **breaking changes** (removed / signature-changed exports), and blast radius. `ast-map risk` + `get_risk_map` tool: rank files by churn × complexity. Brings a time/history dimension. **23 MCP tools**. |
|
|
624
750
|
| **1.11.0** | **Code-health dashboard** — new `ast-map report` CLI writes a premium self-contained HTML overview (grade A–F, stats, language breakdown, complexity hotspots, god nodes, dead code, cycles) + `get_codebase_report` MCP tool for the same as JSON. |
|
|
625
751
|
| **1.10.0** | **Source-map support** — new `read_source_map` MCP tool + `ast-map sourcemap <file>` CLI: given a compiled JS/CSS file with an inline (`data:`) or external `sourceMappingURL`, returns the original source files it maps back to (honors `sourceRoot`). Traces `dist/` output back to source. |
|
|
626
752
|
| **1.9.0** | **Watch mode** — `ast-map watch [dir]` recomputes the dependency analysis (file count · dead exports · cycles) on every source-file change, debounced; `-o file.html` also regenerates the live explorer each time. Plus: the explorer debug readout is now hidden (toggle with `d`). |
|
|
@@ -641,19 +767,4 @@ Not part of the public API: the internal `src/` module layout and the generated
|
|
|
641
767
|
| **0.9.0** | **Scoped type-flow tracing** — new `trace_type` MCP tool + `ast-map trace-type` (alias `flow`) CLI: follow a named type through function params, return types, typed variables, and class fields across a directory. Completes the deeper-analysis suite (dead code · cycles · impact · complexity · duplicates · unused params · type flow). **18 MCP tools**. |
|
|
642
768
|
| **0.8.7** | **Python decorators in the call graph** — function/method symbols now carry a `decorators` field (`@router.get("/x")` → `router.get("/x")`), surfaced in skeletons (outline + full) and in `get_call_graph`. Traces framework wiring like FastAPI/Flask routes and `@staticmethod`/`@property` stacks to their handler. |
|
|
643
769
|
| **0.8.6** | **Unused parameter detection** — new `find_unused_params` MCP tool + `ast-map unused-params` (alias `unused`) CLI: named functions whose params are never referenced. Skips `_`-prefixed/destructured/anonymous and treats object-shorthand as a use (low false-positive). Server now 17 tools. |
|
|
644
|
-
| **0.8.5** | **Cyclomatic complexity** — new `get_complexity` MCP tool + `ast-map
|
|
645
|
-
| **0.8.4** | **Duplicate symbol detection** — new `find_duplicate_symbols` MCP tool + `ast-map duplicates` (alias `dupes`) CLI command: finds symbol names exported from more than one file, with every file/kind that declares each name. |
|
|
646
|
-
| **0.8.3** | **TSX/React component props** — component symbols now carry extracted prop fields. PascalCase functions/arrows that return JSX or are typed `React.FC<P>`/`FC<P>` get `propsType` (named props type) + `props[]` (name, type, optional), resolved from same-file `interface`/`type` declarations or inline object types. Plus: MCP server now reports its real version from `package.json` (was hardcoded `0.5.3`). |
|
|
647
|
-
| **0.8.2** | **Swift cross-file wiring** — `import <Module>` resolves to that module's files (module = the `Sources/<Module>/` directory, else parent dir), wired into `build_symbol_graph` + `resolve_imports`. System modules (Foundation, UIKit, …) stay external. Completes cross-file graph/resolver support for all four v0.8.0 languages. |
|
|
648
|
-
| **0.8.1** | **Cross-file graph wiring for Kotlin & C/C++** — Kotlin FQCN/package index + C/C++ `#include` resolution (with header↔impl pairing) wired into `build_symbol_graph`, `resolve_imports`, and `get_call_graph`. Fixes a parse-cache rel-path leak (stale `.file` poisoned the cross-lang index → doubled paths) and Kotlin call-graph extraction (`function_declaration` name + field-less `call_expression`). |
|
|
649
|
-
| **0.8.0** | **4 new languages: C · C++ · Kotlin · Swift** — symbol extraction + imports parsing. C++ tracks access_specifier through class bodies. Kotlin handles `package`/`object`/`data class`. Swift handles `class`/`struct`/`enum` (all under `class_declaration`) and `protocol_declaration`. Ruby grammar in tree-sitter-wasms@0.1.13 is unstable — skipped. |
|
|
650
|
-
| **0.7.0** | Go full resolution (reads `go.mod`, resolves package-as-directory) · C# reverse `calledBy` via call-site scanning · `csharpTypes` index lets `using` directives resolve to specific types · 4-suite test harness (smoke + graph-smoke + resolver-smoke + callgraph-smoke) |
|
|
651
|
-
| **0.6.0** | **3 new languages: Rust · Java · C#** (extractors + import parsing) · cross-language resolver in `crosslang.ts` (Java FQCN index, C# namespace index, Rust `crate::` module walk) · symbol-graph `imports` edges + `resolveFileImports` enrichment + `get_call_graph` callee resolution rewired through it · Java `package` and C# `namespace` captured as directives |
|
|
652
|
-
| **0.5.3** | Auto-install `/ast-map` Claude Code skill on `npm install` · `postinstall` writes `~/.claude/skills/ast-map/SKILL.md` + registers trigger in `CLAUDE.md` (idempotent, CI-safe) |
|
|
653
|
-
| **0.5.2** | Iterative DFS in `findCircularDeps` (eliminates stack overflow on large codebases) · `build_symbol_graph` inline size guard (>2000 nodes → stats + warning) · integration test suite (`test/analysis.mjs`) |
|
|
654
|
-
| **0.5.1** | Re-export tracking (`export { X } from './foo'`, barrel files) · `export const` surfaced as symbols · `const X = class {}` support · Python relative import fix · parser instance cache |
|
|
655
|
-
| **0.5.0** | Call graph destructuring aliases · in-process parse cache · `.ast-map.config.json` · general validation rules (large-file, too-many-imports, god-export) |
|
|
656
|
-
| **0.4.0** | `search_symbol` · `get_file_deps` · `get_top_symbols` · dead code confidence tiers · 3 new CLI commands |
|
|
657
|
-
| **0.3.0** | `ast-map` CLI · `find_dead_code` · `find_circular_deps` · `get_change_impact` · `get_call_graph` |
|
|
658
|
-
| **0.2.0** | Import extraction · `resolve_imports` · `build_symbol_graph` |
|
|
659
|
-
| **0.1.0** | `get_skeleton_json` · `generate_skeleton` · `get_symbol_context` · `validate_architecture` |
|
|
770
|
+
| **0.8.5** | **Cyclomatic complexity** — new `get_complexity` MCP tool + `ast-map complex
|
package/dist/cli.js
CHANGED
|
@@ -17,6 +17,11 @@ import { discoverWorkspace, findPackageCycles } from "./workspace.js";
|
|
|
17
17
|
import { buildExplorerHtml } from "./explorer.js";
|
|
18
18
|
import { readSourceMap } from "./sourcemap.js";
|
|
19
19
|
import { buildReport, buildReportHtml } from "./report.js";
|
|
20
|
+
import { computeDiff, computeRisk, isGitRepo } from "./gitdiff.js";
|
|
21
|
+
import { packContext } from "./contextpack.js";
|
|
22
|
+
import { computeCoupling } from "./coupling.js";
|
|
23
|
+
import { findLayerViolations } from "./layers.js";
|
|
24
|
+
import { computeModuleCoupling } from "./modulecoupling.js";
|
|
20
25
|
import { buildCallGraph } from "./callgraph.js";
|
|
21
26
|
import { searchSymbols } from "./search.js";
|
|
22
27
|
const ROOT = path.resolve(process.env.AST_MAP_ROOT ?? process.cwd());
|
|
@@ -447,6 +452,183 @@ program
|
|
|
447
452
|
console.log(`\n ${info.sources.length} original source(s)` + (info.hasContent ? dim(" · embeds sourcesContent") : ""));
|
|
448
453
|
console.log();
|
|
449
454
|
});
|
|
455
|
+
// ─── Command: pack ────────────────────────────────────────────────────────────
|
|
456
|
+
program
|
|
457
|
+
.command("pack <file> [symbol]")
|
|
458
|
+
.description("Minimal context pack for a symbol (source + dep signatures + dependents)")
|
|
459
|
+
.option("--scan <dir>", "Directory to scan for dependents", ".")
|
|
460
|
+
.option("--json", "Output as JSON")
|
|
461
|
+
.action(async (file, symbol, opts) => {
|
|
462
|
+
const { abs, rel } = resolveArg(file);
|
|
463
|
+
if (fs.statSync(abs).isDirectory())
|
|
464
|
+
die(`"${rel}" is a directory; pass a file`);
|
|
465
|
+
const scanAbs = resolveArg(opts.scan).abs;
|
|
466
|
+
const pack = await packContext(abs, rel, ROOT, symbol, scanAbs);
|
|
467
|
+
if (opts.json)
|
|
468
|
+
return jsonOut(pack);
|
|
469
|
+
header(`Context Pack \u2014 ${rel}${symbol ? "::" + symbol : ""} ${dim("(~" + pack.tokenEstimate + " tokens)")}`);
|
|
470
|
+
console.log(indent(bold("Primary") + dim(` lines ${pack.primary.startLine}-${pack.primary.endLine}`)));
|
|
471
|
+
console.log();
|
|
472
|
+
console.log(indent(bold("Depends on:")));
|
|
473
|
+
if (pack.dependencies.length === 0)
|
|
474
|
+
console.log(indent(dim("(none in-project)"), 4));
|
|
475
|
+
for (const d of pack.dependencies) {
|
|
476
|
+
console.log(indent(green(d.file), 4));
|
|
477
|
+
for (const sym of d.symbols)
|
|
478
|
+
console.log(indent(dim((sym.signature || sym.name)), 6));
|
|
479
|
+
}
|
|
480
|
+
console.log();
|
|
481
|
+
console.log(indent(bold("Depended on by:")));
|
|
482
|
+
if (pack.dependents.length === 0)
|
|
483
|
+
console.log(indent(dim("(none found in scan)"), 4));
|
|
484
|
+
for (const dep of pack.dependents)
|
|
485
|
+
console.log(indent(yellow(dep.file), 4));
|
|
486
|
+
console.log();
|
|
487
|
+
});
|
|
488
|
+
// ─── Command: diff ────────────────────────────────────────────────────────────
|
|
489
|
+
program
|
|
490
|
+
.command("diff [base]")
|
|
491
|
+
.description("Symbols changed since a git ref + breaking changes + blast radius")
|
|
492
|
+
.option("--dir <dir>", "Limit to a subdirectory", ".")
|
|
493
|
+
.option("--json", "Output as JSON")
|
|
494
|
+
.action(async (base, opts) => {
|
|
495
|
+
if (!isGitRepo(ROOT))
|
|
496
|
+
die("not a git repository (or git is unavailable)");
|
|
497
|
+
const { abs, rel } = resolveArg(opts.dir);
|
|
498
|
+
const ref = base ?? "HEAD";
|
|
499
|
+
const d = await computeDiff(abs, ROOT, ref);
|
|
500
|
+
if (opts.json)
|
|
501
|
+
return jsonOut(d);
|
|
502
|
+
header(`Diff since ${bold(ref)} ${dim(`(${d.summary.filesChanged} file(s) · +${d.summary.added} ~${d.summary.modified} -${d.summary.removed})`)}`);
|
|
503
|
+
if (d.files.length === 0) {
|
|
504
|
+
console.log(indent(dim("No source-symbol changes.")));
|
|
505
|
+
console.log();
|
|
506
|
+
return;
|
|
507
|
+
}
|
|
508
|
+
for (const f of d.files) {
|
|
509
|
+
console.log(indent(`${bold(f.file)} ${dim("[" + f.status + "]")}`));
|
|
510
|
+
for (const a of f.added)
|
|
511
|
+
console.log(indent(green("+ ") + a.symbol + dim(a.exported ? " (exported)" : ""), 4));
|
|
512
|
+
for (const m of f.modified)
|
|
513
|
+
console.log(indent(yellow("~ ") + m.symbol + dim(m.exported ? " (exported)" : ""), 4));
|
|
514
|
+
for (const r of f.removed)
|
|
515
|
+
console.log(indent(red("- ") + r.symbol + dim(r.exported ? " (exported)" : ""), 4));
|
|
516
|
+
}
|
|
517
|
+
if (d.breaking.length > 0) {
|
|
518
|
+
console.log(`\n${indent(bold(red("\u26a0 Breaking changes (" + d.breaking.length + ")")))}`);
|
|
519
|
+
for (const b of d.breaking)
|
|
520
|
+
console.log(indent(`${red(b.symbol)} ${dim(b.reason)} ${dim(b.file)}`, 4));
|
|
521
|
+
console.log(`\n${indent(yellow(d.impactedFiles.length + " file(s) impacted") + dim(" by breaking changes"))}`);
|
|
522
|
+
for (const f of d.impactedFiles.slice(0, 20))
|
|
523
|
+
console.log(indent(dim(f), 4));
|
|
524
|
+
}
|
|
525
|
+
console.log();
|
|
526
|
+
});
|
|
527
|
+
// ─── Command: risk ────────────────────────────────────────────────────────────
|
|
528
|
+
program
|
|
529
|
+
.command("risk [dir]")
|
|
530
|
+
.description("Rank files by refactor risk (git churn × complexity)")
|
|
531
|
+
.option("--json", "Output as JSON")
|
|
532
|
+
.option("-n, --top <n>", "Show top N", (v) => parseInt(v, 10), 15)
|
|
533
|
+
.action(async (dir, opts) => {
|
|
534
|
+
if (!isGitRepo(ROOT))
|
|
535
|
+
die("not a git repository (or git is unavailable)");
|
|
536
|
+
const { abs, rel } = resolveArg(dir ?? ".");
|
|
537
|
+
const files = await computeRisk(abs, ROOT);
|
|
538
|
+
if (opts.json)
|
|
539
|
+
return jsonOut({ count: files.length, files });
|
|
540
|
+
header(`Refactor Risk \u2014 ${rel}/ ${dim("(churn × max complexity)")}`);
|
|
541
|
+
if (files.length === 0) {
|
|
542
|
+
console.log(indent(green("✓ nothing risky (no churn × complexity)")));
|
|
543
|
+
console.log();
|
|
544
|
+
return;
|
|
545
|
+
}
|
|
546
|
+
table(files.slice(0, opts.top).map((f) => [String(f.risk), `${f.churn} × ${f.maxComplexity}`, f.file]), [["Risk", 7], ["churn×cx", 12], ["File", 44]]);
|
|
547
|
+
console.log();
|
|
548
|
+
});
|
|
549
|
+
// ─── Command: coupling ────────────────────────────────────────────────────────
|
|
550
|
+
program
|
|
551
|
+
.command("coupling [dir]")
|
|
552
|
+
.description("Per-file coupling metrics: afferent (Ca), efferent (Ce), instability")
|
|
553
|
+
.option("--json", "Output as JSON")
|
|
554
|
+
.option("-n, --top <n>", "Show top N by total coupling", (v) => parseInt(v, 10), 25)
|
|
555
|
+
.action(async (dir, opts) => {
|
|
556
|
+
const { abs, rel } = resolveArg(dir ?? ".");
|
|
557
|
+
if (!fs.statSync(abs).isDirectory())
|
|
558
|
+
die(`"${rel}" is not a directory`);
|
|
559
|
+
const skeletons = await gatherSkeletons(abs);
|
|
560
|
+
const metrics = computeCoupling(buildSymbolGraph(skeletons, ROOT));
|
|
561
|
+
if (opts.json)
|
|
562
|
+
return jsonOut({ count: metrics.length, files: metrics });
|
|
563
|
+
header(`Coupling \u2014 ${rel}/ ${dim("(Ca = fan-in, Ce = fan-out, I = instability)")}`);
|
|
564
|
+
if (metrics.length === 0) {
|
|
565
|
+
console.log(indent(dim("No import edges found.")));
|
|
566
|
+
console.log();
|
|
567
|
+
return;
|
|
568
|
+
}
|
|
569
|
+
const icolor = (i) => (i >= 0.8 ? red : i <= 0.2 ? green : yellow);
|
|
570
|
+
table(metrics.slice(0, opts.top).map((m) => [String(m.afferent), String(m.efferent), icolor(m.instability)(m.instability.toFixed(2)), m.file]), [["Ca", 4], ["Ce", 4], ["I", 6], ["File", 46]]);
|
|
571
|
+
console.log(indent(dim("high Ca = load-bearing (break carefully) · high I = volatile")));
|
|
572
|
+
console.log();
|
|
573
|
+
});
|
|
574
|
+
// ─── Command: layers ──────────────────────────────────────────────────────────
|
|
575
|
+
program
|
|
576
|
+
.command("layers [dir]")
|
|
577
|
+
.alias("sdp")
|
|
578
|
+
.description("Stable Dependencies Principle: stable files that depend on volatile ones")
|
|
579
|
+
.option("--json", "Output as JSON")
|
|
580
|
+
.option("-g, --min-gap <n>", "Only show violations with instability gap > n", (v) => parseFloat(v), 0)
|
|
581
|
+
.action(async (dir, opts) => {
|
|
582
|
+
const { abs, rel } = resolveArg(dir ?? ".");
|
|
583
|
+
if (!fs.statSync(abs).isDirectory())
|
|
584
|
+
die(`"${rel}" is not a directory`);
|
|
585
|
+
const skeletons = await gatherSkeletons(abs);
|
|
586
|
+
const violations = findLayerViolations(buildSymbolGraph(skeletons, ROOT), opts.minGap);
|
|
587
|
+
if (opts.json)
|
|
588
|
+
return jsonOut({ count: violations.length, violations });
|
|
589
|
+
header(`Layer Violations \u2014 ${rel}/ ${dim("(stable \u2192 volatile dependencies, SDP)")}`);
|
|
590
|
+
if (violations.length === 0) {
|
|
591
|
+
console.log(indent(green("\u2713 No SDP violations \u2014 dependencies flow toward stability.")));
|
|
592
|
+
console.log();
|
|
593
|
+
return;
|
|
594
|
+
}
|
|
595
|
+
for (const v of violations) {
|
|
596
|
+
const sev = v.severity >= 0.4 ? red : v.severity >= 0.2 ? yellow : dim;
|
|
597
|
+
console.log(indent(`${sev(v.severity.toFixed(2))} ${bold(v.from)} ${dim(`(I=${v.fromInstability})`)} ${red("\u2192")} ${v.to} ${dim(`(I=${v.toInstability})`)}`));
|
|
598
|
+
}
|
|
599
|
+
console.log();
|
|
600
|
+
console.log(indent(dim(`${violations.length} stable file(s) depend on more volatile ones \u2014 they churn when those do`)));
|
|
601
|
+
console.log();
|
|
602
|
+
});
|
|
603
|
+
// ─── Command: modules ─────────────────────────────────────────────────────────
|
|
604
|
+
program
|
|
605
|
+
.command("modules [dir]")
|
|
606
|
+
.alias("mods")
|
|
607
|
+
.description("Directory/module-level coupling: per-module Ca / Ce / instability + edges")
|
|
608
|
+
.option("--json", "Output as JSON")
|
|
609
|
+
.action(async (dir, opts) => {
|
|
610
|
+
const { abs, rel } = resolveArg(dir ?? ".");
|
|
611
|
+
if (!fs.statSync(abs).isDirectory())
|
|
612
|
+
die(`"${rel}" is not a directory`);
|
|
613
|
+
const skeletons = await gatherSkeletons(abs);
|
|
614
|
+
const mc = computeModuleCoupling(buildSymbolGraph(skeletons, ROOT));
|
|
615
|
+
if (opts.json)
|
|
616
|
+
return jsonOut(mc);
|
|
617
|
+
header(`Module Coupling \u2014 ${rel}/ ${dim("(directory-level Ca / Ce / instability)")}`);
|
|
618
|
+
if (mc.modules.length === 0) {
|
|
619
|
+
console.log(indent(dim("No cross-module imports found.")));
|
|
620
|
+
console.log();
|
|
621
|
+
return;
|
|
622
|
+
}
|
|
623
|
+
const icolor = (i) => (i >= 0.8 ? red : i <= 0.2 ? green : yellow);
|
|
624
|
+
table(mc.modules.map((m) => [String(m.files), String(m.afferent), String(m.efferent), icolor(m.instability)(m.instability.toFixed(2)), m.module]), [["Files", 6], ["Ca", 4], ["Ce", 4], ["I", 6], ["Module", 40]]);
|
|
625
|
+
if (mc.edges.length) {
|
|
626
|
+
console.log(indent(bold("Inter-module dependencies:")));
|
|
627
|
+
for (const e of mc.edges.slice(0, 20))
|
|
628
|
+
console.log(indent(` ${e.from} ${dim("\u2192")} ${e.to} ${dim(`(${e.weight})`)}`));
|
|
629
|
+
}
|
|
630
|
+
console.log();
|
|
631
|
+
});
|
|
450
632
|
// ─── Command: report ──────────────────────────────────────────────────────────
|
|
451
633
|
program
|
|
452
634
|
.command("report [dir]")
|
|
@@ -0,0 +1,79 @@
|
|
|
1
|
+
import fs from "node:fs";
|
|
2
|
+
import path from "node:path";
|
|
3
|
+
import { buildSkeleton, collectSourceFiles } from "./skeleton.js";
|
|
4
|
+
import { resolveOptions } from "./config.js";
|
|
5
|
+
import { resolveFileImports } from "./resolver.js";
|
|
6
|
+
import { buildCallGraph } from "./callgraph.js";
|
|
7
|
+
function findSym(syms, name) {
|
|
8
|
+
for (const s of syms) {
|
|
9
|
+
if (s.name === name)
|
|
10
|
+
return s;
|
|
11
|
+
const n = findSym(s.children, name);
|
|
12
|
+
if (n)
|
|
13
|
+
return n;
|
|
14
|
+
}
|
|
15
|
+
return null;
|
|
16
|
+
}
|
|
17
|
+
const tok = (s) => Math.round(s.length / 4);
|
|
18
|
+
/**
|
|
19
|
+
* Assemble the minimal context an agent needs to understand or change a symbol:
|
|
20
|
+
* the symbol's own source, the signatures of what it depends on (resolved
|
|
21
|
+
* imports), and the files that depend on it — instead of reading whole files.
|
|
22
|
+
*/
|
|
23
|
+
export async function packContext(absFile, relFile, root, symbolName, scanDir) {
|
|
24
|
+
const opts = resolveOptions({ detail: "full", emitHtml: false });
|
|
25
|
+
const skel = await buildSkeleton(absFile, relFile, opts);
|
|
26
|
+
const lines = fs.readFileSync(absFile, "utf8").split(/\r?\n/);
|
|
27
|
+
let startLine = 1, endLine = lines.length;
|
|
28
|
+
if (symbolName) {
|
|
29
|
+
const sym = findSym(skel.symbols, symbolName);
|
|
30
|
+
if (sym) {
|
|
31
|
+
startLine = sym.range.startLine;
|
|
32
|
+
endLine = sym.range.endLine;
|
|
33
|
+
}
|
|
34
|
+
}
|
|
35
|
+
const source = lines.slice(startLine - 1, endLine).join("\n");
|
|
36
|
+
// Dependencies: resolved in-project imports + the target symbol signatures.
|
|
37
|
+
const refs = await resolveFileImports(skel, absFile, root);
|
|
38
|
+
const byFile = new Map();
|
|
39
|
+
for (const r of refs) {
|
|
40
|
+
if (!r.found || !r.resolvedRel)
|
|
41
|
+
continue;
|
|
42
|
+
const arr = byFile.get(r.resolvedRel) ?? [];
|
|
43
|
+
if (!arr.some((x) => x.name === r.symbol))
|
|
44
|
+
arr.push({ name: r.symbol, signature: r.signature ?? null });
|
|
45
|
+
byFile.set(r.resolvedRel, arr);
|
|
46
|
+
}
|
|
47
|
+
const dependencies = [...byFile.entries()].map(([file, symbols]) => ({ file, symbols }));
|
|
48
|
+
// Dependents: who calls the seed symbol (needs a directory scan).
|
|
49
|
+
let dependents = [];
|
|
50
|
+
if (symbolName && scanDir) {
|
|
51
|
+
const sopts = resolveOptions({ detail: "outline", emitHtml: false });
|
|
52
|
+
const skels = [];
|
|
53
|
+
for (const f of collectSourceFiles(scanDir, sopts)) {
|
|
54
|
+
const rr = path.relative(root, f).split(path.sep).join("/");
|
|
55
|
+
try {
|
|
56
|
+
skels.push(await buildSkeleton(f, rr, sopts));
|
|
57
|
+
}
|
|
58
|
+
catch { /* skip */ }
|
|
59
|
+
}
|
|
60
|
+
const cg = await buildCallGraph(absFile, symbolName, root, skels);
|
|
61
|
+
if (cg) {
|
|
62
|
+
const seen = new Set();
|
|
63
|
+
for (const c of cg.calledBy)
|
|
64
|
+
if (!seen.has(c.file)) {
|
|
65
|
+
seen.add(c.file);
|
|
66
|
+
dependents.push({ file: c.file });
|
|
67
|
+
}
|
|
68
|
+
}
|
|
69
|
+
}
|
|
70
|
+
const depTok = dependencies.reduce((a, d) => a + d.symbols.reduce((b, s) => b + tok(s.signature || s.name), 0), 0);
|
|
71
|
+
return {
|
|
72
|
+
seed: { file: relFile, ...(symbolName ? { symbol: symbolName } : {}) },
|
|
73
|
+
primary: { file: relFile, ...(symbolName ? { symbol: symbolName } : {}), startLine, endLine, source },
|
|
74
|
+
dependencies,
|
|
75
|
+
dependents,
|
|
76
|
+
tokenEstimate: tok(source) + depTok,
|
|
77
|
+
note: "Read primary.source in full; for dependencies you usually only need the listed signatures, not the whole files.",
|
|
78
|
+
};
|
|
79
|
+
}
|
package/dist/coupling.js
ADDED
|
@@ -0,0 +1,35 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Compute Robert C. Martin's coupling metrics per file from the symbol graph's
|
|
3
|
+
* file-level import edges: afferent (fan-in), efferent (fan-out), and instability.
|
|
4
|
+
*/
|
|
5
|
+
export function computeCoupling(graph) {
|
|
6
|
+
const nodeMap = new Map(graph.nodes.map((n) => [n.id, n]));
|
|
7
|
+
const out = new Map(); // file -> set of files it imports
|
|
8
|
+
const inc = new Map(); // file -> set of files that import it
|
|
9
|
+
const files = new Set();
|
|
10
|
+
for (const n of graph.nodes)
|
|
11
|
+
if (n.nodeType === "file")
|
|
12
|
+
files.add(n.id);
|
|
13
|
+
for (const e of graph.edges) {
|
|
14
|
+
if (e.edgeType !== "imports")
|
|
15
|
+
continue;
|
|
16
|
+
const to = nodeMap.get(e.to);
|
|
17
|
+
const toFile = to ? (to.nodeType === "file" ? to.id : to.file) : null;
|
|
18
|
+
const fromFile = e.from;
|
|
19
|
+
if (!toFile || fromFile === toFile)
|
|
20
|
+
continue;
|
|
21
|
+
files.add(fromFile);
|
|
22
|
+
files.add(toFile);
|
|
23
|
+
(out.get(fromFile) ?? out.set(fromFile, new Set()).get(fromFile)).add(toFile);
|
|
24
|
+
(inc.get(toFile) ?? inc.set(toFile, new Set()).get(toFile)).add(fromFile);
|
|
25
|
+
}
|
|
26
|
+
const metrics = [];
|
|
27
|
+
for (const f of files) {
|
|
28
|
+
const ce = out.get(f)?.size ?? 0;
|
|
29
|
+
const ca = inc.get(f)?.size ?? 0;
|
|
30
|
+
const instability = ca + ce === 0 ? 0 : Math.round((ce / (ca + ce)) * 100) / 100;
|
|
31
|
+
metrics.push({ file: f, afferent: ca, efferent: ce, instability });
|
|
32
|
+
}
|
|
33
|
+
// Sort by total coupling desc (most-connected first).
|
|
34
|
+
return metrics.sort((a, b) => (b.afferent + b.efferent) - (a.afferent + a.efferent) || a.file.localeCompare(b.file));
|
|
35
|
+
}
|
package/dist/gitdiff.js
ADDED
|
@@ -0,0 +1,178 @@
|
|
|
1
|
+
import { execFileSync } from "node:child_process";
|
|
2
|
+
import fs from "node:fs";
|
|
3
|
+
import os from "node:os";
|
|
4
|
+
import path from "node:path";
|
|
5
|
+
import { buildSkeleton, collectSourceFiles } from "./skeleton.js";
|
|
6
|
+
import { resolveOptions } from "./config.js";
|
|
7
|
+
import { buildSymbolGraph } from "./graph.js";
|
|
8
|
+
import { getChangeImpact } from "./graph-analysis.js";
|
|
9
|
+
import { detectLanguage } from "./registry.js";
|
|
10
|
+
import { computeFileComplexity } from "./complexity.js";
|
|
11
|
+
function git(args, cwd) {
|
|
12
|
+
return execFileSync("git", args, { cwd, encoding: "utf8", maxBuffer: 64 * 1024 * 1024 });
|
|
13
|
+
}
|
|
14
|
+
export function isGitRepo(root) {
|
|
15
|
+
try {
|
|
16
|
+
git(["rev-parse", "--is-inside-work-tree"], root);
|
|
17
|
+
return true;
|
|
18
|
+
}
|
|
19
|
+
catch {
|
|
20
|
+
return false;
|
|
21
|
+
}
|
|
22
|
+
}
|
|
23
|
+
function changedFiles(root, base) {
|
|
24
|
+
let out;
|
|
25
|
+
try {
|
|
26
|
+
out = git(["diff", "--name-status", base, "--"], root);
|
|
27
|
+
}
|
|
28
|
+
catch {
|
|
29
|
+
return [];
|
|
30
|
+
}
|
|
31
|
+
const res = [];
|
|
32
|
+
for (const line of out.split(/\r?\n/)) {
|
|
33
|
+
if (!line.trim())
|
|
34
|
+
continue;
|
|
35
|
+
const m = line.match(/^([AMD])\t(.+)$/);
|
|
36
|
+
if (m) {
|
|
37
|
+
res.push({ status: m[1], file: m[2] });
|
|
38
|
+
continue;
|
|
39
|
+
}
|
|
40
|
+
const r = line.match(/^R\d+\t\S+\t(.+)$/); // rename → treat new path as modified
|
|
41
|
+
if (r)
|
|
42
|
+
res.push({ status: "M", file: r[1] });
|
|
43
|
+
}
|
|
44
|
+
// Untracked files are new since any ref — treat them as added.
|
|
45
|
+
try {
|
|
46
|
+
const untracked = git(["ls-files", "--others", "--exclude-standard"], root);
|
|
47
|
+
for (const f of untracked.split(/\r?\n/)) {
|
|
48
|
+
if (f.trim() && !res.some((x) => x.file === f))
|
|
49
|
+
res.push({ status: "A", file: f });
|
|
50
|
+
}
|
|
51
|
+
}
|
|
52
|
+
catch { /* ignore */ }
|
|
53
|
+
return res.filter((f) => detectLanguage(f.file));
|
|
54
|
+
}
|
|
55
|
+
function oldContent(root, base, rel) {
|
|
56
|
+
try {
|
|
57
|
+
return git(["show", `${base}:${rel}`], root);
|
|
58
|
+
}
|
|
59
|
+
catch {
|
|
60
|
+
return null;
|
|
61
|
+
}
|
|
62
|
+
}
|
|
63
|
+
async function skeletonFromSource(source, rel) {
|
|
64
|
+
const ext = path.extname(rel);
|
|
65
|
+
const tmp = path.join(os.tmpdir(), `astdiff-${Date.now()}-${Math.random().toString(36).slice(2)}${ext}`);
|
|
66
|
+
try {
|
|
67
|
+
fs.writeFileSync(tmp, source);
|
|
68
|
+
return await buildSkeleton(tmp, rel, resolveOptions({ detail: "full", emitHtml: false }));
|
|
69
|
+
}
|
|
70
|
+
catch {
|
|
71
|
+
return null;
|
|
72
|
+
}
|
|
73
|
+
finally {
|
|
74
|
+
try {
|
|
75
|
+
fs.unlinkSync(tmp);
|
|
76
|
+
}
|
|
77
|
+
catch { /* ignore */ }
|
|
78
|
+
}
|
|
79
|
+
}
|
|
80
|
+
function flatten(syms, prefix, acc) {
|
|
81
|
+
for (const s of syms) {
|
|
82
|
+
const q = prefix ? prefix + "." + s.name : s.name;
|
|
83
|
+
acc.set(q, s);
|
|
84
|
+
flatten(s.children, q, acc);
|
|
85
|
+
}
|
|
86
|
+
return acc;
|
|
87
|
+
}
|
|
88
|
+
const sc = (s) => ({ symbol: s.name, kind: s.kind, exported: s.exported ?? false });
|
|
89
|
+
export async function computeDiff(absDir, root, base) {
|
|
90
|
+
const absDirNorm = path.resolve(absDir);
|
|
91
|
+
const changed = changedFiles(root, base).filter((f) => path.resolve(root, f.file).startsWith(absDirNorm));
|
|
92
|
+
const files = [];
|
|
93
|
+
const breaking = [];
|
|
94
|
+
for (const cf of changed) {
|
|
95
|
+
const rel = cf.file;
|
|
96
|
+
const newSkel = cf.status === "D" ? null : await safeBuildFromDisk(path.resolve(root, rel), rel);
|
|
97
|
+
const oldSrc = cf.status === "A" ? null : oldContent(root, base, rel);
|
|
98
|
+
const oldSkel = oldSrc != null ? await skeletonFromSource(oldSrc, rel) : null;
|
|
99
|
+
const oldMap = oldSkel ? flatten(oldSkel.symbols, "", new Map()) : new Map();
|
|
100
|
+
const newMap = newSkel ? flatten(newSkel.symbols, "", new Map()) : new Map();
|
|
101
|
+
const added = [], removed = [], modified = [];
|
|
102
|
+
for (const [q, s] of newMap)
|
|
103
|
+
if (!oldMap.has(q))
|
|
104
|
+
added.push(sc(s));
|
|
105
|
+
for (const [q, s] of oldMap)
|
|
106
|
+
if (!newMap.has(q))
|
|
107
|
+
removed.push(sc(s));
|
|
108
|
+
for (const [q, s] of newMap) {
|
|
109
|
+
const o = oldMap.get(q);
|
|
110
|
+
if (o && (o.signature ?? "") !== (s.signature ?? ""))
|
|
111
|
+
modified.push(sc(s));
|
|
112
|
+
}
|
|
113
|
+
const status = cf.status === "A" ? "added" : cf.status === "D" ? "deleted" : "modified";
|
|
114
|
+
files.push({ file: rel, status, added, removed, modified });
|
|
115
|
+
for (const r of removed)
|
|
116
|
+
if (r.exported && !r.symbol.includes(" ")) {
|
|
117
|
+
breaking.push({ file: rel, symbol: r.symbol, reason: cf.status === "D" ? "file deleted" : "export removed" });
|
|
118
|
+
}
|
|
119
|
+
for (const m of modified)
|
|
120
|
+
if (m.exported)
|
|
121
|
+
breaking.push({ file: rel, symbol: m.symbol, reason: "signature changed" });
|
|
122
|
+
}
|
|
123
|
+
// Blast radius of breaking changes (top-level symbols only).
|
|
124
|
+
const impacted = new Set();
|
|
125
|
+
if (breaking.length > 0) {
|
|
126
|
+
const opts = resolveOptions({ detail: "outline", emitHtml: false });
|
|
127
|
+
const skels = [];
|
|
128
|
+
for (const f of collectSourceFiles(absDirNorm, opts)) {
|
|
129
|
+
const r = path.relative(root, f).split(path.sep).join("/");
|
|
130
|
+
try {
|
|
131
|
+
skels.push(await buildSkeleton(f, r, opts));
|
|
132
|
+
}
|
|
133
|
+
catch { /* skip */ }
|
|
134
|
+
}
|
|
135
|
+
const graph = buildSymbolGraph(skels, root);
|
|
136
|
+
for (const b of breaking) {
|
|
137
|
+
const imp = getChangeImpact(graph, `${b.file}::${b.symbol}`);
|
|
138
|
+
if (imp)
|
|
139
|
+
for (const d of [...imp.direct, ...imp.transitive])
|
|
140
|
+
if (d.file !== b.file)
|
|
141
|
+
impacted.add(d.file);
|
|
142
|
+
}
|
|
143
|
+
}
|
|
144
|
+
const sum = files.reduce((a, f) => ({ added: a.added + f.added.length, removed: a.removed + f.removed.length, modified: a.modified + f.modified.length }), { added: 0, removed: 0, modified: 0 });
|
|
145
|
+
return {
|
|
146
|
+
base,
|
|
147
|
+
files,
|
|
148
|
+
breaking,
|
|
149
|
+
impactedFiles: [...impacted].sort(),
|
|
150
|
+
summary: { filesChanged: files.length, ...sum, breaking: breaking.length, impactedFiles: impacted.size },
|
|
151
|
+
};
|
|
152
|
+
}
|
|
153
|
+
async function safeBuildFromDisk(abs, rel) {
|
|
154
|
+
try {
|
|
155
|
+
return await buildSkeleton(abs, rel, resolveOptions({ detail: "full", emitHtml: false }));
|
|
156
|
+
}
|
|
157
|
+
catch {
|
|
158
|
+
return null;
|
|
159
|
+
}
|
|
160
|
+
}
|
|
161
|
+
export async function computeRisk(absDir, root) {
|
|
162
|
+
const opts = resolveOptions({ detail: "outline", emitHtml: false });
|
|
163
|
+
const out = [];
|
|
164
|
+
for (const f of collectSourceFiles(absDir, opts)) {
|
|
165
|
+
const rel = path.relative(root, f).split(path.sep).join("/");
|
|
166
|
+
let churn = 0;
|
|
167
|
+
try {
|
|
168
|
+
churn = parseInt(git(["rev-list", "--count", "HEAD", "--", rel], root).trim(), 10) || 0;
|
|
169
|
+
}
|
|
170
|
+
catch {
|
|
171
|
+
churn = 0;
|
|
172
|
+
}
|
|
173
|
+
const fc = await computeFileComplexity(f, rel);
|
|
174
|
+
const maxC = fc ? fc.maxComplexity : 0;
|
|
175
|
+
out.push({ file: rel, churn, maxComplexity: maxC, risk: churn * maxC });
|
|
176
|
+
}
|
|
177
|
+
return out.filter((r) => r.risk > 0).sort((a, b) => b.risk - a.risk);
|
|
178
|
+
}
|
package/dist/graph.js
CHANGED
|
@@ -27,7 +27,9 @@ function isPathBasedLanguage(language) {
|
|
|
27
27
|
return (language === "typescript" ||
|
|
28
28
|
language === "tsx" ||
|
|
29
29
|
language === "javascript" ||
|
|
30
|
-
language === "python"
|
|
30
|
+
language === "python" ||
|
|
31
|
+
language === "vue" ||
|
|
32
|
+
language === "svelte");
|
|
31
33
|
}
|
|
32
34
|
// Wire one TS/JS/Python-style relative import.
|
|
33
35
|
function wirePathImport(skel, imp, fromFileAbs, root, exportedSymbolMap, edges) {
|
package/dist/index.js
CHANGED
|
@@ -21,6 +21,12 @@ import { traceTypeInFile } from "./typeflow.js";
|
|
|
21
21
|
import { discoverWorkspace, findPackageCycles } from "./workspace.js";
|
|
22
22
|
import { readSourceMap } from "./sourcemap.js";
|
|
23
23
|
import { buildReport } from "./report.js";
|
|
24
|
+
import { computeDiff, computeRisk, isGitRepo } from "./gitdiff.js";
|
|
25
|
+
import { packContext } from "./contextpack.js";
|
|
26
|
+
import { computeCoupling } from "./coupling.js";
|
|
27
|
+
import { findLayerViolations } from "./layers.js";
|
|
28
|
+
import { computeModuleCoupling } from "./modulecoupling.js";
|
|
29
|
+
import { registerPrompts } from "./prompts.js";
|
|
24
30
|
/** Files may only be read inside this root (override with AST_MAP_ROOT). */
|
|
25
31
|
const ROOT = path.resolve(process.env.AST_MAP_ROOT ?? process.cwd());
|
|
26
32
|
function resolveInRoot(input) {
|
|
@@ -65,6 +71,7 @@ const server = new McpServer({
|
|
|
65
71
|
name: "universal-ast-mapper",
|
|
66
72
|
version: PKG_VERSION,
|
|
67
73
|
});
|
|
74
|
+
registerPrompts(server);
|
|
68
75
|
/* ----------------------- tool: list_supported_languages ----------------------- */
|
|
69
76
|
server.registerTool("list_supported_languages", {
|
|
70
77
|
title: "List supported languages",
|
|
@@ -790,6 +797,172 @@ server.registerTool("get_codebase_report", {
|
|
|
790
797
|
return errorText(describeError(err));
|
|
791
798
|
}
|
|
792
799
|
});
|
|
800
|
+
/* ─────────────────── tool: get_diff ────────────────────────────────────── */
|
|
801
|
+
server.registerTool("get_diff", {
|
|
802
|
+
title: "Git-aware change diff + blast radius",
|
|
803
|
+
description: "Compare the working tree against a git ref (default HEAD) and return which symbols were " +
|
|
804
|
+
"added/removed/modified per file, which changes are potentially **breaking** (removed or " +
|
|
805
|
+
"signature-changed exports), and the **blast radius** \u2014 files that depend on those breaking changes.",
|
|
806
|
+
inputSchema: {
|
|
807
|
+
base: z.string().optional().describe("Git ref to compare against. Default HEAD."),
|
|
808
|
+
path: z.string().optional().describe("Limit to a subdirectory. Default project root."),
|
|
809
|
+
},
|
|
810
|
+
}, async ({ base, path: input }) => {
|
|
811
|
+
try {
|
|
812
|
+
if (!isGitRepo(ROOT))
|
|
813
|
+
return errorText("Not a git repository (or git is unavailable).");
|
|
814
|
+
const { abs, rel } = resolveInRoot(input ?? ".");
|
|
815
|
+
const data = await computeDiff(abs, ROOT, base ?? "HEAD");
|
|
816
|
+
return jsonText({ directory: rel.split(path.sep).join("/") || ".", ...data });
|
|
817
|
+
}
|
|
818
|
+
catch (err) {
|
|
819
|
+
return errorText(describeError(err));
|
|
820
|
+
}
|
|
821
|
+
});
|
|
822
|
+
/* ─────────────────── tool: get_risk_map ────────────────────────────────── */
|
|
823
|
+
server.registerTool("get_risk_map", {
|
|
824
|
+
title: "Refactor risk map (churn \u00d7 complexity)",
|
|
825
|
+
description: "Rank files by refactor risk = git churn (number of commits touching the file) \u00d7 the file's " +
|
|
826
|
+
"max function complexity. Surfaces the files that are both frequently changed and complex \u2014 " +
|
|
827
|
+
"the most valuable refactor / test targets.",
|
|
828
|
+
inputSchema: {
|
|
829
|
+
path: z.string().optional().describe("Directory to scan. Default project root."),
|
|
830
|
+
},
|
|
831
|
+
}, async ({ path: input }) => {
|
|
832
|
+
try {
|
|
833
|
+
if (!isGitRepo(ROOT))
|
|
834
|
+
return errorText("Not a git repository (or git is unavailable).");
|
|
835
|
+
const { abs, rel } = resolveInRoot(input ?? ".");
|
|
836
|
+
const files = await computeRisk(abs, ROOT);
|
|
837
|
+
return jsonText({ directory: rel.split(path.sep).join("/") || ".", count: files.length, files: files.slice(0, 50) });
|
|
838
|
+
}
|
|
839
|
+
catch (err) {
|
|
840
|
+
return errorText(describeError(err));
|
|
841
|
+
}
|
|
842
|
+
});
|
|
843
|
+
/* ─────────────────── tool: pack_context ────────────────────────────────── */
|
|
844
|
+
server.registerTool("pack_context", {
|
|
845
|
+
title: "Minimal context pack for a symbol",
|
|
846
|
+
description: "Assemble the *minimal* context needed to understand or change a symbol \u2014 the symbol's own " +
|
|
847
|
+
"source, the signatures of what it depends on (resolved imports), and the files that depend on " +
|
|
848
|
+
"it \u2014 instead of reading whole files. Returns a token estimate so you can see the savings.",
|
|
849
|
+
inputSchema: {
|
|
850
|
+
path: z.string().describe("File containing the symbol (relative to root or absolute within it)."),
|
|
851
|
+
symbol: z.string().optional().describe("Symbol name to centre the pack on. Omit for the whole file."),
|
|
852
|
+
scan: z.string().optional().describe("Directory to scan for dependents. Default: project root."),
|
|
853
|
+
},
|
|
854
|
+
}, async ({ path: input, symbol, scan }) => {
|
|
855
|
+
try {
|
|
856
|
+
const { abs, rel } = resolveInRoot(input);
|
|
857
|
+
if (fs.statSync(abs).isDirectory())
|
|
858
|
+
return errorText(`"${input}" is a directory; pass a file.`);
|
|
859
|
+
const scanAbs = scan ? resolveInRoot(scan).abs : ROOT;
|
|
860
|
+
const pack = await packContext(abs, rel.split(path.sep).join("/"), ROOT, symbol, scanAbs);
|
|
861
|
+
return jsonText(pack);
|
|
862
|
+
}
|
|
863
|
+
catch (err) {
|
|
864
|
+
return errorText(describeError(err));
|
|
865
|
+
}
|
|
866
|
+
});
|
|
867
|
+
/* ─────────────────── tool: get_coupling ────────────────────────────────── */
|
|
868
|
+
server.registerTool("get_coupling", {
|
|
869
|
+
title: "Coupling metrics (afferent / efferent / instability)",
|
|
870
|
+
description: "Compute Robert C. Martin's coupling metrics per file from the import graph: afferent coupling " +
|
|
871
|
+
"(Ca, fan-in), efferent coupling (Ce, fan-out), and instability I = Ce/(Ca+Ce) (0 = stable, " +
|
|
872
|
+
"1 = unstable). High-Ca files are load-bearing; high-instability files change freely.",
|
|
873
|
+
inputSchema: {
|
|
874
|
+
path: z.string().optional().describe("Directory to scan. Default project root."),
|
|
875
|
+
},
|
|
876
|
+
}, async ({ path: input }) => {
|
|
877
|
+
try {
|
|
878
|
+
const { abs, rel } = resolveInRoot(input ?? ".");
|
|
879
|
+
if (!fs.statSync(abs).isDirectory()) {
|
|
880
|
+
return errorText(`"${input}" is not a directory. get_coupling requires a directory.`);
|
|
881
|
+
}
|
|
882
|
+
const opts = resolveOptions({ detail: "outline", emitHtml: false });
|
|
883
|
+
const files = collectSourceFiles(abs, opts);
|
|
884
|
+
const skels = [];
|
|
885
|
+
for (const f of files) {
|
|
886
|
+
const r = path.relative(ROOT, f).split(path.sep).join("/");
|
|
887
|
+
try {
|
|
888
|
+
skels.push(await buildSkeleton(f, r, opts));
|
|
889
|
+
}
|
|
890
|
+
catch { /* skip */ }
|
|
891
|
+
}
|
|
892
|
+
const metrics = computeCoupling(buildSymbolGraph(skels, ROOT));
|
|
893
|
+
return jsonText({ directory: rel.split(path.sep).join("/") || ".", count: metrics.length, files: metrics });
|
|
894
|
+
}
|
|
895
|
+
catch (err) {
|
|
896
|
+
return errorText(describeError(err));
|
|
897
|
+
}
|
|
898
|
+
});
|
|
899
|
+
/* ─────────────────── tool: get_layer_violations ────────────────────────── */
|
|
900
|
+
server.registerTool("get_layer_violations", {
|
|
901
|
+
title: "Layer violations (Stable Dependencies Principle)",
|
|
902
|
+
description: "Find dependencies that point the wrong way on the stability gradient: a stable file " +
|
|
903
|
+
"(low instability) that imports a more volatile file (high instability). Per Robert C. Martin's " +
|
|
904
|
+
"Stable Dependencies Principle, stable code should not depend on volatile code — it gets dragged " +
|
|
905
|
+
"along every time the volatile file churns. Results are sorted by severity (the instability gap).",
|
|
906
|
+
inputSchema: {
|
|
907
|
+
path: z.string().optional().describe("Directory to scan. Default project root."),
|
|
908
|
+
minGap: z.number().optional().describe("Only report violations whose instability gap exceeds this (0-1). Default 0."),
|
|
909
|
+
},
|
|
910
|
+
}, async ({ path: input, minGap }) => {
|
|
911
|
+
try {
|
|
912
|
+
const { abs, rel } = resolveInRoot(input ?? ".");
|
|
913
|
+
if (!fs.statSync(abs).isDirectory()) {
|
|
914
|
+
return errorText(`"${input}" is not a directory. get_layer_violations requires a directory.`);
|
|
915
|
+
}
|
|
916
|
+
const opts = resolveOptions({ detail: "outline", emitHtml: false });
|
|
917
|
+
const files = collectSourceFiles(abs, opts);
|
|
918
|
+
const skels = [];
|
|
919
|
+
for (const f of files) {
|
|
920
|
+
const r = path.relative(ROOT, f).split(path.sep).join("/");
|
|
921
|
+
try {
|
|
922
|
+
skels.push(await buildSkeleton(f, r, opts));
|
|
923
|
+
}
|
|
924
|
+
catch { /* skip */ }
|
|
925
|
+
}
|
|
926
|
+
const violations = findLayerViolations(buildSymbolGraph(skels, ROOT), minGap ?? 0);
|
|
927
|
+
return jsonText({ directory: rel.split(path.sep).join("/") || ".", count: violations.length, violations });
|
|
928
|
+
}
|
|
929
|
+
catch (err) {
|
|
930
|
+
return errorText(describeError(err));
|
|
931
|
+
}
|
|
932
|
+
});
|
|
933
|
+
/* ─────────────────── tool: get_module_coupling ─────────────────────────── */
|
|
934
|
+
server.registerTool("get_module_coupling", {
|
|
935
|
+
title: "Module coupling (directory-level Ca / Ce / instability)",
|
|
936
|
+
description: "Aggregate the import graph up to the directory/module level: per-module afferent (Ca) / " +
|
|
937
|
+
"efferent (Ce) coupling and instability, plus the weighted inter-module edges. Intra-module " +
|
|
938
|
+
"imports (files importing siblings in the same directory) are ignored — only cross-module " +
|
|
939
|
+
"dependencies count. The architectural bird's-eye view above per-file coupling.",
|
|
940
|
+
inputSchema: {
|
|
941
|
+
path: z.string().optional().describe("Directory to scan. Default project root."),
|
|
942
|
+
},
|
|
943
|
+
}, async ({ path: input }) => {
|
|
944
|
+
try {
|
|
945
|
+
const { abs, rel } = resolveInRoot(input ?? ".");
|
|
946
|
+
if (!fs.statSync(abs).isDirectory()) {
|
|
947
|
+
return errorText(`"${input}" is not a directory. get_module_coupling requires a directory.`);
|
|
948
|
+
}
|
|
949
|
+
const opts = resolveOptions({ detail: "outline", emitHtml: false });
|
|
950
|
+
const files = collectSourceFiles(abs, opts);
|
|
951
|
+
const skels = [];
|
|
952
|
+
for (const f of files) {
|
|
953
|
+
const r = path.relative(ROOT, f).split(path.sep).join("/");
|
|
954
|
+
try {
|
|
955
|
+
skels.push(await buildSkeleton(f, r, opts));
|
|
956
|
+
}
|
|
957
|
+
catch { /* skip */ }
|
|
958
|
+
}
|
|
959
|
+
const mc = computeModuleCoupling(buildSymbolGraph(skels, ROOT));
|
|
960
|
+
return jsonText({ directory: rel.split(path.sep).join("/") || ".", moduleCount: mc.modules.length, ...mc });
|
|
961
|
+
}
|
|
962
|
+
catch (err) {
|
|
963
|
+
return errorText(describeError(err));
|
|
964
|
+
}
|
|
965
|
+
});
|
|
793
966
|
/* ─────────────────── tool: get_change_impact ───────────────────────────── */
|
|
794
967
|
server.registerTool("get_change_impact", {
|
|
795
968
|
title: "Get change impact (blast radius)",
|
package/dist/layers.js
ADDED
|
@@ -0,0 +1,36 @@
|
|
|
1
|
+
import { computeCoupling } from "./coupling.js";
|
|
2
|
+
/**
|
|
3
|
+
* Detect violations of Robert C. Martin's Stable Dependencies Principle (SDP):
|
|
4
|
+
* a module should depend only on modules at least as stable as itself. A "stable"
|
|
5
|
+
* file (low instability) that imports a "volatile" file (high instability) is a
|
|
6
|
+
* violation — volatile code changes often and will keep dragging the stable code
|
|
7
|
+
* with it. Severity is the instability gap the dependency crosses.
|
|
8
|
+
*/
|
|
9
|
+
export function findLayerViolations(graph, minGap = 0) {
|
|
10
|
+
const inst = new Map(computeCoupling(graph).map((m) => [m.file, m.instability]));
|
|
11
|
+
const nodeMap = new Map(graph.nodes.map((n) => [n.id, n]));
|
|
12
|
+
const seen = new Set();
|
|
13
|
+
const violations = [];
|
|
14
|
+
for (const e of graph.edges) {
|
|
15
|
+
if (e.edgeType !== "imports")
|
|
16
|
+
continue;
|
|
17
|
+
const to = nodeMap.get(e.to);
|
|
18
|
+
const toFile = to ? (to.nodeType === "file" ? to.id : to.file) : null;
|
|
19
|
+
const fromFile = e.from;
|
|
20
|
+
if (!toFile || fromFile === toFile)
|
|
21
|
+
continue;
|
|
22
|
+
const fi = inst.get(fromFile);
|
|
23
|
+
const ti = inst.get(toFile);
|
|
24
|
+
if (fi === undefined || ti === undefined)
|
|
25
|
+
continue;
|
|
26
|
+
const severity = Math.round((ti - fi) * 100) / 100;
|
|
27
|
+
if (severity <= minGap)
|
|
28
|
+
continue; // only "uphill" dependencies (stable -> volatile)
|
|
29
|
+
const key = fromFile + " " + toFile;
|
|
30
|
+
if (seen.has(key))
|
|
31
|
+
continue;
|
|
32
|
+
seen.add(key);
|
|
33
|
+
violations.push({ from: fromFile, to: toFile, fromInstability: fi, toInstability: ti, severity });
|
|
34
|
+
}
|
|
35
|
+
return violations.sort((a, b) => b.severity - a.severity || a.from.localeCompare(b.from));
|
|
36
|
+
}
|
|
Binary file
|
package/dist/prompts.js
ADDED
|
@@ -0,0 +1,67 @@
|
|
|
1
|
+
import { z } from "zod";
|
|
2
|
+
/** GetPromptResult helper — a single user-role text message. */
|
|
3
|
+
function userPrompt(text) {
|
|
4
|
+
return { messages: [{ role: "user", content: { type: "text", text } }] };
|
|
5
|
+
}
|
|
6
|
+
const dirArg = { dir: z.string().optional().describe("Directory to analyze, relative to the project root. Default 'src'.") };
|
|
7
|
+
const d = (dir) => (dir && dir.trim() ? dir.trim() : "src");
|
|
8
|
+
/**
|
|
9
|
+
* Register the Cookbook recipes as MCP prompts so clients can invoke a whole
|
|
10
|
+
* AST-MCP workflow (a chain of tool calls) by name, instead of the user pasting
|
|
11
|
+
* the recipe text. Each prompt returns a ready-to-run instruction that references
|
|
12
|
+
* the server's own tools.
|
|
13
|
+
*/
|
|
14
|
+
export function registerPrompts(server) {
|
|
15
|
+
server.registerPrompt("architecture_audit", {
|
|
16
|
+
title: "Architecture audit",
|
|
17
|
+
description: "Full structural audit of a directory: God Nodes, cycles, rule violations, and module coupling.",
|
|
18
|
+
argsSchema: dirArg,
|
|
19
|
+
}, ({ dir }) => userPrompt(`Run a full architecture audit of \`${d(dir)}\` using the ast-mapper tools, in this order:\n\n` +
|
|
20
|
+
`1. \`build_symbol_graph\` on \`${d(dir)}\` to load the dependency graph.\n` +
|
|
21
|
+
`2. \`get_top_symbols\` — identify the 5 most-imported symbols (the "God Nodes").\n` +
|
|
22
|
+
`3. \`find_circular_deps\` — report any circular import chains.\n` +
|
|
23
|
+
`4. \`validate_architecture\` — list structural rule violations (large files, too many imports, god exports).\n` +
|
|
24
|
+
`5. \`get_module_coupling\` — which directories are load-bearing (high Ca) vs. volatile (high I)?\n` +
|
|
25
|
+
`6. \`get_layer_violations\` — any stable code depending on volatile code (SDP breaks)?\n\n` +
|
|
26
|
+
`Then write a short prioritized summary: the top 3 architectural risks and a concrete first step for each.`));
|
|
27
|
+
server.registerPrompt("safe_refactor", {
|
|
28
|
+
title: "Safe refactor checklist",
|
|
29
|
+
description: "Everything you need to know before changing a specific symbol: blast radius, callees, and minimal context.",
|
|
30
|
+
argsSchema: {
|
|
31
|
+
file: z.string().describe("File containing the symbol, relative to the project root."),
|
|
32
|
+
symbol: z.string().describe("Name of the function/class/symbol you intend to change."),
|
|
33
|
+
},
|
|
34
|
+
}, ({ file, symbol }) => userPrompt(`Before refactoring \`${symbol}\` in \`${file}\`, gather the impact using the ast-mapper tools:\n\n` +
|
|
35
|
+
`1. \`get_change_impact\` for \`${file}\` / \`${symbol}\` — who depends on it (the blast radius)?\n` +
|
|
36
|
+
`2. \`get_call_graph\` — what does \`${symbol}\` call, and what calls it?\n` +
|
|
37
|
+
`3. \`pack_context\` for \`${file}\` / \`${symbol}\` — the minimal context (its source + the signatures it depends on).\n\n` +
|
|
38
|
+
`Then summarize: what will break if the signature changes, which call sites need updating, and a safe step-by-step refactor order.`));
|
|
39
|
+
server.registerPrompt("dead_code_cleanup", {
|
|
40
|
+
title: "Dead-code cleanup",
|
|
41
|
+
description: "Find unused exports and verify each is safe to delete before removing it.",
|
|
42
|
+
argsSchema: dirArg,
|
|
43
|
+
}, ({ dir }) => userPrompt(`Help me remove dead code from \`${d(dir)}\` using the ast-mapper tools:\n\n` +
|
|
44
|
+
`1. \`find_dead_code\` on \`${d(dir)}\` — list exported symbols nobody imports.\n` +
|
|
45
|
+
`2. For each HIGH-confidence result, double-check with \`get_change_impact\` (should be empty).\n` +
|
|
46
|
+
`3. Before suggesting deletion, show the symbol's source with \`get_symbol_context\`.\n\n` +
|
|
47
|
+
`Produce a deletion checklist: only symbols that are high-confidence AND have zero impact. Flag anything dynamic (string-referenced, re-exported) as "verify manually".`));
|
|
48
|
+
server.registerPrompt("health_check", {
|
|
49
|
+
title: "Codebase health check",
|
|
50
|
+
description: "A one-pass health report: overall grade, riskiest files, and stability inversions.",
|
|
51
|
+
argsSchema: dirArg,
|
|
52
|
+
}, ({ dir }) => userPrompt(`Give me a health check of \`${d(dir)}\` using the ast-mapper tools:\n\n` +
|
|
53
|
+
`1. \`get_codebase_report\` on \`${d(dir)}\` — overall grade A–F, hotspots, god nodes, dead code, cycles.\n` +
|
|
54
|
+
`2. \`get_risk_map\` — the files with the highest churn × complexity (best refactor / test targets).\n` +
|
|
55
|
+
`3. \`get_layer_violations\` — stable files depending on volatile ones.\n\n` +
|
|
56
|
+
`Summarize as: the grade, the single biggest problem, and the 3 files I should touch first to improve it.`));
|
|
57
|
+
server.registerPrompt("onboard_codebase", {
|
|
58
|
+
title: "Onboard to a codebase",
|
|
59
|
+
description: "Get oriented in an unfamiliar directory: languages, structure, the core symbols, and how modules connect.",
|
|
60
|
+
argsSchema: dirArg,
|
|
61
|
+
}, ({ dir }) => userPrompt(`I'm new to this codebase. Walk me through \`${d(dir)}\` using the ast-mapper tools:\n\n` +
|
|
62
|
+
`1. \`list_supported_languages\` then \`generate_skeleton\` on \`${d(dir)}\` — what languages and overall shape?\n` +
|
|
63
|
+
`2. \`get_top_symbols\` — the most-depended-on symbols are the concepts to learn first.\n` +
|
|
64
|
+
`3. \`get_module_coupling\` — how do the directories relate; what's the stable core?\n` +
|
|
65
|
+
`4. For the top God Node, \`get_symbol_context\` with related symbols to see how it's used.\n\n` +
|
|
66
|
+
`Then write a 5-bullet "start here" orientation: what this code does, its core abstractions, and where to begin reading.`));
|
|
67
|
+
}
|
package/dist/registry.js
CHANGED
|
@@ -16,6 +16,14 @@ const TS_ENTRY = (language, grammar) => ({
|
|
|
16
16
|
extractDirectives: extractDirectivesTS,
|
|
17
17
|
extractImports: extractImportsTS,
|
|
18
18
|
});
|
|
19
|
+
const SFC_ENTRY = (language) => ({
|
|
20
|
+
language,
|
|
21
|
+
grammar: "typescript", // overridden per-file after detecting lang="ts" vs js
|
|
22
|
+
extract: extractTypeScript,
|
|
23
|
+
extractDirectives: extractDirectivesTS,
|
|
24
|
+
extractImports: extractImportsTS,
|
|
25
|
+
sfc: true,
|
|
26
|
+
});
|
|
19
27
|
const BY_EXT = {
|
|
20
28
|
".ts": TS_ENTRY("typescript", "typescript"),
|
|
21
29
|
".mts": TS_ENTRY("typescript", "typescript"),
|
|
@@ -54,6 +62,8 @@ const BY_EXT = {
|
|
|
54
62
|
extract: extractKotlin, extractDirectives: extractDirectivesKotlin, extractImports: extractImportsKotlin,
|
|
55
63
|
},
|
|
56
64
|
".swift": { language: "swift", grammar: "swift", extract: extractSwift, extractImports: extractImportsSwift },
|
|
65
|
+
".vue": SFC_ENTRY("vue"),
|
|
66
|
+
".svelte": SFC_ENTRY("svelte"),
|
|
57
67
|
};
|
|
58
68
|
export function detectLanguage(filePath) {
|
|
59
69
|
return BY_EXT[path.extname(filePath).toLowerCase()] ?? null;
|
package/dist/resolver.js
CHANGED
|
@@ -5,7 +5,7 @@ import { resolveOptions } from "./config.js";
|
|
|
5
5
|
import { findSymbol } from "./analysis.js";
|
|
6
6
|
import { buildCrossLangIndex, resolveCrossLangTarget, } from "./crosslang.js";
|
|
7
7
|
import { resolveWorkspaceImportCached } from "./workspace.js";
|
|
8
|
-
const SRC_EXTS = [".ts", ".tsx", ".js", ".jsx", ".mts", ".cts", ".mjs", ".cjs"];
|
|
8
|
+
const SRC_EXTS = [".ts", ".tsx", ".js", ".jsx", ".mts", ".cts", ".mjs", ".cjs", ".vue", ".svelte"];
|
|
9
9
|
function extractParams(sig) {
|
|
10
10
|
const start = sig.indexOf("(");
|
|
11
11
|
if (start === -1)
|
package/dist/sfc.js
ADDED
|
@@ -0,0 +1,27 @@
|
|
|
1
|
+
const SCRIPT_RE = /<script\b([^>]*)>([\s\S]*?)<\/script>/gi;
|
|
2
|
+
export function isSfcExt(ext) {
|
|
3
|
+
return ext === ".vue" || ext === ".svelte";
|
|
4
|
+
}
|
|
5
|
+
export function extractScript(source) {
|
|
6
|
+
// Start from an all-blank canvas the same shape as the source (newlines kept,
|
|
7
|
+
// every other character turned into a space) to preserve line/column offsets.
|
|
8
|
+
let out = source.replace(/[^\n]/g, " ");
|
|
9
|
+
let hasScript = false;
|
|
10
|
+
let lang = "js";
|
|
11
|
+
let m;
|
|
12
|
+
SCRIPT_RE.lastIndex = 0;
|
|
13
|
+
while ((m = SCRIPT_RE.exec(source)) !== null) {
|
|
14
|
+
hasScript = true;
|
|
15
|
+
const attrs = m[1] ?? "";
|
|
16
|
+
if (/lang\s*=\s*["'](ts|typescript)["']/i.test(attrs))
|
|
17
|
+
lang = "ts";
|
|
18
|
+
const inner = m[2] ?? "";
|
|
19
|
+
const innerStart = m.index + m[0].indexOf(inner, m[1] ? m[1].length : 0);
|
|
20
|
+
out = out.slice(0, innerStart) + inner + out.slice(innerStart + inner.length);
|
|
21
|
+
}
|
|
22
|
+
return {
|
|
23
|
+
code: hasScript ? out : "",
|
|
24
|
+
grammar: lang === "ts" ? "typescript" : "javascript",
|
|
25
|
+
hasScript,
|
|
26
|
+
};
|
|
27
|
+
}
|
package/dist/skeleton.js
CHANGED
|
@@ -3,6 +3,7 @@ import path from "node:path";
|
|
|
3
3
|
import { detectLanguage, supportedExtensions } from "./registry.js";
|
|
4
4
|
import { parseSource } from "./parser.js";
|
|
5
5
|
import { countSymbols, toOutline } from "./extractors/common.js";
|
|
6
|
+
import { extractScript } from "./sfc.js";
|
|
6
7
|
export const SCHEMA_VERSION = "1.1";
|
|
7
8
|
export const GRAMMAR_SOURCE = "tree-sitter-wasms@0.1.13";
|
|
8
9
|
const parseCache = new Map();
|
|
@@ -57,8 +58,14 @@ export async function buildSkeleton(absPath, relPath, opts) {
|
|
|
57
58
|
const wantFile = relPath.split(path.sep).join("/");
|
|
58
59
|
return cached.file === wantFile ? cached : { ...cached, file: wantFile };
|
|
59
60
|
}
|
|
60
|
-
|
|
61
|
-
|
|
61
|
+
let source = fs.readFileSync(absPath, "utf8");
|
|
62
|
+
let grammar = entry.grammar;
|
|
63
|
+
if (entry.sfc) {
|
|
64
|
+
const script = extractScript(source);
|
|
65
|
+
source = script.code; // blank-padded script-only source (offsets preserved)
|
|
66
|
+
grammar = script.grammar;
|
|
67
|
+
}
|
|
68
|
+
const root = await parseSource(grammar, source);
|
|
62
69
|
let symbols = entry.extract(root, source);
|
|
63
70
|
if (opts.detail === "outline")
|
|
64
71
|
symbols = toOutline(symbols);
|
|
@@ -69,7 +76,7 @@ export async function buildSkeleton(absPath, relPath, opts) {
|
|
|
69
76
|
file: relPath.split(path.sep).join("/"),
|
|
70
77
|
language: entry.language,
|
|
71
78
|
generatedAt: new Date().toISOString(),
|
|
72
|
-
parser: { engine: "tree-sitter", grammar: `${
|
|
79
|
+
parser: { engine: "tree-sitter", grammar: `${grammar} (${GRAMMAR_SOURCE})` },
|
|
73
80
|
symbolCount: countSymbols(symbols),
|
|
74
81
|
...(directives.length > 0 ? { directives } : {}),
|
|
75
82
|
...(imports.length > 0 ? { imports } : {}),
|
package/package.json
CHANGED