universal-ast-mapper 0.5.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/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
+ }
@@ -0,0 +1,40 @@
1
+ import path from "node:path";
2
+ import { extractTypeScript, extractDirectivesTS, extractImportsTS } from "./extractors/typescript.js";
3
+ import { extractPython, extractImportsPython } from "./extractors/python.js";
4
+ import { extractGo, extractImportsGo } from "./extractors/go.js";
5
+ const TS_ENTRY = (language, grammar) => ({
6
+ language,
7
+ grammar,
8
+ extract: extractTypeScript,
9
+ extractDirectives: extractDirectivesTS,
10
+ extractImports: extractImportsTS,
11
+ });
12
+ /** Map of file extension -> language entry. The Factory/Strategy registry. */
13
+ const BY_EXT = {
14
+ ".ts": TS_ENTRY("typescript", "typescript"),
15
+ ".mts": TS_ENTRY("typescript", "typescript"),
16
+ ".cts": TS_ENTRY("typescript", "typescript"),
17
+ ".tsx": TS_ENTRY("tsx", "tsx"),
18
+ ".js": TS_ENTRY("javascript", "javascript"),
19
+ ".mjs": TS_ENTRY("javascript", "javascript"),
20
+ ".cjs": TS_ENTRY("javascript", "javascript"),
21
+ ".jsx": TS_ENTRY("javascript", "tsx"),
22
+ ".py": { language: "python", grammar: "python", extract: extractPython, extractImports: extractImportsPython },
23
+ ".pyi": { language: "python", grammar: "python", extract: extractPython, extractImports: extractImportsPython },
24
+ ".go": { language: "go", grammar: "go", extract: extractGo, extractImports: extractImportsGo },
25
+ };
26
+ export function detectLanguage(filePath) {
27
+ return BY_EXT[path.extname(filePath).toLowerCase()] ?? null;
28
+ }
29
+ export function supportedExtensions() {
30
+ return Object.keys(BY_EXT);
31
+ }
32
+ export function supportedLanguages() {
33
+ const map = new Map();
34
+ for (const [ext, entry] of Object.entries(BY_EXT)) {
35
+ const arr = map.get(entry.language) ?? [];
36
+ arr.push(ext);
37
+ map.set(entry.language, arr);
38
+ }
39
+ return [...map.entries()].map(([language, extensions]) => ({ language, extensions }));
40
+ }
@@ -0,0 +1,131 @@
1
+ import fs from "node:fs";
2
+ import path from "node:path";
3
+ import { buildSkeleton } from "./skeleton.js";
4
+ import { resolveOptions } from "./config.js";
5
+ import { findSymbol } from "./analysis.js";
6
+ const SRC_EXTS = [".ts", ".tsx", ".js", ".jsx", ".mts", ".cts", ".mjs", ".cjs"];
7
+ /** Extract the outermost balanced parenthesised group from a signature string. */
8
+ function extractParams(sig) {
9
+ const start = sig.indexOf("(");
10
+ if (start === -1)
11
+ return null;
12
+ let depth = 0;
13
+ for (let i = start; i < sig.length; i++) {
14
+ if (sig[i] === "(")
15
+ depth++;
16
+ else if (sig[i] === ")") {
17
+ depth--;
18
+ if (depth === 0)
19
+ return sig.slice(start, i + 1);
20
+ }
21
+ }
22
+ return null;
23
+ }
24
+ // TypeScript ESM: `import from "./foo.js"` actually means `./foo.ts` on disk.
25
+ // Map each JS-family extension to the TS-family equivalents we should try first.
26
+ const JS_TO_TS = {
27
+ ".js": [".ts", ".tsx", ".js"],
28
+ ".jsx": [".tsx", ".jsx"],
29
+ ".mjs": [".mts", ".mjs"],
30
+ ".cjs": [".cts", ".cjs"],
31
+ };
32
+ /**
33
+ * Resolve a relative import path from a source file to an absolute path on disk.
34
+ * Handles TypeScript ESM convention (`.js` in source → `.ts` on disk).
35
+ * Returns null for external packages or when the file cannot be found.
36
+ */
37
+ export function resolveImportPath(importFrom, fromAbs) {
38
+ if (!importFrom.startsWith("."))
39
+ return null;
40
+ const fromDir = path.dirname(fromAbs);
41
+ const candidate = path.resolve(fromDir, importFrom);
42
+ const declaredExt = path.extname(candidate).toLowerCase();
43
+ // If the import has a JS-family extension, try the TS equivalents first
44
+ if (declaredExt && JS_TO_TS[declaredExt]) {
45
+ const base = candidate.slice(0, candidate.length - declaredExt.length);
46
+ for (const ext of JS_TO_TS[declaredExt]) {
47
+ const p = base + ext;
48
+ if (fs.existsSync(p))
49
+ return p;
50
+ }
51
+ }
52
+ // Exact match (already has extension or points to a file)
53
+ try {
54
+ const stat = fs.statSync(candidate);
55
+ if (stat.isFile())
56
+ return candidate;
57
+ }
58
+ catch {
59
+ // not found — try with extensions
60
+ }
61
+ // Try appending source extensions
62
+ for (const ext of SRC_EXTS) {
63
+ const p = candidate + ext;
64
+ if (fs.existsSync(p))
65
+ return p;
66
+ }
67
+ // Try index file inside the directory
68
+ for (const ext of SRC_EXTS) {
69
+ const p = path.join(candidate, `index${ext}`);
70
+ if (fs.existsSync(p))
71
+ return p;
72
+ }
73
+ return null;
74
+ }
75
+ /**
76
+ * For each import in `skel`, resolve the target file and look up the symbol.
77
+ * Returns enriched Reference Objects with resolved path, signature, and params.
78
+ */
79
+ export async function resolveFileImports(skel, absPath, root) {
80
+ if (!skel.imports || skel.imports.length === 0)
81
+ return [];
82
+ const opts = resolveOptions({ detail: "full", emitHtml: false });
83
+ const results = [];
84
+ for (const imp of skel.imports) {
85
+ const isExternal = !imp.from.startsWith(".");
86
+ const resolvedAbs = isExternal ? null : resolveImportPath(imp.from, absPath);
87
+ const resolvedRel = resolvedAbs
88
+ ? path.relative(root, resolvedAbs).split(path.sep).join("/")
89
+ : null;
90
+ let found = false;
91
+ let kind;
92
+ let signature;
93
+ let params;
94
+ if (resolvedAbs && !imp.isSideEffect && !imp.isNamespaceImport && imp.symbol !== "*") {
95
+ try {
96
+ const targetSkel = await buildSkeleton(resolvedAbs, resolvedRel, opts);
97
+ const targetSym = findSymbol(targetSkel.symbols, imp.symbol);
98
+ if (targetSym) {
99
+ found = true;
100
+ kind = targetSym.kind;
101
+ signature = targetSym.signature ?? null;
102
+ if (signature) {
103
+ params = extractParams(signature);
104
+ }
105
+ }
106
+ }
107
+ catch {
108
+ // target file unresolvable or parse error — leave found=false
109
+ }
110
+ }
111
+ else if (resolvedAbs) {
112
+ // Namespace import or side-effect: file exists = success
113
+ found = true;
114
+ }
115
+ const resolved = {
116
+ ...imp,
117
+ resolvedPath: resolvedAbs,
118
+ resolvedRel,
119
+ found,
120
+ importKind: isExternal ? "external" : "relative",
121
+ };
122
+ if (kind !== undefined)
123
+ resolved.kind = kind;
124
+ if (signature !== undefined)
125
+ resolved.signature = signature;
126
+ if (params !== undefined)
127
+ resolved.params = params;
128
+ results.push(resolved);
129
+ }
130
+ return results;
131
+ }
package/dist/search.js ADDED
@@ -0,0 +1,68 @@
1
+ import path from "node:path";
2
+ import { buildSkeleton, collectSourceFiles } from "./skeleton.js";
3
+ import { resolveOptions, loadProjectConfig } from "./config.js";
4
+ // ─── Helpers ──────────────────────────────────────────────────────────────────
5
+ /** Recursively yield every symbol in a file, including nested ones. */
6
+ function* flattenSymbols(symbols, file, parentName) {
7
+ for (const sym of symbols) {
8
+ const fullName = parentName ? `${parentName}.${sym.name}` : sym.name;
9
+ yield {
10
+ file,
11
+ symbol: fullName,
12
+ kind: sym.kind,
13
+ exported: sym.exported ?? false,
14
+ range: sym.range,
15
+ ...(sym.signature ? { signature: sym.signature } : {}),
16
+ };
17
+ if (sym.children.length > 0) {
18
+ yield* flattenSymbols(sym.children, file, fullName);
19
+ }
20
+ }
21
+ }
22
+ function makeMatcher(pattern, matchType) {
23
+ if (matchType === "exact") {
24
+ return (name) => name === pattern || name.endsWith(`.${pattern}`);
25
+ }
26
+ if (matchType === "regex") {
27
+ const re = new RegExp(pattern, "i");
28
+ return (name) => re.test(name);
29
+ }
30
+ // contains (default) — case-insensitive
31
+ const lower = pattern.toLowerCase();
32
+ return (name) => name.toLowerCase().includes(lower);
33
+ }
34
+ /**
35
+ * Search for symbols by name pattern across all source files in a directory.
36
+ * Traverses nested symbols (methods inside classes, etc.) with dot-notation names.
37
+ *
38
+ * @param dirAbs Absolute path of directory to scan.
39
+ * @param pattern Name to search for (matched per `matchType`).
40
+ * @param root Project root (for relative paths in results).
41
+ * @param options matchType, kind filter, exportedOnly, detail level.
42
+ */
43
+ export async function searchSymbols(dirAbs, pattern, root, options = {}) {
44
+ const { matchType = "contains", kind, exportedOnly = false, detail = "outline" } = options;
45
+ const test = makeMatcher(pattern, matchType);
46
+ const opts = resolveOptions({ detail, emitHtml: false }, loadProjectConfig(root));
47
+ const files = collectSourceFiles(dirAbs, opts);
48
+ const results = [];
49
+ for (const file of files) {
50
+ const fileRel = path.relative(root, file).split(path.sep).join("/");
51
+ try {
52
+ const skel = await buildSkeleton(file, fileRel, opts);
53
+ for (const match of flattenSymbols(skel.symbols, skel.file)) {
54
+ if (!test(match.symbol))
55
+ continue;
56
+ if (kind && match.kind !== kind)
57
+ continue;
58
+ if (exportedOnly && !match.exported)
59
+ continue;
60
+ results.push(match);
61
+ }
62
+ }
63
+ catch {
64
+ // skip unreadable / unparseable files
65
+ }
66
+ }
67
+ return results;
68
+ }
@@ -0,0 +1,106 @@
1
+ import fs from "node:fs";
2
+ import path from "node:path";
3
+ import { detectLanguage, supportedExtensions } from "./registry.js";
4
+ import { parseSource } from "./parser.js";
5
+ import { countSymbols, toOutline } from "./extractors/common.js";
6
+ export const SCHEMA_VERSION = "1.1";
7
+ export const GRAMMAR_SOURCE = "tree-sitter-wasms@0.1.13";
8
+ const parseCache = new Map();
9
+ function cacheKey(absPath, detail) { return `${absPath}|${detail}`; }
10
+ function getCached(absPath, detail) {
11
+ try {
12
+ const entry = parseCache.get(cacheKey(absPath, detail));
13
+ if (!entry)
14
+ return null;
15
+ const mtime = fs.statSync(absPath).mtimeMs;
16
+ return entry.mtime === mtime ? entry.result : null;
17
+ }
18
+ catch {
19
+ return null;
20
+ }
21
+ }
22
+ function setCached(absPath, detail, result) {
23
+ try {
24
+ const mtime = fs.statSync(absPath).mtimeMs;
25
+ parseCache.set(cacheKey(absPath, detail), { mtime, result });
26
+ }
27
+ catch { /* skip if stat fails */ }
28
+ }
29
+ export class UnsupportedLanguageError extends Error {
30
+ ext;
31
+ constructor(ext) {
32
+ super(`Unsupported file type "${ext}". Supported: ${supportedExtensions().join(", ")}`);
33
+ this.ext = ext;
34
+ this.name = "UnsupportedLanguageError";
35
+ }
36
+ }
37
+ /**
38
+ * Build a skeleton for a single file.
39
+ * @param absPath absolute path on disk (already validated to be within root)
40
+ * @param relPath path relative to root, used as the displayed `file`
41
+ */
42
+ export async function buildSkeleton(absPath, relPath, opts) {
43
+ const ext = path.extname(absPath).toLowerCase();
44
+ const entry = detectLanguage(absPath);
45
+ if (!entry)
46
+ throw new UnsupportedLanguageError(ext);
47
+ const stat = fs.statSync(absPath);
48
+ if (stat.size > opts.maxFileBytes) {
49
+ throw new Error(`File is ${stat.size} bytes, exceeds maxFileBytes (${opts.maxFileBytes}). Increase the limit to parse it.`);
50
+ }
51
+ // Return cached result if file hasn't changed
52
+ const cached = getCached(absPath, opts.detail);
53
+ if (cached)
54
+ return cached;
55
+ const source = fs.readFileSync(absPath, "utf8");
56
+ const root = await parseSource(entry.grammar, source);
57
+ let symbols = entry.extract(root, source);
58
+ if (opts.detail === "outline")
59
+ symbols = toOutline(symbols);
60
+ const directives = entry.extractDirectives ? entry.extractDirectives(root, source) : [];
61
+ const imports = entry.extractImports ? entry.extractImports(root, source) : [];
62
+ const result = {
63
+ schemaVersion: SCHEMA_VERSION,
64
+ file: relPath.split(path.sep).join("/"),
65
+ language: entry.language,
66
+ generatedAt: new Date().toISOString(),
67
+ parser: { engine: "tree-sitter", grammar: `${entry.grammar} (${GRAMMAR_SOURCE})` },
68
+ symbolCount: countSymbols(symbols),
69
+ ...(directives.length > 0 ? { directives } : {}),
70
+ ...(imports.length > 0 ? { imports } : {}),
71
+ symbols,
72
+ };
73
+ setCached(absPath, opts.detail, result);
74
+ return result;
75
+ }
76
+ /** Recursively collect supported source files under a directory. */
77
+ export function collectSourceFiles(absDir, opts) {
78
+ const supported = new Set(supportedExtensions());
79
+ const ignore = new Set(opts.ignore);
80
+ const results = [];
81
+ const walk = (dir) => {
82
+ let entries;
83
+ try {
84
+ entries = fs.readdirSync(dir, { withFileTypes: true });
85
+ }
86
+ catch {
87
+ return;
88
+ }
89
+ for (const e of entries) {
90
+ if (e.name.startsWith(".")) {
91
+ if (e.isDirectory())
92
+ continue;
93
+ }
94
+ const full = path.join(dir, e.name);
95
+ if (e.isDirectory()) {
96
+ if (!ignore.has(e.name))
97
+ walk(full);
98
+ }
99
+ else if (e.isFile() && supported.has(path.extname(e.name).toLowerCase())) {
100
+ results.push(full);
101
+ }
102
+ }
103
+ };
104
+ walk(absDir);
105
+ return results.sort();
106
+ }
package/dist/types.js ADDED
@@ -0,0 +1,5 @@
1
+ /**
2
+ * Standard Skeleton JSON schema — the shared "interlingua" that every
3
+ * language extractor must produce, regardless of the source language.
4
+ */
5
+ export {};
package/package.json ADDED
@@ -0,0 +1,44 @@
1
+ {
2
+ "name": "universal-ast-mapper",
3
+ "version": "0.5.2",
4
+ "description": "MCP server that maps source files into a normalized code skeleton (JSON + HTML) using tree-sitter.",
5
+ "type": "module",
6
+ "main": "dist/index.js",
7
+ "bin": {
8
+ "universal-ast-mapper": "dist/index.js",
9
+ "ast-map": "dist/cli.js"
10
+ },
11
+ "files": [
12
+ "dist",
13
+ "README.md",
14
+ "BLUEPRINT.md"
15
+ ],
16
+ "scripts": {
17
+ "build": "tsc",
18
+ "start": "node dist/index.js",
19
+ "smoke": "node test/smoke.mjs",
20
+ "test": "node test/smoke.mjs && node test/analysis.mjs"
21
+ },
22
+ "engines": {
23
+ "node": ">=18"
24
+ },
25
+ "keywords": [
26
+ "mcp",
27
+ "ast",
28
+ "tree-sitter",
29
+ "code-map",
30
+ "skeleton"
31
+ ],
32
+ "license": "MIT",
33
+ "dependencies": {
34
+ "@modelcontextprotocol/sdk": "^1.29.0",
35
+ "commander": "^14.0.3",
36
+ "tree-sitter-wasms": "0.1.13",
37
+ "web-tree-sitter": "0.20.8",
38
+ "zod": "^3.23.8"
39
+ },
40
+ "devDependencies": {
41
+ "@types/node": "^20.11.0",
42
+ "typescript": "^5.4.0"
43
+ }
44
+ }