universal-ast-mapper 1.28.0 → 2.0.1

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/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`);
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
+ }
@@ -0,0 +1,88 @@
1
+ import fs from "node:fs";
2
+ import path from "node:path";
3
+ // ─── Loader ───────────────────────────────────────────────────────────────────
4
+ const PLUGINS_DIR = ".ast-map/plugins";
5
+ /** Load all `.mjs` / `.js` plugin files from `<root>/.ast-map/plugins/`. */
6
+ export async function loadPlugins(root) {
7
+ const dir = path.join(root, PLUGINS_DIR);
8
+ if (!fs.existsSync(dir))
9
+ return [];
10
+ const files = fs.readdirSync(dir).filter((f) => f.endsWith(".mjs") || f.endsWith(".js"));
11
+ const plugins = [];
12
+ for (const file of files) {
13
+ const abs = path.resolve(dir, file);
14
+ try {
15
+ const mod = await import(abs);
16
+ const plugin = "default" in mod && isPlugin(mod.default) ? mod.default
17
+ : "plugin" in mod && isPlugin(mod.plugin) ? mod.plugin
18
+ : isPlugin(mod) ? mod
19
+ : undefined;
20
+ if (plugin)
21
+ plugins.push(plugin);
22
+ else
23
+ process.stderr.write(`[ast-map plugins] ${file}: no valid default/plugin export\n`);
24
+ }
25
+ catch (e) {
26
+ process.stderr.write(`[ast-map plugins] failed to load ${file}: ${e instanceof Error ? e.message : String(e)}\n`);
27
+ }
28
+ }
29
+ return plugins;
30
+ }
31
+ function isPlugin(v) {
32
+ return typeof v === "object" && v !== null && "id" in v && "run" in v && typeof v.run === "function";
33
+ }
34
+ /** Run all loaded plugins against the given skeletons. */
35
+ export async function runPlugins(plugins, ctx) {
36
+ const results = [];
37
+ for (const plugin of plugins) {
38
+ try {
39
+ const violations = await plugin.run(ctx);
40
+ results.push({ pluginId: plugin.id, description: plugin.description, violations });
41
+ }
42
+ catch (e) {
43
+ results.push({
44
+ pluginId: plugin.id,
45
+ description: plugin.description,
46
+ violations: [],
47
+ error: e instanceof Error ? e.message : String(e),
48
+ });
49
+ }
50
+ }
51
+ return results;
52
+ }
53
+ // ─── Example plugin scaffolding (written to disk by ast-map init) ─────────────
54
+ export const EXAMPLE_PLUGIN = `/**
55
+ * Example ast-map plugin: no-console-in-lib
56
+ *
57
+ * Reports any "console.log/warn/error" usage in library source files.
58
+ * Adapt the logic to define your own custom rules.
59
+ *
60
+ * Export your plugin as the default export.
61
+ * @type {import('universal-ast-mapper').AstMapPlugin}
62
+ */
63
+ export default {
64
+ id: "no-console-in-lib",
65
+ description: "Disallow console.* calls in library code",
66
+
67
+ run({ skeletons }) {
68
+ const violations = [];
69
+ for (const skel of skeletons) {
70
+ // Only apply to files under src/ (adjust as needed)
71
+ if (!skel.file.startsWith("src/")) continue;
72
+ for (const sym of skel.symbols) {
73
+ if (sym.name.startsWith("console")) {
74
+ violations.push({
75
+ rule: "no-console-in-lib",
76
+ file: skel.file,
77
+ line: sym.range?.startLine,
78
+ symbol: sym.name,
79
+ severity: "warning",
80
+ message: \`console.\${sym.name} should not be used in library code\`,
81
+ });
82
+ }
83
+ }
84
+ }
85
+ return violations;
86
+ },
87
+ };
88
+ `;