universal-ast-mapper 1.11.0 → 1.13.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 +12 -0
- package/README.md +68 -0
- package/dist/cli.js +96 -0
- package/dist/contextpack.js +79 -0
- package/dist/gitdiff.js +178 -0
- package/dist/index.js +69 -0
- package/package.json +1 -1
package/CHANGELOG.md
CHANGED
|
@@ -6,6 +6,16 @@ since 1.0.0, guarantees a stable MCP tool / CLI surface across the 1.x line.
|
|
|
6
6
|
|
|
7
7
|
---
|
|
8
8
|
|
|
9
|
+
## [1.13.0] — 2026-06-08 · Context-pack
|
|
10
|
+
- **`pack_context`** + **`ast-map pack <file> [symbol]`**: the minimal context to
|
|
11
|
+
work on a symbol — its source, the signatures it depends on, and its dependents
|
|
12
|
+
— with a token estimate, instead of reading whole files.
|
|
13
|
+
|
|
14
|
+
## [1.12.0] — 2026-06-08 · Git-aware analysis
|
|
15
|
+
- **`ast-map diff [base]`** + **`get_diff`**: changed symbols since a git ref,
|
|
16
|
+
breaking changes (removed / signature-changed exports), and blast radius.
|
|
17
|
+
- **`ast-map risk`** + **`get_risk_map`**: rank files by churn × complexity.
|
|
18
|
+
|
|
9
19
|
## [1.11.0] — 2026-06-01 · Code-health dashboard
|
|
10
20
|
- **`ast-map report`** writes a premium self-contained HTML dashboard: health
|
|
11
21
|
grade (A–F), stats, language breakdown, complexity hotspots, god nodes, dead
|
|
@@ -117,6 +127,8 @@ since 1.0.0, guarantees a stable MCP tool / CLI surface across the 1.x line.
|
|
|
117
127
|
- **0.2.0** — import extraction; `resolve_imports`; `build_symbol_graph`.
|
|
118
128
|
- **0.1.0** — `get_skeleton_json`, `generate_skeleton`, `get_symbol_context`, `validate_architecture`.
|
|
119
129
|
|
|
130
|
+
[1.13.0]: https://github.com/6ixthxense/AST-MCP/releases/tag/v1.13.0
|
|
131
|
+
[1.12.0]: https://github.com/6ixthxense/AST-MCP/releases/tag/v1.12.0
|
|
120
132
|
[1.11.0]: https://github.com/6ixthxense/AST-MCP/releases/tag/v1.11.0
|
|
121
133
|
[1.10.0]: https://github.com/6ixthxense/AST-MCP/releases/tag/v1.10.0
|
|
122
134
|
[1.9.0]: https://github.com/6ixthxense/AST-MCP/releases/tag/v1.9.0
|
package/README.md
CHANGED
|
@@ -4,6 +4,8 @@ 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
|
+
**24 MCP tools / 25 CLI commands** 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
|
+
|
|
7
9
|
**Supported languages:** TypeScript · TSX · JavaScript (ESM/CJS) · Python · Go · Rust · Java · C# · C · C++ · Kotlin · Swift
|
|
8
10
|
|
|
9
11
|
| Capability | TS/JS | Python | Go | Rust | Java | C# | C | C++ | Kt | Swift |
|
|
@@ -102,6 +104,9 @@ 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
|
|
105
110
|
ast-map search <pattern> [dir] [-m contains|exact|regex] [-k kind] [-e]
|
|
106
111
|
ast-map deps <file> [--scan <dir>]
|
|
107
112
|
ast-map top <dir> [-n 10]
|
|
@@ -348,6 +353,67 @@ Scan a file or directory for **named functions/methods with parameters that are
|
|
|
348
353
|
|
|
349
354
|
---
|
|
350
355
|
|
|
356
|
+
### `read_source_map`
|
|
357
|
+
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`).
|
|
358
|
+
|
|
359
|
+
```json
|
|
360
|
+
{ "file": "dist/bundle.js", "mapKind": "inline", "sources": ["../src/app.ts", "../src/util.ts"], "hasContent": true }
|
|
361
|
+
```
|
|
362
|
+
|
|
363
|
+
**Params:** `path`
|
|
364
|
+
|
|
365
|
+
---
|
|
366
|
+
|
|
367
|
+
### `get_codebase_report`
|
|
368
|
+
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`.
|
|
369
|
+
|
|
370
|
+
```json
|
|
371
|
+
{ "grade": "B", "score": 82, "fileCount": 120, "symbolCount": 1400,
|
|
372
|
+
"complexity": { "average": 4.1, "max": 22, "hotspots": [ … ] },
|
|
373
|
+
"godNodes": [ … ], "dead": { "count": 3, "items": [ … ] }, "cycles": { "count": 0, "items": [] } }
|
|
374
|
+
```
|
|
375
|
+
|
|
376
|
+
**Params:** `path` (optional, defaults to root)
|
|
377
|
+
|
|
378
|
+
---
|
|
379
|
+
|
|
380
|
+
### `get_diff`
|
|
381
|
+
**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.
|
|
382
|
+
|
|
383
|
+
```json
|
|
384
|
+
{ "summary": { "filesChanged": 2, "added": 1, "removed": 1, "modified": 1, "breaking": 2, "impactedFiles": 1 },
|
|
385
|
+
"breaking": [ { "file": "src/a.ts", "symbol": "foo", "reason": "signature changed" } ],
|
|
386
|
+
"impactedFiles": ["src/b.ts"] }
|
|
387
|
+
```
|
|
388
|
+
|
|
389
|
+
**Params:** `base` (optional), `path` (optional)
|
|
390
|
+
|
|
391
|
+
---
|
|
392
|
+
|
|
393
|
+
### `pack_context`
|
|
394
|
+
**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.
|
|
395
|
+
|
|
396
|
+
```json
|
|
397
|
+
{ "primary": { "symbol": "login", "startLine": 8, "endLine": 12, "source": "…" },
|
|
398
|
+
"dependencies": [ { "file": "utils.ts", "symbols": [ { "name": "hashPassword", "signature": "…" } ] } ],
|
|
399
|
+
"dependents": [ { "file": "router.ts" } ], "tokenEstimate": 56 }
|
|
400
|
+
```
|
|
401
|
+
|
|
402
|
+
**Params:** `path`, `symbol` (optional), `scan` (optional)
|
|
403
|
+
|
|
404
|
+
---
|
|
405
|
+
|
|
406
|
+
### `get_risk_map`
|
|
407
|
+
Rank files by **refactor risk = git churn × max complexity** — the files that are both frequently changed and complex (the best refactor / test targets).
|
|
408
|
+
|
|
409
|
+
```json
|
|
410
|
+
{ "files": [ { "file": "src/callgraph.ts", "churn": 7, "maxComplexity": 69, "risk": 483 } ] }
|
|
411
|
+
```
|
|
412
|
+
|
|
413
|
+
**Params:** `path` (optional)
|
|
414
|
+
|
|
415
|
+
---
|
|
416
|
+
|
|
351
417
|
### `get_change_impact`
|
|
352
418
|
Given a file + symbol, reverse-traverse the import graph to compute **blast radius**.
|
|
353
419
|
|
|
@@ -621,6 +687,8 @@ Not part of the public API: the internal `src/` module layout and the generated
|
|
|
621
687
|
|
|
622
688
|
| Version | What changed |
|
|
623
689
|
|---------|--------------|
|
|
690
|
+
| **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**. |
|
|
691
|
+
| **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
692
|
| **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
693
|
| **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
694
|
| **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`). |
|
package/dist/cli.js
CHANGED
|
@@ -17,6 +17,8 @@ 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";
|
|
20
22
|
import { buildCallGraph } from "./callgraph.js";
|
|
21
23
|
import { searchSymbols } from "./search.js";
|
|
22
24
|
const ROOT = path.resolve(process.env.AST_MAP_ROOT ?? process.cwd());
|
|
@@ -447,6 +449,100 @@ program
|
|
|
447
449
|
console.log(`\n ${info.sources.length} original source(s)` + (info.hasContent ? dim(" · embeds sourcesContent") : ""));
|
|
448
450
|
console.log();
|
|
449
451
|
});
|
|
452
|
+
// ─── Command: pack ────────────────────────────────────────────────────────────
|
|
453
|
+
program
|
|
454
|
+
.command("pack <file> [symbol]")
|
|
455
|
+
.description("Minimal context pack for a symbol (source + dep signatures + dependents)")
|
|
456
|
+
.option("--scan <dir>", "Directory to scan for dependents", ".")
|
|
457
|
+
.option("--json", "Output as JSON")
|
|
458
|
+
.action(async (file, symbol, opts) => {
|
|
459
|
+
const { abs, rel } = resolveArg(file);
|
|
460
|
+
if (fs.statSync(abs).isDirectory())
|
|
461
|
+
die(`"${rel}" is a directory; pass a file`);
|
|
462
|
+
const scanAbs = resolveArg(opts.scan).abs;
|
|
463
|
+
const pack = await packContext(abs, rel, ROOT, symbol, scanAbs);
|
|
464
|
+
if (opts.json)
|
|
465
|
+
return jsonOut(pack);
|
|
466
|
+
header(`Context Pack \u2014 ${rel}${symbol ? "::" + symbol : ""} ${dim("(~" + pack.tokenEstimate + " tokens)")}`);
|
|
467
|
+
console.log(indent(bold("Primary") + dim(` lines ${pack.primary.startLine}-${pack.primary.endLine}`)));
|
|
468
|
+
console.log();
|
|
469
|
+
console.log(indent(bold("Depends on:")));
|
|
470
|
+
if (pack.dependencies.length === 0)
|
|
471
|
+
console.log(indent(dim("(none in-project)"), 4));
|
|
472
|
+
for (const d of pack.dependencies) {
|
|
473
|
+
console.log(indent(green(d.file), 4));
|
|
474
|
+
for (const sym of d.symbols)
|
|
475
|
+
console.log(indent(dim((sym.signature || sym.name)), 6));
|
|
476
|
+
}
|
|
477
|
+
console.log();
|
|
478
|
+
console.log(indent(bold("Depended on by:")));
|
|
479
|
+
if (pack.dependents.length === 0)
|
|
480
|
+
console.log(indent(dim("(none found in scan)"), 4));
|
|
481
|
+
for (const dep of pack.dependents)
|
|
482
|
+
console.log(indent(yellow(dep.file), 4));
|
|
483
|
+
console.log();
|
|
484
|
+
});
|
|
485
|
+
// ─── Command: diff ────────────────────────────────────────────────────────────
|
|
486
|
+
program
|
|
487
|
+
.command("diff [base]")
|
|
488
|
+
.description("Symbols changed since a git ref + breaking changes + blast radius")
|
|
489
|
+
.option("--dir <dir>", "Limit to a subdirectory", ".")
|
|
490
|
+
.option("--json", "Output as JSON")
|
|
491
|
+
.action(async (base, opts) => {
|
|
492
|
+
if (!isGitRepo(ROOT))
|
|
493
|
+
die("not a git repository (or git is unavailable)");
|
|
494
|
+
const { abs, rel } = resolveArg(opts.dir);
|
|
495
|
+
const ref = base ?? "HEAD";
|
|
496
|
+
const d = await computeDiff(abs, ROOT, ref);
|
|
497
|
+
if (opts.json)
|
|
498
|
+
return jsonOut(d);
|
|
499
|
+
header(`Diff since ${bold(ref)} ${dim(`(${d.summary.filesChanged} file(s) · +${d.summary.added} ~${d.summary.modified} -${d.summary.removed})`)}`);
|
|
500
|
+
if (d.files.length === 0) {
|
|
501
|
+
console.log(indent(dim("No source-symbol changes.")));
|
|
502
|
+
console.log();
|
|
503
|
+
return;
|
|
504
|
+
}
|
|
505
|
+
for (const f of d.files) {
|
|
506
|
+
console.log(indent(`${bold(f.file)} ${dim("[" + f.status + "]")}`));
|
|
507
|
+
for (const a of f.added)
|
|
508
|
+
console.log(indent(green("+ ") + a.symbol + dim(a.exported ? " (exported)" : ""), 4));
|
|
509
|
+
for (const m of f.modified)
|
|
510
|
+
console.log(indent(yellow("~ ") + m.symbol + dim(m.exported ? " (exported)" : ""), 4));
|
|
511
|
+
for (const r of f.removed)
|
|
512
|
+
console.log(indent(red("- ") + r.symbol + dim(r.exported ? " (exported)" : ""), 4));
|
|
513
|
+
}
|
|
514
|
+
if (d.breaking.length > 0) {
|
|
515
|
+
console.log(`\n${indent(bold(red("\u26a0 Breaking changes (" + d.breaking.length + ")")))}`);
|
|
516
|
+
for (const b of d.breaking)
|
|
517
|
+
console.log(indent(`${red(b.symbol)} ${dim(b.reason)} ${dim(b.file)}`, 4));
|
|
518
|
+
console.log(`\n${indent(yellow(d.impactedFiles.length + " file(s) impacted") + dim(" by breaking changes"))}`);
|
|
519
|
+
for (const f of d.impactedFiles.slice(0, 20))
|
|
520
|
+
console.log(indent(dim(f), 4));
|
|
521
|
+
}
|
|
522
|
+
console.log();
|
|
523
|
+
});
|
|
524
|
+
// ─── Command: risk ────────────────────────────────────────────────────────────
|
|
525
|
+
program
|
|
526
|
+
.command("risk [dir]")
|
|
527
|
+
.description("Rank files by refactor risk (git churn × complexity)")
|
|
528
|
+
.option("--json", "Output as JSON")
|
|
529
|
+
.option("-n, --top <n>", "Show top N", (v) => parseInt(v, 10), 15)
|
|
530
|
+
.action(async (dir, opts) => {
|
|
531
|
+
if (!isGitRepo(ROOT))
|
|
532
|
+
die("not a git repository (or git is unavailable)");
|
|
533
|
+
const { abs, rel } = resolveArg(dir ?? ".");
|
|
534
|
+
const files = await computeRisk(abs, ROOT);
|
|
535
|
+
if (opts.json)
|
|
536
|
+
return jsonOut({ count: files.length, files });
|
|
537
|
+
header(`Refactor Risk \u2014 ${rel}/ ${dim("(churn × max complexity)")}`);
|
|
538
|
+
if (files.length === 0) {
|
|
539
|
+
console.log(indent(green("✓ nothing risky (no churn × complexity)")));
|
|
540
|
+
console.log();
|
|
541
|
+
return;
|
|
542
|
+
}
|
|
543
|
+
table(files.slice(0, opts.top).map((f) => [String(f.risk), `${f.churn} × ${f.maxComplexity}`, f.file]), [["Risk", 7], ["churn×cx", 12], ["File", 44]]);
|
|
544
|
+
console.log();
|
|
545
|
+
});
|
|
450
546
|
// ─── Command: report ──────────────────────────────────────────────────────────
|
|
451
547
|
program
|
|
452
548
|
.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/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/index.js
CHANGED
|
@@ -21,6 +21,8 @@ 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";
|
|
24
26
|
/** Files may only be read inside this root (override with AST_MAP_ROOT). */
|
|
25
27
|
const ROOT = path.resolve(process.env.AST_MAP_ROOT ?? process.cwd());
|
|
26
28
|
function resolveInRoot(input) {
|
|
@@ -790,6 +792,73 @@ server.registerTool("get_codebase_report", {
|
|
|
790
792
|
return errorText(describeError(err));
|
|
791
793
|
}
|
|
792
794
|
});
|
|
795
|
+
/* ─────────────────── tool: get_diff ────────────────────────────────────── */
|
|
796
|
+
server.registerTool("get_diff", {
|
|
797
|
+
title: "Git-aware change diff + blast radius",
|
|
798
|
+
description: "Compare the working tree against a git ref (default HEAD) and return which symbols were " +
|
|
799
|
+
"added/removed/modified per file, which changes are potentially **breaking** (removed or " +
|
|
800
|
+
"signature-changed exports), and the **blast radius** \u2014 files that depend on those breaking changes.",
|
|
801
|
+
inputSchema: {
|
|
802
|
+
base: z.string().optional().describe("Git ref to compare against. Default HEAD."),
|
|
803
|
+
path: z.string().optional().describe("Limit to a subdirectory. Default project root."),
|
|
804
|
+
},
|
|
805
|
+
}, async ({ base, path: input }) => {
|
|
806
|
+
try {
|
|
807
|
+
if (!isGitRepo(ROOT))
|
|
808
|
+
return errorText("Not a git repository (or git is unavailable).");
|
|
809
|
+
const { abs, rel } = resolveInRoot(input ?? ".");
|
|
810
|
+
const data = await computeDiff(abs, ROOT, base ?? "HEAD");
|
|
811
|
+
return jsonText({ directory: rel.split(path.sep).join("/") || ".", ...data });
|
|
812
|
+
}
|
|
813
|
+
catch (err) {
|
|
814
|
+
return errorText(describeError(err));
|
|
815
|
+
}
|
|
816
|
+
});
|
|
817
|
+
/* ─────────────────── tool: get_risk_map ────────────────────────────────── */
|
|
818
|
+
server.registerTool("get_risk_map", {
|
|
819
|
+
title: "Refactor risk map (churn \u00d7 complexity)",
|
|
820
|
+
description: "Rank files by refactor risk = git churn (number of commits touching the file) \u00d7 the file's " +
|
|
821
|
+
"max function complexity. Surfaces the files that are both frequently changed and complex \u2014 " +
|
|
822
|
+
"the most valuable refactor / test targets.",
|
|
823
|
+
inputSchema: {
|
|
824
|
+
path: z.string().optional().describe("Directory to scan. Default project root."),
|
|
825
|
+
},
|
|
826
|
+
}, async ({ path: input }) => {
|
|
827
|
+
try {
|
|
828
|
+
if (!isGitRepo(ROOT))
|
|
829
|
+
return errorText("Not a git repository (or git is unavailable).");
|
|
830
|
+
const { abs, rel } = resolveInRoot(input ?? ".");
|
|
831
|
+
const files = await computeRisk(abs, ROOT);
|
|
832
|
+
return jsonText({ directory: rel.split(path.sep).join("/") || ".", count: files.length, files: files.slice(0, 50) });
|
|
833
|
+
}
|
|
834
|
+
catch (err) {
|
|
835
|
+
return errorText(describeError(err));
|
|
836
|
+
}
|
|
837
|
+
});
|
|
838
|
+
/* ─────────────────── tool: pack_context ────────────────────────────────── */
|
|
839
|
+
server.registerTool("pack_context", {
|
|
840
|
+
title: "Minimal context pack for a symbol",
|
|
841
|
+
description: "Assemble the *minimal* context needed to understand or change a symbol \u2014 the symbol's own " +
|
|
842
|
+
"source, the signatures of what it depends on (resolved imports), and the files that depend on " +
|
|
843
|
+
"it \u2014 instead of reading whole files. Returns a token estimate so you can see the savings.",
|
|
844
|
+
inputSchema: {
|
|
845
|
+
path: z.string().describe("File containing the symbol (relative to root or absolute within it)."),
|
|
846
|
+
symbol: z.string().optional().describe("Symbol name to centre the pack on. Omit for the whole file."),
|
|
847
|
+
scan: z.string().optional().describe("Directory to scan for dependents. Default: project root."),
|
|
848
|
+
},
|
|
849
|
+
}, async ({ path: input, symbol, scan }) => {
|
|
850
|
+
try {
|
|
851
|
+
const { abs, rel } = resolveInRoot(input);
|
|
852
|
+
if (fs.statSync(abs).isDirectory())
|
|
853
|
+
return errorText(`"${input}" is a directory; pass a file.`);
|
|
854
|
+
const scanAbs = scan ? resolveInRoot(scan).abs : ROOT;
|
|
855
|
+
const pack = await packContext(abs, rel.split(path.sep).join("/"), ROOT, symbol, scanAbs);
|
|
856
|
+
return jsonText(pack);
|
|
857
|
+
}
|
|
858
|
+
catch (err) {
|
|
859
|
+
return errorText(describeError(err));
|
|
860
|
+
}
|
|
861
|
+
});
|
|
793
862
|
/* ─────────────────── tool: get_change_impact ───────────────────────────── */
|
|
794
863
|
server.registerTool("get_change_impact", {
|
|
795
864
|
title: "Get change impact (blast radius)",
|
package/package.json
CHANGED