ucn 3.6.28 → 3.7.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/.claude/skills/ucn/SKILL.md +3 -3
- package/README.md +7 -10
- package/cli/index.js +12 -7
- package/core/discovery.js +2 -2
- package/core/parser.js +21 -2
- package/core/project.js +42 -7
- package/languages/html.js +318 -0
- package/languages/index.js +15 -3
- package/mcp/server.js +1 -1
- package/package.json +3 -1
- package/test/parser.test.js +689 -0
|
@@ -1,13 +1,13 @@
|
|
|
1
1
|
---
|
|
2
2
|
name: ucn
|
|
3
|
-
description: "Code relationship analyzer (callers, call trees, impact, dead code) via tree-sitter AST. PREFER over grep+read when you need: who calls a function, what breaks if you change it, or the full call chain of a pipeline. One `ucn about` replaces 3-4 grep+read cycles. One `ucn trace` maps an entire execution flow without reading any files. Works on
|
|
3
|
+
description: "Code relationship analyzer (callers, call trees, impact, dead code) via tree-sitter AST. PREFER over grep+read when you need: who calls a function, what breaks if you change it, or the full call chain of a pipeline. One `ucn about` replaces 3-4 grep+read cycles. One `ucn trace` maps an entire execution flow without reading any files. Works on JS/TS, Python, Go, Rust, Java, HTML. Skip for plain text search or codebases under 500 LOC."
|
|
4
4
|
allowed-tools: Bash(ucn *), Bash(npx ucn *)
|
|
5
5
|
argument-hint: "[command] [symbol-name] [--flags]"
|
|
6
6
|
---
|
|
7
7
|
|
|
8
8
|
# UCN — Universal Code Navigator
|
|
9
9
|
|
|
10
|
-
Understands code structure via tree-sitter ASTs: who calls what, what breaks if you change something, full call trees, dead code. Works on
|
|
10
|
+
Understands code structure via tree-sitter ASTs: who calls what, what breaks if you change something, full call trees, dead code. Works on JS/TS, Python, Go, Rust, Java. Also parses HTML files (inline scripts and event handlers).
|
|
11
11
|
|
|
12
12
|
## When to Reach for UCN Instead of Grep/Read
|
|
13
13
|
|
|
@@ -23,7 +23,7 @@ Understands code structure via tree-sitter ASTs: who calls what, what breaks if
|
|
|
23
23
|
|
|
24
24
|
- Searching for a string literal, error message, TODO, or config value
|
|
25
25
|
- The codebase is under 500 LOC — just read the files
|
|
26
|
-
- Language not supported (only
|
|
26
|
+
- Language not supported (only JS/TS, Python, Go, Rust, Java, HTML)
|
|
27
27
|
- Finding files by name — use glob
|
|
28
28
|
|
|
29
29
|
## The 5 Commands You'll Use Most
|
package/README.md
CHANGED
|
@@ -2,6 +2,8 @@
|
|
|
2
2
|
|
|
3
3
|
UCN is designed to work with large files and codebases, helping AI agents ingest exactly the data they need. Its surgical output discourages agents from cutting corners, and without UCN, agents working with large codebases tend to skip parts of the code structure, assuming they have "enough data."
|
|
4
4
|
|
|
5
|
+
Supported languages: JS/TS, Python, Go, Rust, Java. Also parses HTML files (inline scripts and event handlers).
|
|
6
|
+
|
|
5
7
|
---
|
|
6
8
|
|
|
7
9
|
## Three Ways to Use UCN
|
|
@@ -91,15 +93,10 @@ Instead of reading entire files, ask precise questions:
|
|
|
91
93
|
│
|
|
92
94
|
tree-sitter AST
|
|
93
95
|
│
|
|
94
|
-
|
|
95
|
-
|
|
96
|
-
|
|
97
|
-
|
|
98
|
-
└───────┘ └─────────┘ └──────┘
|
|
99
|
-
┌───────────┼───────────┐
|
|
100
|
-
┌───┴───┐ ┌────┴───┐
|
|
101
|
-
│ Rust │ │ Java │
|
|
102
|
-
└───────┘ └────────┘
|
|
96
|
+
┌───────────────────┴─────────────────┐
|
|
97
|
+
│ Supported Languages │
|
|
98
|
+
│ JS/TS, Python, Go, Rust, Java, HTML │
|
|
99
|
+
└─────────────────────────────────────┘
|
|
103
100
|
```
|
|
104
101
|
|
|
105
102
|
No cloud. No API keys. Parses locally, stays local.
|
|
@@ -502,7 +499,7 @@ ucn toc # Project overview
|
|
|
502
499
|
│ Limitation │ What happens │
|
|
503
500
|
├──────────────────────────┼──────────────────────────────────────────┤
|
|
504
501
|
│ │ │
|
|
505
|
-
│ 5 languages
|
|
502
|
+
│ 5 languages + HTML │ JS/TS, Python, Go, Rust, Java. │
|
|
506
503
|
│ (no C, Ruby, PHP, etc.) │ Agents fall back to grep for the rest. │
|
|
507
504
|
│ │ UCN complements, doesn't replace. │
|
|
508
505
|
│ │ │
|
package/cli/index.js
CHANGED
|
@@ -10,7 +10,7 @@
|
|
|
10
10
|
const fs = require('fs');
|
|
11
11
|
const path = require('path');
|
|
12
12
|
|
|
13
|
-
const { parse, parseFile, extractFunction, extractClass, detectLanguage, isSupported } = require('../core/parser');
|
|
13
|
+
const { parse, parseFile, extractFunction, extractClass, cleanHtmlScriptTags, detectLanguage, isSupported } = require('../core/parser');
|
|
14
14
|
const { getParser, getLanguageModule } = require('../languages');
|
|
15
15
|
const { ProjectIndex } = require('../core/project');
|
|
16
16
|
const { expandGlob, findProjectRoot, isTestFile } = require('../core/discovery');
|
|
@@ -823,6 +823,7 @@ function runProjectCommand(rootDir, command, arg) {
|
|
|
823
823
|
case 'smart': {
|
|
824
824
|
requireArg(arg, 'Usage: ucn . smart <name>');
|
|
825
825
|
const smart = index.smart(arg, {
|
|
826
|
+
file: flags.file,
|
|
826
827
|
withTypes: flags.withTypes,
|
|
827
828
|
includeMethods: flags.includeMethods,
|
|
828
829
|
includeUncertain: flags.includeUncertain
|
|
@@ -1069,7 +1070,8 @@ function extractFunctionFromProject(index, name) {
|
|
|
1069
1070
|
const m = matches[i];
|
|
1070
1071
|
const code = fs.readFileSync(m.file, 'utf-8');
|
|
1071
1072
|
const lines = code.split('\n');
|
|
1072
|
-
const
|
|
1073
|
+
const extracted = lines.slice(m.startLine - 1, m.endLine);
|
|
1074
|
+
const fnCode = cleanHtmlScriptTags(extracted, detectLanguage(m.file)).join('\n');
|
|
1073
1075
|
if (i > 0) console.log('');
|
|
1074
1076
|
if (flags.json) {
|
|
1075
1077
|
console.log(output.formatFunctionJson(m, fnCode));
|
|
@@ -1093,7 +1095,8 @@ function extractFunctionFromProject(index, name) {
|
|
|
1093
1095
|
// Extract code directly using symbol index location (works for class methods and overloads)
|
|
1094
1096
|
const code = fs.readFileSync(match.file, 'utf-8');
|
|
1095
1097
|
const lines = code.split('\n');
|
|
1096
|
-
const
|
|
1098
|
+
const extracted = lines.slice(match.startLine - 1, match.endLine);
|
|
1099
|
+
const fnCode = cleanHtmlScriptTags(extracted, detectLanguage(match.file)).join('\n');
|
|
1097
1100
|
|
|
1098
1101
|
if (flags.json) {
|
|
1099
1102
|
console.log(output.formatFunctionJson(match, fnCode));
|
|
@@ -1118,7 +1121,8 @@ function extractClassFromProject(index, name) {
|
|
|
1118
1121
|
const m = matches[i];
|
|
1119
1122
|
const code = fs.readFileSync(m.file, 'utf-8');
|
|
1120
1123
|
const codeLines = code.split('\n');
|
|
1121
|
-
const
|
|
1124
|
+
const extracted = codeLines.slice(m.startLine - 1, m.endLine);
|
|
1125
|
+
const clsCode = cleanHtmlScriptTags(extracted, detectLanguage(m.file)).join('\n');
|
|
1122
1126
|
if (i > 0) console.log('');
|
|
1123
1127
|
if (flags.json) {
|
|
1124
1128
|
console.log(JSON.stringify({ ...m, code: clsCode }, null, 2));
|
|
@@ -1142,7 +1146,8 @@ function extractClassFromProject(index, name) {
|
|
|
1142
1146
|
// Use index data directly instead of re-parsing the file
|
|
1143
1147
|
const code = fs.readFileSync(match.file, 'utf-8');
|
|
1144
1148
|
const codeLines = code.split('\n');
|
|
1145
|
-
const
|
|
1149
|
+
const extracted = codeLines.slice(match.startLine - 1, match.endLine);
|
|
1150
|
+
const clsCode = cleanHtmlScriptTags(extracted, detectLanguage(match.file)).join('\n');
|
|
1146
1151
|
|
|
1147
1152
|
if (flags.json) {
|
|
1148
1153
|
console.log(JSON.stringify({ ...match, code: clsCode }, null, 2));
|
|
@@ -1673,7 +1678,7 @@ function isCommentOrString(line) {
|
|
|
1673
1678
|
function printUsage() {
|
|
1674
1679
|
console.log(`UCN - Universal Code Navigator
|
|
1675
1680
|
|
|
1676
|
-
Supported: JavaScript, TypeScript, Python, Go, Rust, Java
|
|
1681
|
+
Supported: JavaScript, TypeScript, Python, Go, Rust, Java, HTML
|
|
1677
1682
|
|
|
1678
1683
|
Usage:
|
|
1679
1684
|
ucn [command] [args] Project mode (current directory)
|
|
@@ -1928,7 +1933,7 @@ function executeInteractiveCommand(index, command, arg) {
|
|
|
1928
1933
|
console.log('Usage: smart <name>');
|
|
1929
1934
|
return;
|
|
1930
1935
|
}
|
|
1931
|
-
const smart = index.smart(arg, { includeUncertain: flags.includeUncertain });
|
|
1936
|
+
const smart = index.smart(arg, { file: flags.file, includeUncertain: flags.includeUncertain });
|
|
1932
1937
|
if (smart) {
|
|
1933
1938
|
console.log(output.formatSmart(smart, {
|
|
1934
1939
|
uncertainHint: 'use --include-uncertain to include all'
|
package/core/discovery.js
CHANGED
|
@@ -389,7 +389,7 @@ function detectProjectPattern(projectRoot) {
|
|
|
389
389
|
// Helper to check for config files in a directory
|
|
390
390
|
const checkDir = (dir) => {
|
|
391
391
|
if (fs.existsSync(path.join(dir, 'package.json'))) {
|
|
392
|
-
extensions.push('js', 'jsx', 'ts', 'tsx', 'mjs', 'cjs');
|
|
392
|
+
extensions.push('js', 'jsx', 'ts', 'tsx', 'mjs', 'cjs', 'html', 'htm');
|
|
393
393
|
}
|
|
394
394
|
|
|
395
395
|
if (fs.existsSync(path.join(dir, 'pyproject.toml')) ||
|
|
@@ -433,7 +433,7 @@ function detectProjectPattern(projectRoot) {
|
|
|
433
433
|
return `**/*.{${unique.join(',')}}`;
|
|
434
434
|
}
|
|
435
435
|
|
|
436
|
-
return '**/*.{js,jsx,ts,tsx,py,go,java,rs,rb,php,c,cpp,h,hpp}';
|
|
436
|
+
return '**/*.{js,jsx,ts,tsx,py,go,java,rs,rb,php,c,cpp,h,hpp,html,htm}';
|
|
437
437
|
}
|
|
438
438
|
|
|
439
439
|
/**
|
package/core/parser.js
CHANGED
|
@@ -128,7 +128,8 @@ function extractFunction(code, language, name) {
|
|
|
128
128
|
return { fn: null, code: '' };
|
|
129
129
|
}
|
|
130
130
|
|
|
131
|
-
const
|
|
131
|
+
const extracted = lines.slice(fn.startLine - 1, fn.endLine);
|
|
132
|
+
const fnCode = cleanHtmlScriptTags(extracted, language).join('\n');
|
|
132
133
|
return { fn, code: fnCode };
|
|
133
134
|
}
|
|
134
135
|
|
|
@@ -148,7 +149,8 @@ function extractClass(code, language, name) {
|
|
|
148
149
|
return { cls: null, code: '' };
|
|
149
150
|
}
|
|
150
151
|
|
|
151
|
-
const
|
|
152
|
+
const extracted = lines.slice(cls.startLine - 1, cls.endLine);
|
|
153
|
+
const clsCode = cleanHtmlScriptTags(extracted, language).join('\n');
|
|
152
154
|
return { cls, code: clsCode };
|
|
153
155
|
}
|
|
154
156
|
|
|
@@ -266,6 +268,22 @@ function getExportedSymbols(result) {
|
|
|
266
268
|
return exported;
|
|
267
269
|
}
|
|
268
270
|
|
|
271
|
+
/**
|
|
272
|
+
* Strip <script> and </script> tags from extracted code lines for HTML files.
|
|
273
|
+
* Only affects the first and last lines when they contain script tags alongside JS code.
|
|
274
|
+
* @param {string[]} lines - Extracted lines
|
|
275
|
+
* @param {string} language - Language name
|
|
276
|
+
* @returns {string[]} Cleaned lines (same array mutated)
|
|
277
|
+
*/
|
|
278
|
+
function cleanHtmlScriptTags(lines, language) {
|
|
279
|
+
if (language === 'html' && lines.length > 0) {
|
|
280
|
+
lines[0] = lines[0].replace(/^(\s*)<script[^>]*>/i, '$1');
|
|
281
|
+
const last = lines.length - 1;
|
|
282
|
+
lines[last] = lines[last].replace(/<\/script>\s*$/i, '');
|
|
283
|
+
}
|
|
284
|
+
return lines;
|
|
285
|
+
}
|
|
286
|
+
|
|
269
287
|
module.exports = {
|
|
270
288
|
parse,
|
|
271
289
|
parseFile,
|
|
@@ -274,6 +292,7 @@ module.exports = {
|
|
|
274
292
|
getToc,
|
|
275
293
|
findSymbol,
|
|
276
294
|
getExportedSymbols,
|
|
295
|
+
cleanHtmlScriptTags,
|
|
277
296
|
detectLanguage,
|
|
278
297
|
isSupported
|
|
279
298
|
};
|
package/core/project.js
CHANGED
|
@@ -11,7 +11,7 @@ const crypto = require('crypto');
|
|
|
11
11
|
const { execSync } = require('child_process');
|
|
12
12
|
const { expandGlob, findProjectRoot, detectProjectPattern, isTestFile, parseGitignore, DEFAULT_IGNORES } = require('./discovery');
|
|
13
13
|
const { extractImports, extractExports, resolveImport } = require('./imports');
|
|
14
|
-
const { parseFile } = require('./parser');
|
|
14
|
+
const { parseFile, cleanHtmlScriptTags } = require('./parser');
|
|
15
15
|
const { detectLanguage, getParser, getLanguageModule, PARSE_OPTIONS, safeParse } = require('../languages');
|
|
16
16
|
const { getTokenTypeAtPosition } = require('../languages/utils');
|
|
17
17
|
|
|
@@ -1881,7 +1881,9 @@ class ProjectIndex {
|
|
|
1881
1881
|
try {
|
|
1882
1882
|
const content = fs.readFileSync(symbol.file, 'utf-8');
|
|
1883
1883
|
const lines = content.split('\n');
|
|
1884
|
-
|
|
1884
|
+
const extracted = lines.slice(symbol.startLine - 1, symbol.endLine);
|
|
1885
|
+
cleanHtmlScriptTags(extracted, detectLanguage(symbol.file));
|
|
1886
|
+
return extracted.join('\n');
|
|
1885
1887
|
} catch (e) {
|
|
1886
1888
|
return '';
|
|
1887
1889
|
}
|
|
@@ -2605,11 +2607,26 @@ class ProjectIndex {
|
|
|
2605
2607
|
const language = detectLanguage(filePath);
|
|
2606
2608
|
if (!language) continue;
|
|
2607
2609
|
|
|
2608
|
-
const parser = getParser(language);
|
|
2609
|
-
if (!parser) continue;
|
|
2610
|
-
|
|
2611
2610
|
const content = fs.readFileSync(filePath, 'utf-8');
|
|
2612
|
-
|
|
2611
|
+
|
|
2612
|
+
// For HTML files, parse the virtual JS content instead of raw HTML
|
|
2613
|
+
// (HTML tree-sitter sees script content as raw_text, not JS identifiers)
|
|
2614
|
+
let tree;
|
|
2615
|
+
if (language === 'html') {
|
|
2616
|
+
const htmlModule = getLanguageModule('html');
|
|
2617
|
+
const htmlParser = getParser('html');
|
|
2618
|
+
const jsParser = getParser('javascript');
|
|
2619
|
+
const blocks = htmlModule.extractScriptBlocks(content, htmlParser);
|
|
2620
|
+
if (blocks.length === 0 && !htmlModule.extractEventHandlerCalls) continue;
|
|
2621
|
+
if (blocks.length > 0) {
|
|
2622
|
+
const virtualJS = htmlModule.buildVirtualJSContent(content, blocks);
|
|
2623
|
+
tree = jsParser.parse(virtualJS, undefined, PARSE_OPTIONS);
|
|
2624
|
+
}
|
|
2625
|
+
} else {
|
|
2626
|
+
const parser = getParser(language);
|
|
2627
|
+
if (!parser) continue;
|
|
2628
|
+
tree = parser.parse(content, undefined, PARSE_OPTIONS);
|
|
2629
|
+
}
|
|
2613
2630
|
|
|
2614
2631
|
// Collect all identifiers from this file in one pass
|
|
2615
2632
|
const traverse = (node) => {
|
|
@@ -2634,7 +2651,25 @@ class ProjectIndex {
|
|
|
2634
2651
|
traverse(node.child(i));
|
|
2635
2652
|
}
|
|
2636
2653
|
};
|
|
2637
|
-
traverse(tree.rootNode);
|
|
2654
|
+
if (tree) traverse(tree.rootNode);
|
|
2655
|
+
|
|
2656
|
+
// For HTML files, also extract identifiers from event handler attributes
|
|
2657
|
+
// (onclick="foo()" etc. — these are in HTML, not in <script> blocks)
|
|
2658
|
+
if (language === 'html') {
|
|
2659
|
+
const htmlModule = getLanguageModule('html');
|
|
2660
|
+
const htmlParser = getParser('html');
|
|
2661
|
+
const handlerCalls = htmlModule.extractEventHandlerCalls(content, htmlParser);
|
|
2662
|
+
for (const call of handlerCalls) {
|
|
2663
|
+
if (!usageIndex.has(call.name)) {
|
|
2664
|
+
usageIndex.set(call.name, []);
|
|
2665
|
+
}
|
|
2666
|
+
usageIndex.get(call.name).push({
|
|
2667
|
+
file: filePath,
|
|
2668
|
+
line: call.line,
|
|
2669
|
+
relativePath: fileEntry.relativePath
|
|
2670
|
+
});
|
|
2671
|
+
}
|
|
2672
|
+
}
|
|
2638
2673
|
} catch (e) {
|
|
2639
2674
|
// Skip files that can't be processed
|
|
2640
2675
|
}
|
|
@@ -0,0 +1,318 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* languages/html.js - HTML file support via inline <script> extraction
|
|
3
|
+
*
|
|
4
|
+
* Parses HTML with tree-sitter-html, extracts inline JavaScript from <script> blocks,
|
|
5
|
+
* builds a line-preserving virtual JS string, and delegates to javascript.js for analysis.
|
|
6
|
+
* Line numbers are automatically correct because the virtual JS has the same line count
|
|
7
|
+
* as the original HTML file, with empty lines for non-script content.
|
|
8
|
+
*/
|
|
9
|
+
|
|
10
|
+
const { getParser, getLanguageModule } = require('./index');
|
|
11
|
+
|
|
12
|
+
// Script type values that indicate JavaScript content
|
|
13
|
+
const JS_TYPES = new Set([
|
|
14
|
+
'text/javascript',
|
|
15
|
+
'application/javascript',
|
|
16
|
+
'module',
|
|
17
|
+
''
|
|
18
|
+
]);
|
|
19
|
+
|
|
20
|
+
/**
|
|
21
|
+
* Extract inline script blocks from HTML using tree-sitter-html AST.
|
|
22
|
+
* Skips external scripts (src=...) and non-JS types (application/json, importmap, etc.).
|
|
23
|
+
*
|
|
24
|
+
* @param {string} htmlContent - Raw HTML source
|
|
25
|
+
* @param {object} htmlParser - tree-sitter parser configured for HTML
|
|
26
|
+
* @returns {Array<{text: string, startRow: number, startCol: number}>}
|
|
27
|
+
*/
|
|
28
|
+
function extractScriptBlocks(htmlContent, htmlParser) {
|
|
29
|
+
const { safeParse, getParseOptions } = require('./index');
|
|
30
|
+
const tree = safeParse(htmlParser, htmlContent, undefined, getParseOptions(htmlContent.length));
|
|
31
|
+
const blocks = [];
|
|
32
|
+
|
|
33
|
+
// Walk the AST looking for script_element nodes
|
|
34
|
+
const visit = (node) => {
|
|
35
|
+
if (node.type === 'script_element') {
|
|
36
|
+
const startTag = node.childForFieldName('start_tag') ||
|
|
37
|
+
node.children.find(c => c.type === 'start_tag');
|
|
38
|
+
const rawText = node.children.find(c => c.type === 'raw_text');
|
|
39
|
+
|
|
40
|
+
if (!startTag || !rawText || !rawText.text) return;
|
|
41
|
+
|
|
42
|
+
// Check attributes on start_tag
|
|
43
|
+
let hasSrc = false;
|
|
44
|
+
let typeValue = null;
|
|
45
|
+
|
|
46
|
+
for (let i = 0; i < startTag.childCount; i++) {
|
|
47
|
+
const attr = startTag.child(i);
|
|
48
|
+
if (attr.type !== 'attribute') continue;
|
|
49
|
+
|
|
50
|
+
const nameNode = attr.children.find(c => c.type === 'attribute_name');
|
|
51
|
+
if (!nameNode) continue;
|
|
52
|
+
const attrName = nameNode.text.toLowerCase();
|
|
53
|
+
|
|
54
|
+
if (attrName === 'src') {
|
|
55
|
+
hasSrc = true;
|
|
56
|
+
break;
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
if (attrName === 'type') {
|
|
60
|
+
const valueNode = attr.children.find(c =>
|
|
61
|
+
c.type === 'quoted_attribute_value' || c.type === 'attribute_value'
|
|
62
|
+
);
|
|
63
|
+
if (valueNode) {
|
|
64
|
+
// Extract value text - strip quotes if quoted_attribute_value
|
|
65
|
+
const innerValue = valueNode.type === 'quoted_attribute_value'
|
|
66
|
+
? valueNode.children.find(c => c.type === 'attribute_value')
|
|
67
|
+
: valueNode;
|
|
68
|
+
typeValue = innerValue ? innerValue.text.toLowerCase().trim() : '';
|
|
69
|
+
} else {
|
|
70
|
+
typeValue = '';
|
|
71
|
+
}
|
|
72
|
+
}
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
// Skip external scripts
|
|
76
|
+
if (hasSrc) return;
|
|
77
|
+
|
|
78
|
+
// Skip non-JS types
|
|
79
|
+
if (typeValue !== null && !JS_TYPES.has(typeValue)) return;
|
|
80
|
+
|
|
81
|
+
blocks.push({
|
|
82
|
+
text: rawText.text,
|
|
83
|
+
startRow: rawText.startPosition.row,
|
|
84
|
+
startCol: rawText.startPosition.column
|
|
85
|
+
});
|
|
86
|
+
return; // Don't recurse into script_element children
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
for (let i = 0; i < node.childCount; i++) {
|
|
90
|
+
visit(node.child(i));
|
|
91
|
+
}
|
|
92
|
+
};
|
|
93
|
+
|
|
94
|
+
visit(tree.rootNode);
|
|
95
|
+
return blocks;
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
/**
|
|
99
|
+
* Build a virtual JS string with the same line count as the HTML file.
|
|
100
|
+
* Script block lines are placed at their original positions; everything else is empty.
|
|
101
|
+
* The first line of each block is padded with spaces to match its column offset.
|
|
102
|
+
*
|
|
103
|
+
* @param {string} htmlContent - Raw HTML source
|
|
104
|
+
* @param {Array<{text: string, startRow: number, startCol: number}>} blocks
|
|
105
|
+
* @returns {string}
|
|
106
|
+
*/
|
|
107
|
+
function buildVirtualJSContent(htmlContent, blocks) {
|
|
108
|
+
const totalLines = htmlContent.split('\n').length;
|
|
109
|
+
const lines = new Array(totalLines).fill('');
|
|
110
|
+
|
|
111
|
+
for (const block of blocks) {
|
|
112
|
+
const blockLines = block.text.split('\n');
|
|
113
|
+
for (let i = 0; i < blockLines.length; i++) {
|
|
114
|
+
const row = block.startRow + i;
|
|
115
|
+
if (row >= totalLines) break;
|
|
116
|
+
if (i === 0 && block.startCol > 0) {
|
|
117
|
+
// Pad first line to match column offset
|
|
118
|
+
lines[row] = ' '.repeat(block.startCol) + blockLines[i];
|
|
119
|
+
} else {
|
|
120
|
+
lines[row] = blockLines[i];
|
|
121
|
+
}
|
|
122
|
+
}
|
|
123
|
+
}
|
|
124
|
+
|
|
125
|
+
return lines.join('\n');
|
|
126
|
+
}
|
|
127
|
+
|
|
128
|
+
/**
|
|
129
|
+
* Extract JavaScript from HTML and prepare for JS parsing.
|
|
130
|
+
* Returns null if no inline scripts found.
|
|
131
|
+
*
|
|
132
|
+
* @param {string} htmlContent - Raw HTML source
|
|
133
|
+
* @param {object} htmlParser - tree-sitter parser configured for HTML
|
|
134
|
+
* @returns {{virtualJS: string, jsParser: object, jsModule: object}|null}
|
|
135
|
+
*/
|
|
136
|
+
function extractJS(htmlContent, htmlParser) {
|
|
137
|
+
const blocks = extractScriptBlocks(htmlContent, htmlParser);
|
|
138
|
+
if (blocks.length === 0) return null;
|
|
139
|
+
|
|
140
|
+
const virtualJS = buildVirtualJSContent(htmlContent, blocks);
|
|
141
|
+
const jsParser = getParser('javascript');
|
|
142
|
+
const jsModule = getLanguageModule('javascript');
|
|
143
|
+
|
|
144
|
+
return { virtualJS, jsParser, jsModule };
|
|
145
|
+
}
|
|
146
|
+
|
|
147
|
+
/**
|
|
148
|
+
* Extract function calls from HTML event handler attributes (onclick, onchange, etc.).
|
|
149
|
+
* Walks the HTML AST for elements with on* attributes, extracts function names
|
|
150
|
+
* from the attribute values using regex, and returns call objects.
|
|
151
|
+
*
|
|
152
|
+
* @param {string} htmlContent - Raw HTML source
|
|
153
|
+
* @param {object} htmlParser - tree-sitter parser configured for HTML
|
|
154
|
+
* @returns {Array<{name: string, line: number, isMethod: boolean, enclosingFunction: null, uncertain: boolean, isEventHandler: boolean}>}
|
|
155
|
+
*/
|
|
156
|
+
function extractEventHandlerCalls(htmlContent, htmlParser) {
|
|
157
|
+
const { safeParse, getParseOptions } = require('./index');
|
|
158
|
+
const tree = safeParse(htmlParser, htmlContent, undefined, getParseOptions(htmlContent.length));
|
|
159
|
+
const calls = [];
|
|
160
|
+
|
|
161
|
+
const JS_KEYWORDS = new Set([
|
|
162
|
+
'if', 'for', 'while', 'switch', 'catch', 'function', 'return',
|
|
163
|
+
'typeof', 'void', 'delete', 'new', 'throw', 'class', 'const',
|
|
164
|
+
'let', 'var', 'true', 'false', 'null', 'undefined', 'this'
|
|
165
|
+
]);
|
|
166
|
+
|
|
167
|
+
const visit = (node) => {
|
|
168
|
+
// Skip script elements — their content is handled separately
|
|
169
|
+
if (node.type === 'script_element') return;
|
|
170
|
+
|
|
171
|
+
if (node.type === 'attribute') {
|
|
172
|
+
const nameNode = node.children.find(c => c.type === 'attribute_name');
|
|
173
|
+
if (!nameNode || !nameNode.text.toLowerCase().startsWith('on')) {
|
|
174
|
+
for (let i = 0; i < node.childCount; i++) visit(node.child(i));
|
|
175
|
+
return;
|
|
176
|
+
}
|
|
177
|
+
|
|
178
|
+
const valueNode = node.children.find(c =>
|
|
179
|
+
c.type === 'quoted_attribute_value' || c.type === 'attribute_value'
|
|
180
|
+
);
|
|
181
|
+
if (!valueNode) return;
|
|
182
|
+
|
|
183
|
+
let valueText;
|
|
184
|
+
if (valueNode.type === 'quoted_attribute_value') {
|
|
185
|
+
const inner = valueNode.children.find(c => c.type === 'attribute_value');
|
|
186
|
+
valueText = inner ? inner.text : '';
|
|
187
|
+
} else {
|
|
188
|
+
valueText = valueNode.text;
|
|
189
|
+
}
|
|
190
|
+
if (!valueText) return;
|
|
191
|
+
|
|
192
|
+
const line = nameNode.startPosition.row + 1; // 1-indexed
|
|
193
|
+
|
|
194
|
+
// Extract standalone function calls (not method calls like obj.method())
|
|
195
|
+
const regex = /([a-zA-Z_$][a-zA-Z0-9_$]*)\s*\(/g;
|
|
196
|
+
let match;
|
|
197
|
+
while ((match = regex.exec(valueText)) !== null) {
|
|
198
|
+
const fnName = match[1];
|
|
199
|
+
if (JS_KEYWORDS.has(fnName)) continue;
|
|
200
|
+
// Skip if preceded by dot (method call on object)
|
|
201
|
+
if (match.index > 0 && valueText[match.index - 1] === '.') continue;
|
|
202
|
+
|
|
203
|
+
calls.push({
|
|
204
|
+
name: fnName,
|
|
205
|
+
line,
|
|
206
|
+
isMethod: false,
|
|
207
|
+
enclosingFunction: null,
|
|
208
|
+
uncertain: false,
|
|
209
|
+
isEventHandler: true
|
|
210
|
+
});
|
|
211
|
+
}
|
|
212
|
+
return;
|
|
213
|
+
}
|
|
214
|
+
|
|
215
|
+
for (let i = 0; i < node.childCount; i++) visit(node.child(i));
|
|
216
|
+
};
|
|
217
|
+
|
|
218
|
+
visit(tree.rootNode);
|
|
219
|
+
return calls;
|
|
220
|
+
}
|
|
221
|
+
|
|
222
|
+
// ── Exported language module interface ──────────────────────────────────────
|
|
223
|
+
|
|
224
|
+
function parse(code, parser) {
|
|
225
|
+
const result = extractJS(code, parser);
|
|
226
|
+
if (!result) {
|
|
227
|
+
return {
|
|
228
|
+
language: 'html',
|
|
229
|
+
totalLines: code.split('\n').length,
|
|
230
|
+
functions: [],
|
|
231
|
+
classes: [],
|
|
232
|
+
stateObjects: [],
|
|
233
|
+
imports: [],
|
|
234
|
+
exports: []
|
|
235
|
+
};
|
|
236
|
+
}
|
|
237
|
+
|
|
238
|
+
const jsResult = result.jsModule.parse(result.virtualJS, result.jsParser);
|
|
239
|
+
jsResult.language = 'html';
|
|
240
|
+
jsResult.totalLines = code.split('\n').length;
|
|
241
|
+
return jsResult;
|
|
242
|
+
}
|
|
243
|
+
|
|
244
|
+
function findFunctions(code, parser) {
|
|
245
|
+
const result = extractJS(code, parser);
|
|
246
|
+
if (!result) return [];
|
|
247
|
+
return result.jsModule.findFunctions(result.virtualJS, result.jsParser);
|
|
248
|
+
}
|
|
249
|
+
|
|
250
|
+
function findClasses(code, parser) {
|
|
251
|
+
const result = extractJS(code, parser);
|
|
252
|
+
if (!result) return [];
|
|
253
|
+
return result.jsModule.findClasses(result.virtualJS, result.jsParser);
|
|
254
|
+
}
|
|
255
|
+
|
|
256
|
+
function findStateObjects(code, parser) {
|
|
257
|
+
const result = extractJS(code, parser);
|
|
258
|
+
if (!result) return [];
|
|
259
|
+
return result.jsModule.findStateObjects(result.virtualJS, result.jsParser);
|
|
260
|
+
}
|
|
261
|
+
|
|
262
|
+
function findCallsInCode(code, parser) {
|
|
263
|
+
const scriptCalls = (() => {
|
|
264
|
+
const result = extractJS(code, parser);
|
|
265
|
+
if (!result) return [];
|
|
266
|
+
return result.jsModule.findCallsInCode(result.virtualJS, result.jsParser);
|
|
267
|
+
})();
|
|
268
|
+
const handlerCalls = extractEventHandlerCalls(code, parser);
|
|
269
|
+
if (handlerCalls.length === 0) return scriptCalls;
|
|
270
|
+
return scriptCalls.concat(handlerCalls);
|
|
271
|
+
}
|
|
272
|
+
|
|
273
|
+
function findCallbackUsages(code, name, parser) {
|
|
274
|
+
const result = extractJS(code, parser);
|
|
275
|
+
if (!result) return [];
|
|
276
|
+
return result.jsModule.findCallbackUsages(result.virtualJS, name, result.jsParser);
|
|
277
|
+
}
|
|
278
|
+
|
|
279
|
+
function findReExports(code, parser) {
|
|
280
|
+
const result = extractJS(code, parser);
|
|
281
|
+
if (!result) return [];
|
|
282
|
+
return result.jsModule.findReExports(result.virtualJS, result.jsParser);
|
|
283
|
+
}
|
|
284
|
+
|
|
285
|
+
function findImportsInCode(code, parser) {
|
|
286
|
+
const result = extractJS(code, parser);
|
|
287
|
+
if (!result) return [];
|
|
288
|
+
return result.jsModule.findImportsInCode(result.virtualJS, result.jsParser);
|
|
289
|
+
}
|
|
290
|
+
|
|
291
|
+
function findExportsInCode(code, parser) {
|
|
292
|
+
const result = extractJS(code, parser);
|
|
293
|
+
if (!result) return [];
|
|
294
|
+
return result.jsModule.findExportsInCode(result.virtualJS, result.jsParser);
|
|
295
|
+
}
|
|
296
|
+
|
|
297
|
+
function findUsagesInCode(code, name, parser) {
|
|
298
|
+
const result = extractJS(code, parser);
|
|
299
|
+
if (!result) return [];
|
|
300
|
+
return result.jsModule.findUsagesInCode(result.virtualJS, name, result.jsParser);
|
|
301
|
+
}
|
|
302
|
+
|
|
303
|
+
module.exports = {
|
|
304
|
+
parse,
|
|
305
|
+
findFunctions,
|
|
306
|
+
findClasses,
|
|
307
|
+
findStateObjects,
|
|
308
|
+
findCallsInCode,
|
|
309
|
+
findCallbackUsages,
|
|
310
|
+
findReExports,
|
|
311
|
+
findImportsInCode,
|
|
312
|
+
findExportsInCode,
|
|
313
|
+
findUsagesInCode,
|
|
314
|
+
// Exported for testing
|
|
315
|
+
extractScriptBlocks,
|
|
316
|
+
buildVirtualJSContent,
|
|
317
|
+
extractEventHandlerCalls
|
|
318
|
+
};
|
package/languages/index.js
CHANGED
|
@@ -55,6 +55,12 @@ const LANGUAGES = {
|
|
|
55
55
|
extensions: ['.java'],
|
|
56
56
|
treeSitterLang: 'java',
|
|
57
57
|
module: () => require('./java')
|
|
58
|
+
},
|
|
59
|
+
html: {
|
|
60
|
+
name: 'html',
|
|
61
|
+
extensions: ['.html', '.htm'],
|
|
62
|
+
treeSitterLang: 'html',
|
|
63
|
+
module: () => require('./html')
|
|
58
64
|
}
|
|
59
65
|
};
|
|
60
66
|
|
|
@@ -124,6 +130,9 @@ function getParser(language) {
|
|
|
124
130
|
case 'rust':
|
|
125
131
|
lang = require('tree-sitter-rust');
|
|
126
132
|
break;
|
|
133
|
+
case 'html':
|
|
134
|
+
lang = require('tree-sitter-html');
|
|
135
|
+
break;
|
|
127
136
|
default:
|
|
128
137
|
throw new Error(`No tree-sitter grammar for: ${language}`);
|
|
129
138
|
}
|
|
@@ -238,9 +247,12 @@ function safeParse(parser, content, oldTree = undefined, options = {}) {
|
|
|
238
247
|
} catch (e) {
|
|
239
248
|
lastError = e;
|
|
240
249
|
// Only retry on buffer-related errors
|
|
241
|
-
|
|
242
|
-
|
|
243
|
-
|
|
250
|
+
// tree-sitter throws "Invalid argument" when buffer is too small
|
|
251
|
+
const msg = e.message?.toLowerCase() || '';
|
|
252
|
+
if (!msg.includes('buffer') &&
|
|
253
|
+
!msg.includes('memory') &&
|
|
254
|
+
!msg.includes('alloc') &&
|
|
255
|
+
!msg.includes('invalid argument')) {
|
|
244
256
|
throw e; // Non-buffer error, don't retry
|
|
245
257
|
}
|
|
246
258
|
// Continue to next buffer size
|
package/mcp/server.js
CHANGED
|
@@ -169,7 +169,7 @@ function requireName(name) {
|
|
|
169
169
|
// CONSOLIDATED TOOL REGISTRATION
|
|
170
170
|
// ============================================================================
|
|
171
171
|
|
|
172
|
-
const TOOL_DESCRIPTION = `Universal Code Navigator powered by tree-sitter ASTs. Analyzes code structure — functions, callers, callees, dependencies — across JavaScript/TypeScript, Python, Go, Rust, and
|
|
172
|
+
const TOOL_DESCRIPTION = `Universal Code Navigator powered by tree-sitter ASTs. Analyzes code structure — functions, callers, callees, dependencies — across JavaScript/TypeScript, Python, Go, Rust, Java, and HTML (inline scripts and event handlers). Use instead of grep/read for code relationships.
|
|
173
173
|
|
|
174
174
|
TOP 5 (covers 90% of tasks): about, impact, trace, find, deadcode
|
|
175
175
|
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "ucn",
|
|
3
|
-
"version": "3.
|
|
3
|
+
"version": "3.7.1",
|
|
4
4
|
"description": "Universal Code Navigator — function relationships, call trees, and impact analysis across large codebases without reading entire files.",
|
|
5
5
|
"main": "index.js",
|
|
6
6
|
"bin": {
|
|
@@ -21,6 +21,7 @@
|
|
|
21
21
|
"go",
|
|
22
22
|
"rust",
|
|
23
23
|
"java",
|
|
24
|
+
"html",
|
|
24
25
|
"ai",
|
|
25
26
|
"agent"
|
|
26
27
|
],
|
|
@@ -34,6 +35,7 @@
|
|
|
34
35
|
"dependencies": {
|
|
35
36
|
"tree-sitter": "^0.21.0",
|
|
36
37
|
"tree-sitter-go": "^0.21.0",
|
|
38
|
+
"tree-sitter-html": "^0.23.0",
|
|
37
39
|
"tree-sitter-java": "^0.21.0",
|
|
38
40
|
"tree-sitter-javascript": "^0.21.0",
|
|
39
41
|
"tree-sitter-python": "^0.21.0",
|