universal-ast-mapper 2.0.0 → 2.0.2
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 +15 -0
- package/README.md +261 -12
- package/dist/ai-refactor.js +185 -0
- package/dist/ai-testgen.js +105 -0
- package/dist/analysis.js +134 -0
- package/dist/arch-rules.js +82 -0
- package/dist/callgraph.js +467 -0
- package/dist/check.js +112 -0
- package/dist/cli.js +2284 -0
- package/dist/complexity.js +98 -0
- package/dist/config.js +53 -0
- package/dist/contextpack.js +79 -0
- package/dist/coupling.js +35 -0
- package/dist/covmerge.js +176 -0
- package/dist/crosslang.js +425 -0
- package/dist/dashboard.js +259 -0
- package/dist/diagram.js +264 -0
- package/dist/diskcache.js +97 -0
- package/dist/docgen.js +156 -0
- package/dist/embeddings.js +136 -0
- package/dist/explain.js +123 -0
- package/dist/explorer.js +123 -0
- package/dist/extractors/c.js +204 -0
- package/dist/extractors/common.js +56 -0
- package/dist/extractors/cpp.js +272 -0
- package/dist/extractors/csharp.js +209 -0
- package/dist/extractors/go.js +212 -0
- package/dist/extractors/java.js +152 -0
- package/dist/extractors/kotlin.js +159 -0
- package/dist/extractors/php.js +208 -0
- package/dist/extractors/python.js +153 -0
- package/dist/extractors/ruby.js +146 -0
- package/dist/extractors/rust.js +249 -0
- package/dist/extractors/swift.js +192 -0
- package/dist/extractors/typescript.js +577 -0
- package/dist/fix.js +92 -0
- package/dist/gitdiff.js +178 -0
- package/dist/graph-analysis.js +279 -0
- package/dist/graph.js +165 -0
- package/dist/history.js +36 -0
- package/dist/html.js +658 -0
- package/dist/incremental.js +122 -0
- package/dist/index.js +1945 -0
- package/dist/indexstore.js +105 -0
- package/dist/layers.js +36 -0
- package/dist/lsp.js +238 -0
- package/dist/modulecoupling.js +0 -0
- package/dist/parser.js +84 -0
- package/dist/patch.js +199 -0
- package/dist/plugins.js +88 -0
- package/dist/pool.js +114 -0
- package/dist/prompts.js +67 -0
- package/dist/registry.js +87 -0
- package/dist/report.js +441 -0
- package/dist/resolver.js +222 -0
- package/dist/roots.js +47 -0
- package/dist/search.js +68 -0
- package/dist/security.js +178 -0
- package/dist/semantic.js +365 -0
- package/dist/serve.js +328 -0
- package/dist/sfc.js +27 -0
- package/dist/similar.js +98 -0
- package/dist/skeleton.js +132 -0
- package/dist/smells.js +285 -0
- package/dist/sourcemap.js +60 -0
- package/dist/testgen.js +280 -0
- package/dist/testmap.js +167 -0
- package/dist/tsconfig.js +212 -0
- package/dist/typeflow.js +124 -0
- package/dist/types.js +5 -0
- package/dist/unused-params.js +127 -0
- package/dist/webapp.js +646 -0
- package/dist/worker.js +27 -0
- package/dist/workspace.js +330 -0
- package/package.json +2 -1
|
@@ -0,0 +1,105 @@
|
|
|
1
|
+
import fs from "node:fs";
|
|
2
|
+
import path from "node:path";
|
|
3
|
+
import crypto from "node:crypto";
|
|
4
|
+
import { buildSkeletonsBulk } from "./pool.js";
|
|
5
|
+
import { collectSourceFiles } from "./skeleton.js";
|
|
6
|
+
import { resolveOptions } from "./config.js";
|
|
7
|
+
const INDEX_VERSION = "2";
|
|
8
|
+
const INDEX_FILE = ".ast-map/index.json";
|
|
9
|
+
function indexPath(root) {
|
|
10
|
+
return path.join(root, INDEX_FILE);
|
|
11
|
+
}
|
|
12
|
+
export function loadIndex(root) {
|
|
13
|
+
try {
|
|
14
|
+
const raw = fs.readFileSync(indexPath(root), "utf8");
|
|
15
|
+
const store = JSON.parse(raw);
|
|
16
|
+
if (store.version !== INDEX_VERSION)
|
|
17
|
+
return null;
|
|
18
|
+
return store;
|
|
19
|
+
}
|
|
20
|
+
catch {
|
|
21
|
+
return null;
|
|
22
|
+
}
|
|
23
|
+
}
|
|
24
|
+
export function saveIndex(root, store) {
|
|
25
|
+
const p = indexPath(root);
|
|
26
|
+
fs.mkdirSync(path.dirname(p), { recursive: true });
|
|
27
|
+
fs.writeFileSync(p, JSON.stringify(store, null, 2), "utf8");
|
|
28
|
+
}
|
|
29
|
+
export function hashFile(filePath) {
|
|
30
|
+
try {
|
|
31
|
+
const content = fs.readFileSync(filePath);
|
|
32
|
+
return crypto.createHash("sha1").update(content).digest("hex").slice(0, 16);
|
|
33
|
+
}
|
|
34
|
+
catch {
|
|
35
|
+
return "";
|
|
36
|
+
}
|
|
37
|
+
}
|
|
38
|
+
export function isIndexFresh(store) {
|
|
39
|
+
for (const [, entry] of Object.entries(store.entries)) {
|
|
40
|
+
const abs = path.join(store.root, entry.rel);
|
|
41
|
+
if (hashFile(abs) !== entry.hash)
|
|
42
|
+
return false;
|
|
43
|
+
}
|
|
44
|
+
return true;
|
|
45
|
+
}
|
|
46
|
+
export function getSkeletons(store, filterPrefix) {
|
|
47
|
+
const entries = Object.values(store.entries);
|
|
48
|
+
if (filterPrefix) {
|
|
49
|
+
const norm = filterPrefix.split(path.sep).join("/");
|
|
50
|
+
return entries.filter(e => e.rel.startsWith(norm)).map(e => e.skel);
|
|
51
|
+
}
|
|
52
|
+
return entries.map(e => e.skel);
|
|
53
|
+
}
|
|
54
|
+
export async function buildIndex(root, scanDir, opts) {
|
|
55
|
+
const skOpts = resolveOptions({ ...opts, detail: "outline", emitHtml: false });
|
|
56
|
+
const files = collectSourceFiles(scanDir, skOpts);
|
|
57
|
+
const items = files.map(f => ({
|
|
58
|
+
abs: f,
|
|
59
|
+
rel: path.relative(root, f).split(path.sep).join("/"),
|
|
60
|
+
}));
|
|
61
|
+
const existing = loadIndex(root);
|
|
62
|
+
const existingEntries = existing?.entries ?? {};
|
|
63
|
+
const toRebuild = [];
|
|
64
|
+
const reused = [];
|
|
65
|
+
for (const item of items) {
|
|
66
|
+
const h = hashFile(item.abs);
|
|
67
|
+
const cached = existingEntries[item.rel];
|
|
68
|
+
if (cached && cached.hash === h) {
|
|
69
|
+
reused.push(cached);
|
|
70
|
+
}
|
|
71
|
+
else {
|
|
72
|
+
toRebuild.push(item);
|
|
73
|
+
}
|
|
74
|
+
}
|
|
75
|
+
const built = await buildSkeletonsBulk(toRebuild, skOpts);
|
|
76
|
+
const entries = {};
|
|
77
|
+
for (const e of reused) {
|
|
78
|
+
entries[e.rel] = e;
|
|
79
|
+
}
|
|
80
|
+
for (let i = 0; i < toRebuild.length; i++) {
|
|
81
|
+
const r = built[i];
|
|
82
|
+
if (!r)
|
|
83
|
+
continue;
|
|
84
|
+
const rel = toRebuild[i].rel;
|
|
85
|
+
entries[rel] = {
|
|
86
|
+
rel,
|
|
87
|
+
hash: hashFile(toRebuild[i].abs),
|
|
88
|
+
builtAt: new Date().toISOString(),
|
|
89
|
+
skel: r.skel,
|
|
90
|
+
};
|
|
91
|
+
}
|
|
92
|
+
const store = {
|
|
93
|
+
version: INDEX_VERSION,
|
|
94
|
+
root,
|
|
95
|
+
scanDir,
|
|
96
|
+
builtAt: new Date().toISOString(),
|
|
97
|
+
fileCount: Object.keys(entries).length,
|
|
98
|
+
entries,
|
|
99
|
+
};
|
|
100
|
+
saveIndex(root, store);
|
|
101
|
+
return store;
|
|
102
|
+
}
|
|
103
|
+
export async function refreshIndex(root, scanDir) {
|
|
104
|
+
return buildIndex(root, scanDir);
|
|
105
|
+
}
|
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
|
+
}
|
package/dist/lsp.js
ADDED
|
@@ -0,0 +1,238 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
/**
|
|
3
|
+
* Minimal Language Server Protocol (LSP) server for ast-map.
|
|
4
|
+
* Implements JSON-RPC 2.0 over stdio without any external library.
|
|
5
|
+
*
|
|
6
|
+
* Capabilities:
|
|
7
|
+
* - textDocument/publishDiagnostics (dead exports + security issues)
|
|
8
|
+
* - textDocument/codeLens (cyclomatic complexity per function/class)
|
|
9
|
+
* - textDocument/hover (symbol kind, complexity, line count)
|
|
10
|
+
*
|
|
11
|
+
* Invocation: node dist/lsp.js
|
|
12
|
+
* The VS Code extension (or any LSP client) starts this as a child process.
|
|
13
|
+
*/
|
|
14
|
+
import fs from "node:fs";
|
|
15
|
+
import path from "node:path";
|
|
16
|
+
import { buildSkeleton, collectSourceFiles } from "./skeleton.js";
|
|
17
|
+
import { resolveOptions, loadProjectConfig } from "./config.js";
|
|
18
|
+
import { initDiskCache, defaultCacheDir } from "./diskcache.js";
|
|
19
|
+
import { buildSymbolGraph } from "./graph.js";
|
|
20
|
+
import { findDeadExports } from "./graph-analysis.js";
|
|
21
|
+
import { computeFileComplexity } from "./complexity.js";
|
|
22
|
+
import { scanFileForSecurityIssues } from "./security.js";
|
|
23
|
+
import { detectSmells } from "./smells.js";
|
|
24
|
+
import { parseRootsFromEnv } from "./roots.js";
|
|
25
|
+
const ROOTS = parseRootsFromEnv();
|
|
26
|
+
const ROOT = ROOTS.roots[0];
|
|
27
|
+
if (process.env.AST_MAP_NO_CACHE !== "1" && loadProjectConfig(ROOT).cache !== false) {
|
|
28
|
+
initDiskCache(defaultCacheDir(ROOT));
|
|
29
|
+
}
|
|
30
|
+
function sendRaw(obj) {
|
|
31
|
+
const body = JSON.stringify(obj);
|
|
32
|
+
process.stdout.write(`Content-Length: ${Buffer.byteLength(body, "utf8")}\r\n\r\n${body}`);
|
|
33
|
+
}
|
|
34
|
+
function respond(id, result) {
|
|
35
|
+
sendRaw({ jsonrpc: "2.0", id, result });
|
|
36
|
+
}
|
|
37
|
+
function notify(method, params) {
|
|
38
|
+
sendRaw({ jsonrpc: "2.0", method, params });
|
|
39
|
+
}
|
|
40
|
+
function respondError(id, code, message) {
|
|
41
|
+
sendRaw({ jsonrpc: "2.0", id: id ?? null, error: { code, message } });
|
|
42
|
+
}
|
|
43
|
+
// ─── LSP message reader ───────────────────────────────────────────────────────
|
|
44
|
+
let buffer = Buffer.alloc(0);
|
|
45
|
+
process.stdin.on("data", (chunk) => {
|
|
46
|
+
buffer = Buffer.concat([buffer, chunk]);
|
|
47
|
+
processBuffer();
|
|
48
|
+
});
|
|
49
|
+
function processBuffer() {
|
|
50
|
+
while (true) {
|
|
51
|
+
const headerEnd = buffer.indexOf("\r\n\r\n");
|
|
52
|
+
if (headerEnd === -1)
|
|
53
|
+
break;
|
|
54
|
+
const headerStr = buffer.slice(0, headerEnd).toString("utf8");
|
|
55
|
+
const match = /Content-Length:\s*(\d+)/i.exec(headerStr);
|
|
56
|
+
if (!match) {
|
|
57
|
+
buffer = buffer.slice(headerEnd + 4);
|
|
58
|
+
continue;
|
|
59
|
+
}
|
|
60
|
+
const contentLength = parseInt(match[1], 10);
|
|
61
|
+
const start = headerEnd + 4;
|
|
62
|
+
if (buffer.length < start + contentLength)
|
|
63
|
+
break;
|
|
64
|
+
const body = buffer.slice(start, start + contentLength).toString("utf8");
|
|
65
|
+
buffer = buffer.slice(start + contentLength);
|
|
66
|
+
try {
|
|
67
|
+
const msg = JSON.parse(body);
|
|
68
|
+
void handleMessage(msg);
|
|
69
|
+
}
|
|
70
|
+
catch { /* malformed JSON */ }
|
|
71
|
+
}
|
|
72
|
+
}
|
|
73
|
+
async function computeDiagnostics(fileUri) {
|
|
74
|
+
const filePath = uriToPath(fileUri);
|
|
75
|
+
const rel = path.relative(ROOT, filePath).split(path.sep).join("/");
|
|
76
|
+
const diags = [];
|
|
77
|
+
try {
|
|
78
|
+
const source = fs.readFileSync(filePath, "utf8");
|
|
79
|
+
// Security issues
|
|
80
|
+
const issues = scanFileForSecurityIssues(source, rel);
|
|
81
|
+
for (const issue of issues) {
|
|
82
|
+
const line = Math.max(0, issue.line - 1);
|
|
83
|
+
diags.push({
|
|
84
|
+
range: { start: { line, character: 0 }, end: { line, character: 999 } },
|
|
85
|
+
severity: ["critical", "high"].includes(issue.severity) ? 1 : 2,
|
|
86
|
+
source: "ast-map",
|
|
87
|
+
message: `[${issue.severity.toUpperCase()}] ${issue.message}`,
|
|
88
|
+
code: issue.rule,
|
|
89
|
+
});
|
|
90
|
+
}
|
|
91
|
+
// Smells
|
|
92
|
+
const opts = resolveOptions({ detail: "full", emitHtml: false });
|
|
93
|
+
const skel = await buildSkeleton(filePath, rel, opts);
|
|
94
|
+
const lineCount = source.split("\n").length;
|
|
95
|
+
const smells = detectSmells(skel, lineCount);
|
|
96
|
+
for (const smell of smells) {
|
|
97
|
+
const line = Math.max(0, (smell.line ?? 1) - 1);
|
|
98
|
+
diags.push({
|
|
99
|
+
range: { start: { line, character: 0 }, end: { line, character: 999 } },
|
|
100
|
+
severity: smell.severity === "warning" ? 2 : 3,
|
|
101
|
+
source: "ast-map",
|
|
102
|
+
message: smell.symbol ? `[${smell.smell}] ${smell.symbol}: ${smell.message}` : `[${smell.smell}] ${smell.message}`,
|
|
103
|
+
code: smell.smell,
|
|
104
|
+
});
|
|
105
|
+
}
|
|
106
|
+
// Dead exports (scan directory containing the file)
|
|
107
|
+
try {
|
|
108
|
+
const dir = path.dirname(filePath);
|
|
109
|
+
const skOpts = resolveOptions({ detail: "outline", emitHtml: false });
|
|
110
|
+
const files = collectSourceFiles(dir, skOpts);
|
|
111
|
+
const skels = await Promise.all(files.map(async (f) => {
|
|
112
|
+
const r = path.relative(ROOT, f).split(path.sep).join("/");
|
|
113
|
+
try {
|
|
114
|
+
return await buildSkeleton(f, r, skOpts);
|
|
115
|
+
}
|
|
116
|
+
catch {
|
|
117
|
+
return null;
|
|
118
|
+
}
|
|
119
|
+
}));
|
|
120
|
+
const graph = buildSymbolGraph(skels.filter(Boolean), ROOT);
|
|
121
|
+
const dead = findDeadExports(graph).filter((d) => d.file === rel && d.confidence === "high");
|
|
122
|
+
for (const d of dead) {
|
|
123
|
+
const line = 0; // DeadExport has no line number; mark at file start
|
|
124
|
+
diags.push({
|
|
125
|
+
range: { start: { line, character: 0 }, end: { line, character: 999 } },
|
|
126
|
+
severity: 2,
|
|
127
|
+
source: "ast-map",
|
|
128
|
+
message: `Dead export: "${d.symbol}" (${d.kind}) is never imported within the scanned directory.`,
|
|
129
|
+
code: "dead-export",
|
|
130
|
+
});
|
|
131
|
+
}
|
|
132
|
+
}
|
|
133
|
+
catch { /* dead export scan optional */ }
|
|
134
|
+
}
|
|
135
|
+
catch { /* file unreadable */ }
|
|
136
|
+
return diags;
|
|
137
|
+
}
|
|
138
|
+
async function computeCodeLenses(fileUri) {
|
|
139
|
+
const filePath = uriToPath(fileUri);
|
|
140
|
+
const rel = path.relative(ROOT, filePath).split(path.sep).join("/");
|
|
141
|
+
try {
|
|
142
|
+
const cx = await computeFileComplexity(filePath, rel);
|
|
143
|
+
if (!cx)
|
|
144
|
+
return [];
|
|
145
|
+
return cx.functions.map((fn) => {
|
|
146
|
+
const line = Math.max(0, fn.startLine - 1);
|
|
147
|
+
const icon = fn.complexity >= 20 ? "🔴" : fn.complexity >= 10 ? "🟡" : "✦";
|
|
148
|
+
return {
|
|
149
|
+
range: { start: { line, character: 0 }, end: { line, character: 0 } },
|
|
150
|
+
command: {
|
|
151
|
+
title: `${icon} Complexity: ${fn.complexity} (${fn.rating})`,
|
|
152
|
+
command: "",
|
|
153
|
+
},
|
|
154
|
+
};
|
|
155
|
+
});
|
|
156
|
+
}
|
|
157
|
+
catch {
|
|
158
|
+
return [];
|
|
159
|
+
}
|
|
160
|
+
}
|
|
161
|
+
// ─── URI helpers ─────────────────────────────────────────────────────────────
|
|
162
|
+
function uriToPath(uri) {
|
|
163
|
+
return decodeURIComponent(uri.replace(/^file:\/\//, "").replace(/^\/([A-Za-z]):/, "$1:"));
|
|
164
|
+
}
|
|
165
|
+
function pathToUri(p) {
|
|
166
|
+
return "file://" + p.split(path.sep).join("/");
|
|
167
|
+
}
|
|
168
|
+
// ─── Supported languages ─────────────────────────────────────────────────────
|
|
169
|
+
const SUPPORTED_EXTS = new Set([".ts", ".tsx", ".js", ".jsx", ".mjs", ".cjs", ".py", ".go", ".java", ".rb"]);
|
|
170
|
+
function isSupported(uri) {
|
|
171
|
+
return SUPPORTED_EXTS.has(path.extname(uriToPath(uri)));
|
|
172
|
+
}
|
|
173
|
+
// ─── Message router ───────────────────────────────────────────────────────────
|
|
174
|
+
const CAPABILITIES = {
|
|
175
|
+
textDocumentSync: 2, // incremental
|
|
176
|
+
codeLensProvider: { resolveProvider: false },
|
|
177
|
+
hoverProvider: false,
|
|
178
|
+
diagnosticProvider: { interFileDependencies: false, workspaceDiagnostics: false },
|
|
179
|
+
};
|
|
180
|
+
async function handleMessage(msg) {
|
|
181
|
+
const { method, id, params } = msg;
|
|
182
|
+
if (method === "initialize") {
|
|
183
|
+
respond(id ?? null, {
|
|
184
|
+
capabilities: CAPABILITIES,
|
|
185
|
+
serverInfo: { name: "ast-map-lsp", version: "1.33.0" },
|
|
186
|
+
});
|
|
187
|
+
return;
|
|
188
|
+
}
|
|
189
|
+
if (method === "initialized") {
|
|
190
|
+
// Push diagnostics for already-open files (none tracked yet at startup)
|
|
191
|
+
return;
|
|
192
|
+
}
|
|
193
|
+
if (method === "shutdown") {
|
|
194
|
+
respond(id ?? null, null);
|
|
195
|
+
return;
|
|
196
|
+
}
|
|
197
|
+
if (method === "exit") {
|
|
198
|
+
process.exit(0);
|
|
199
|
+
}
|
|
200
|
+
if (method === "textDocument/didOpen") {
|
|
201
|
+
const p = params;
|
|
202
|
+
if (isSupported(p.textDocument.uri)) {
|
|
203
|
+
const diags = await computeDiagnostics(p.textDocument.uri);
|
|
204
|
+
notify("textDocument/publishDiagnostics", { uri: p.textDocument.uri, diagnostics: diags });
|
|
205
|
+
}
|
|
206
|
+
return;
|
|
207
|
+
}
|
|
208
|
+
if (method === "textDocument/didSave") {
|
|
209
|
+
const p = params;
|
|
210
|
+
if (isSupported(p.textDocument.uri)) {
|
|
211
|
+
const diags = await computeDiagnostics(p.textDocument.uri);
|
|
212
|
+
notify("textDocument/publishDiagnostics", { uri: p.textDocument.uri, diagnostics: diags });
|
|
213
|
+
}
|
|
214
|
+
return;
|
|
215
|
+
}
|
|
216
|
+
if (method === "textDocument/didClose") {
|
|
217
|
+
const p = params;
|
|
218
|
+
notify("textDocument/publishDiagnostics", { uri: p.textDocument.uri, diagnostics: [] });
|
|
219
|
+
return;
|
|
220
|
+
}
|
|
221
|
+
if (method === "textDocument/codeLens") {
|
|
222
|
+
const p = params;
|
|
223
|
+
if (!isSupported(p.textDocument.uri)) {
|
|
224
|
+
respond(id ?? null, []);
|
|
225
|
+
return;
|
|
226
|
+
}
|
|
227
|
+
const lenses = await computeCodeLenses(p.textDocument.uri);
|
|
228
|
+
respond(id ?? null, lenses);
|
|
229
|
+
return;
|
|
230
|
+
}
|
|
231
|
+
// Unknown method — return null for requests, ignore notifications
|
|
232
|
+
if (id !== undefined && id !== null) {
|
|
233
|
+
respondError(id, -32601, `Method not found: ${method}`);
|
|
234
|
+
}
|
|
235
|
+
}
|
|
236
|
+
process.on("SIGTERM", () => process.exit(0));
|
|
237
|
+
process.on("SIGINT", () => process.exit(0));
|
|
238
|
+
process.stderr.write(`ast-map LSP server started. root=${ROOT}\n`);
|
|
Binary file
|
package/dist/parser.js
ADDED
|
@@ -0,0 +1,84 @@
|
|
|
1
|
+
import Parser from "web-tree-sitter";
|
|
2
|
+
import { createRequire } from "node:module";
|
|
3
|
+
import path from "node:path";
|
|
4
|
+
const require = createRequire(import.meta.url);
|
|
5
|
+
// `web-tree-sitter` 0.20.x ships a CommonJS class with static members that are
|
|
6
|
+
// only populated after `init()`. We treat the static surface loosely.
|
|
7
|
+
const P = Parser;
|
|
8
|
+
let initPromise = null;
|
|
9
|
+
const languageCache = new Map();
|
|
10
|
+
const parserCache = new Map();
|
|
11
|
+
function pkgDir(spec) {
|
|
12
|
+
return path.dirname(require.resolve(spec));
|
|
13
|
+
}
|
|
14
|
+
function grammarWasmPath(grammar) {
|
|
15
|
+
return path.join(pkgDir("tree-sitter-wasms/package.json"), "out", `tree-sitter-${grammar}.wasm`);
|
|
16
|
+
}
|
|
17
|
+
export async function initParser() {
|
|
18
|
+
if (!initPromise) {
|
|
19
|
+
const coreDir = pkgDir("web-tree-sitter/package.json");
|
|
20
|
+
initPromise = P.init({
|
|
21
|
+
locateFile(name) {
|
|
22
|
+
// The runtime requests "tree-sitter.wasm"; serve it from the package dir.
|
|
23
|
+
return path.join(coreDir, name);
|
|
24
|
+
},
|
|
25
|
+
});
|
|
26
|
+
}
|
|
27
|
+
return initPromise;
|
|
28
|
+
}
|
|
29
|
+
async function loadLanguage(grammar) {
|
|
30
|
+
await initParser();
|
|
31
|
+
const cached = languageCache.get(grammar);
|
|
32
|
+
if (cached)
|
|
33
|
+
return cached;
|
|
34
|
+
const lang = await P.Language.load(grammarWasmPath(grammar));
|
|
35
|
+
languageCache.set(grammar, lang);
|
|
36
|
+
return lang;
|
|
37
|
+
}
|
|
38
|
+
/** Parse source code with the given grammar and return the root node. */
|
|
39
|
+
export async function parseSource(grammar, source) {
|
|
40
|
+
const lang = await loadLanguage(grammar);
|
|
41
|
+
let parser = parserCache.get(grammar);
|
|
42
|
+
if (!parser) {
|
|
43
|
+
parser = new P();
|
|
44
|
+
parser.setLanguage(lang);
|
|
45
|
+
parserCache.set(grammar, parser);
|
|
46
|
+
}
|
|
47
|
+
return parser.parse(source).rootNode;
|
|
48
|
+
}
|
|
49
|
+
/* ----------------------------- node helpers ----------------------------- */
|
|
50
|
+
export function namedChildren(node) {
|
|
51
|
+
const out = [];
|
|
52
|
+
for (let i = 0; i < node.namedChildCount; i++) {
|
|
53
|
+
const c = node.namedChild(i);
|
|
54
|
+
if (c)
|
|
55
|
+
out.push(c);
|
|
56
|
+
}
|
|
57
|
+
return out;
|
|
58
|
+
}
|
|
59
|
+
export function nameOf(node) {
|
|
60
|
+
const n = node.childForFieldName("name");
|
|
61
|
+
return n ? n.text : null;
|
|
62
|
+
}
|
|
63
|
+
/**
|
|
64
|
+
* Build a one-line "header" signature: text from the node start up to the
|
|
65
|
+
* start of its body (or the whole node if there is no body), whitespace
|
|
66
|
+
* collapsed. Works uniformly across languages.
|
|
67
|
+
*/
|
|
68
|
+
export function headerSignature(node, body) {
|
|
69
|
+
const src = node.text;
|
|
70
|
+
const slice = body ? src.slice(0, body.startIndex - node.startIndex) : src;
|
|
71
|
+
return slice.replace(/\s+/g, " ").trim();
|
|
72
|
+
}
|
|
73
|
+
/** Collect consecutive leading line/block comments immediately above a node. */
|
|
74
|
+
export function leadingComment(node) {
|
|
75
|
+
const lines = [];
|
|
76
|
+
let prev = node.previousNamedSibling;
|
|
77
|
+
while (prev && prev.type === "comment") {
|
|
78
|
+
lines.unshift(prev.text);
|
|
79
|
+
prev = prev.previousNamedSibling;
|
|
80
|
+
}
|
|
81
|
+
if (lines.length === 0)
|
|
82
|
+
return null;
|
|
83
|
+
return lines.join("\n").slice(0, 500);
|
|
84
|
+
}
|
package/dist/patch.js
ADDED
|
@@ -0,0 +1,199 @@
|
|
|
1
|
+
import https from "node:https";
|
|
2
|
+
import readline from "node:readline";
|
|
3
|
+
import fs from "node:fs";
|
|
4
|
+
// ─── ANSI ─────────────────────────────────────────────────────────────────────
|
|
5
|
+
const tty = process.stdout.isTTY ?? false;
|
|
6
|
+
const esc = (code) => (s) => tty ? `\x1b[${code}m${s}\x1b[0m` : s;
|
|
7
|
+
const red = esc("31");
|
|
8
|
+
const green = esc("32");
|
|
9
|
+
const dim = esc("2");
|
|
10
|
+
// ─── Colored unified diff ─────────────────────────────────────────────────────
|
|
11
|
+
function coloredDiff(before, after) {
|
|
12
|
+
const beforeLines = before.split("\n");
|
|
13
|
+
const afterLines = after.split("\n");
|
|
14
|
+
const lines = [dim("--- before"), dim("+++ after")];
|
|
15
|
+
const max = Math.max(beforeLines.length, afterLines.length);
|
|
16
|
+
for (let i = 0; i < max; i++) {
|
|
17
|
+
if (i < beforeLines.length && i < afterLines.length) {
|
|
18
|
+
if (beforeLines[i] !== afterLines[i]) {
|
|
19
|
+
lines.push(red("- " + beforeLines[i]));
|
|
20
|
+
lines.push(green("+ " + afterLines[i]));
|
|
21
|
+
}
|
|
22
|
+
else {
|
|
23
|
+
lines.push(dim(" " + beforeLines[i]));
|
|
24
|
+
}
|
|
25
|
+
}
|
|
26
|
+
else if (i < beforeLines.length) {
|
|
27
|
+
lines.push(red("- " + beforeLines[i]));
|
|
28
|
+
}
|
|
29
|
+
else {
|
|
30
|
+
lines.push(green("+ " + afterLines[i]));
|
|
31
|
+
}
|
|
32
|
+
}
|
|
33
|
+
return lines.join("\n");
|
|
34
|
+
}
|
|
35
|
+
// ─── Claude API ───────────────────────────────────────────────────────────────
|
|
36
|
+
async function callClaude(prompt, opts) {
|
|
37
|
+
const apiKey = opts.apiKey ?? process.env.ANTHROPIC_API_KEY;
|
|
38
|
+
if (!apiKey)
|
|
39
|
+
throw new Error("No Anthropic API key — set ANTHROPIC_API_KEY or pass --api-key");
|
|
40
|
+
const body = JSON.stringify({
|
|
41
|
+
model: opts.model ?? "claude-sonnet-4-6",
|
|
42
|
+
max_tokens: opts.maxTokens ?? 4096,
|
|
43
|
+
messages: [{ role: "user", content: prompt }],
|
|
44
|
+
});
|
|
45
|
+
return new Promise((resolve, reject) => {
|
|
46
|
+
const req = https.request({
|
|
47
|
+
hostname: "api.anthropic.com",
|
|
48
|
+
path: "/v1/messages",
|
|
49
|
+
method: "POST",
|
|
50
|
+
headers: {
|
|
51
|
+
"content-type": "application/json",
|
|
52
|
+
"x-api-key": apiKey,
|
|
53
|
+
"anthropic-version": "2023-06-01",
|
|
54
|
+
"content-length": Buffer.byteLength(body),
|
|
55
|
+
},
|
|
56
|
+
}, (res) => {
|
|
57
|
+
const chunks = [];
|
|
58
|
+
res.on("data", (c) => chunks.push(c));
|
|
59
|
+
res.on("end", () => {
|
|
60
|
+
const raw = Buffer.concat(chunks).toString("utf8");
|
|
61
|
+
try {
|
|
62
|
+
const parsed = JSON.parse(raw);
|
|
63
|
+
if (parsed.error)
|
|
64
|
+
reject(new Error(`Anthropic API: ${parsed.error.message}`));
|
|
65
|
+
else
|
|
66
|
+
resolve(parsed.content?.[0]?.text ?? "");
|
|
67
|
+
}
|
|
68
|
+
catch {
|
|
69
|
+
reject(new Error("Unexpected API response"));
|
|
70
|
+
}
|
|
71
|
+
});
|
|
72
|
+
});
|
|
73
|
+
req.on("error", reject);
|
|
74
|
+
req.write(body);
|
|
75
|
+
req.end();
|
|
76
|
+
});
|
|
77
|
+
}
|
|
78
|
+
// ─── Prompt ───────────────────────────────────────────────────────────────────
|
|
79
|
+
function buildPatchPrompt(issue) {
|
|
80
|
+
const langFence = issue.language === "typescript" ? "ts" : issue.language === "javascript" ? "js" : issue.language;
|
|
81
|
+
let issueDesc;
|
|
82
|
+
if (issue.kind === "smell" && issue.smell) {
|
|
83
|
+
issueDesc = `Code smell: **${issue.smell.smell}**\nMessage: ${issue.smell.message}\nFile: ${issue.filePath}${issue.smell.line ? `, line ${issue.smell.line}` : ""}`;
|
|
84
|
+
}
|
|
85
|
+
else if (issue.kind === "security" && issue.security) {
|
|
86
|
+
issueDesc = `Security issue: **${issue.security.rule}** (${issue.security.severity})\nMessage: ${issue.security.message}\nFile: ${issue.filePath}, line ${issue.security.line}\nSnippet: \`${issue.security.snippet}\``;
|
|
87
|
+
}
|
|
88
|
+
else {
|
|
89
|
+
issueDesc = "Unknown issue";
|
|
90
|
+
}
|
|
91
|
+
return `You are an expert ${issue.language} developer fixing a code issue.
|
|
92
|
+
|
|
93
|
+
## Issue
|
|
94
|
+
${issueDesc}
|
|
95
|
+
|
|
96
|
+
## Source file
|
|
97
|
+
\`\`\`${langFence}
|
|
98
|
+
${issue.sourceCode}
|
|
99
|
+
\`\`\`
|
|
100
|
+
|
|
101
|
+
## Your task
|
|
102
|
+
Fix the issue with the minimal change needed (not the whole file unless necessary).
|
|
103
|
+
|
|
104
|
+
Format your response EXACTLY as:
|
|
105
|
+
<before>
|
|
106
|
+
// original code block
|
|
107
|
+
</before>
|
|
108
|
+
<after>
|
|
109
|
+
// fixed code block
|
|
110
|
+
</after>
|
|
111
|
+
<explanation>
|
|
112
|
+
One paragraph explanation of the fix.
|
|
113
|
+
</explanation>`;
|
|
114
|
+
}
|
|
115
|
+
function parseResponse(raw) {
|
|
116
|
+
const extract = (tag) => {
|
|
117
|
+
const m = raw.match(new RegExp(`<${tag}>([\\s\\S]*?)<\\/${tag}>`));
|
|
118
|
+
return m ? m[1].trim() : "";
|
|
119
|
+
};
|
|
120
|
+
return { before: extract("before"), after: extract("after"), explanation: extract("explanation") };
|
|
121
|
+
}
|
|
122
|
+
function issueLabel(issue) {
|
|
123
|
+
if (issue.kind === "smell" && issue.smell) {
|
|
124
|
+
return issue.smell.smell + (issue.smell.symbol ? `: ${issue.smell.symbol}` : "");
|
|
125
|
+
}
|
|
126
|
+
if (issue.kind === "security" && issue.security) {
|
|
127
|
+
return `${issue.security.rule} (${issue.security.severity})`;
|
|
128
|
+
}
|
|
129
|
+
return "issue";
|
|
130
|
+
}
|
|
131
|
+
// ─── Interactive y/n ──────────────────────────────────────────────────────────
|
|
132
|
+
async function askYesNo(question) {
|
|
133
|
+
if (!process.stdin.isTTY)
|
|
134
|
+
return false;
|
|
135
|
+
const rl = readline.createInterface({ input: process.stdin, output: process.stdout });
|
|
136
|
+
return new Promise((resolve) => {
|
|
137
|
+
rl.question(question + " [y/N] ", (ans) => {
|
|
138
|
+
rl.close();
|
|
139
|
+
resolve(ans.trim().toLowerCase() === "y");
|
|
140
|
+
});
|
|
141
|
+
});
|
|
142
|
+
}
|
|
143
|
+
// ─── Public API ───────────────────────────────────────────────────────────────
|
|
144
|
+
export async function generatePatch(issue, opts = {}) {
|
|
145
|
+
const label = issueLabel(issue);
|
|
146
|
+
try {
|
|
147
|
+
const raw = await callClaude(buildPatchPrompt(issue), opts);
|
|
148
|
+
const { before, after, explanation } = parseResponse(raw);
|
|
149
|
+
return { filePath: issue.filePath, issue: label, before, after, explanation, applied: false };
|
|
150
|
+
}
|
|
151
|
+
catch (e) {
|
|
152
|
+
return { filePath: issue.filePath, issue: label, before: "", after: "", explanation: "", applied: false, error: e instanceof Error ? e.message : String(e) };
|
|
153
|
+
}
|
|
154
|
+
}
|
|
155
|
+
export async function interactivePatch(issues, opts = {}) {
|
|
156
|
+
const results = [];
|
|
157
|
+
for (const issue of issues) {
|
|
158
|
+
console.log(`\n${dim("─────────────────────────────────────────────")}`);
|
|
159
|
+
console.log(`${dim(issue.filePath)} ${issueLabel(issue)}`);
|
|
160
|
+
console.log(dim("Generating patch…"));
|
|
161
|
+
const result = await generatePatch(issue, opts);
|
|
162
|
+
if (result.error) {
|
|
163
|
+
console.error(` Error: ${result.error}`);
|
|
164
|
+
results.push(result);
|
|
165
|
+
continue;
|
|
166
|
+
}
|
|
167
|
+
if (!result.before || !result.after) {
|
|
168
|
+
console.log(dim(" (no diff produced)"));
|
|
169
|
+
results.push(result);
|
|
170
|
+
continue;
|
|
171
|
+
}
|
|
172
|
+
console.log(coloredDiff(result.before, result.after));
|
|
173
|
+
console.log(dim(`\n ${result.explanation}`));
|
|
174
|
+
let apply = opts.yes ?? false;
|
|
175
|
+
if (!apply) {
|
|
176
|
+
apply = await askYesNo(` Apply this patch to ${issue.filePath}?`);
|
|
177
|
+
}
|
|
178
|
+
if (apply) {
|
|
179
|
+
try {
|
|
180
|
+
const src = fs.readFileSync(issue.filePath, "utf8");
|
|
181
|
+
const patched = src.replace(result.before, result.after);
|
|
182
|
+
if (patched === src) {
|
|
183
|
+
console.log(dim(" (patch did not change file — before block not found verbatim)"));
|
|
184
|
+
}
|
|
185
|
+
else {
|
|
186
|
+
fs.writeFileSync(issue.filePath, patched, "utf8");
|
|
187
|
+
console.log(`${green("✓")} Applied patch to ${issue.filePath}`);
|
|
188
|
+
results.push({ ...result, applied: true });
|
|
189
|
+
continue;
|
|
190
|
+
}
|
|
191
|
+
}
|
|
192
|
+
catch (e) {
|
|
193
|
+
console.error(` Failed to apply: ${e instanceof Error ? e.message : String(e)}`);
|
|
194
|
+
}
|
|
195
|
+
}
|
|
196
|
+
results.push(result);
|
|
197
|
+
}
|
|
198
|
+
return results;
|
|
199
|
+
}
|