ucn 3.6.28 → 3.7.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/.claude/skills/ucn/SKILL.md +3 -3
- package/README.md +4 -10
- package/cli/index.js +12 -7
- package/core/discovery.js +2 -2
- package/core/parser.js +21 -2
- package/core/project.js +21 -6
- package/languages/html.js +237 -0
- package/languages/index.js +15 -3
- package/mcp/server.js +1 -1
- package/package.json +3 -1
- package/test/parser.test.js +536 -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 Python, JS/TS, Go, Rust, Java. Skip for plain text search or codebases under 500 LOC."
|
|
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 Python, JS/TS, 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 Python, JS/TS, Go, Rust, Java.
|
|
10
|
+
Understands code structure via tree-sitter ASTs: who calls what, what breaks if you change something, full call trees, dead code. Works on Python, JS/TS, Go, Rust, Java, HTML (inline scripts).
|
|
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 Python, JS/TS, Go, Rust, Java)
|
|
26
|
+
- Language not supported (only Python, JS/TS, 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
|
@@ -91,15 +91,9 @@ Instead of reading entire files, ask precise questions:
|
|
|
91
91
|
│
|
|
92
92
|
tree-sitter AST
|
|
93
93
|
│
|
|
94
|
-
|
|
95
|
-
|
|
96
|
-
|
|
97
|
-
│ JS/TS │ │ Python │ │ Go │
|
|
98
|
-
└───────┘ └─────────┘ └──────┘
|
|
99
|
-
┌───────────┼───────────┐
|
|
100
|
-
┌───┴───┐ ┌────┴───┐
|
|
101
|
-
│ Rust │ │ Java │
|
|
102
|
-
└───────┘ └────────┘
|
|
94
|
+
┌─────────────┴─────────────┐
|
|
95
|
+
│ Supported Languages │
|
|
96
|
+
└───────────────────────────┘
|
|
103
97
|
```
|
|
104
98
|
|
|
105
99
|
No cloud. No API keys. Parses locally, stays local.
|
|
@@ -502,7 +496,7 @@ ucn toc # Project overview
|
|
|
502
496
|
│ Limitation │ What happens │
|
|
503
497
|
├──────────────────────────┼──────────────────────────────────────────┤
|
|
504
498
|
│ │ │
|
|
505
|
-
│
|
|
499
|
+
│ 6 languages only │ JS/TS, Python, Go, Rust, Java, HTML. │
|
|
506
500
|
│ (no C, Ruby, PHP, etc.) │ Agents fall back to grep for the rest. │
|
|
507
501
|
│ │ UCN complements, doesn't replace. │
|
|
508
502
|
│ │ │
|
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,24 @@ 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) continue;
|
|
2621
|
+
const virtualJS = htmlModule.buildVirtualJSContent(content, blocks);
|
|
2622
|
+
tree = jsParser.parse(virtualJS, undefined, PARSE_OPTIONS);
|
|
2623
|
+
} else {
|
|
2624
|
+
const parser = getParser(language);
|
|
2625
|
+
if (!parser) continue;
|
|
2626
|
+
tree = parser.parse(content, undefined, PARSE_OPTIONS);
|
|
2627
|
+
}
|
|
2613
2628
|
|
|
2614
2629
|
// Collect all identifiers from this file in one pass
|
|
2615
2630
|
const traverse = (node) => {
|
|
@@ -0,0 +1,237 @@
|
|
|
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
|
+
// ── Exported language module interface ──────────────────────────────────────
|
|
148
|
+
|
|
149
|
+
function parse(code, parser) {
|
|
150
|
+
const result = extractJS(code, parser);
|
|
151
|
+
if (!result) {
|
|
152
|
+
return {
|
|
153
|
+
language: 'html',
|
|
154
|
+
totalLines: code.split('\n').length,
|
|
155
|
+
functions: [],
|
|
156
|
+
classes: [],
|
|
157
|
+
stateObjects: [],
|
|
158
|
+
imports: [],
|
|
159
|
+
exports: []
|
|
160
|
+
};
|
|
161
|
+
}
|
|
162
|
+
|
|
163
|
+
const jsResult = result.jsModule.parse(result.virtualJS, result.jsParser);
|
|
164
|
+
jsResult.language = 'html';
|
|
165
|
+
jsResult.totalLines = code.split('\n').length;
|
|
166
|
+
return jsResult;
|
|
167
|
+
}
|
|
168
|
+
|
|
169
|
+
function findFunctions(code, parser) {
|
|
170
|
+
const result = extractJS(code, parser);
|
|
171
|
+
if (!result) return [];
|
|
172
|
+
return result.jsModule.findFunctions(result.virtualJS, result.jsParser);
|
|
173
|
+
}
|
|
174
|
+
|
|
175
|
+
function findClasses(code, parser) {
|
|
176
|
+
const result = extractJS(code, parser);
|
|
177
|
+
if (!result) return [];
|
|
178
|
+
return result.jsModule.findClasses(result.virtualJS, result.jsParser);
|
|
179
|
+
}
|
|
180
|
+
|
|
181
|
+
function findStateObjects(code, parser) {
|
|
182
|
+
const result = extractJS(code, parser);
|
|
183
|
+
if (!result) return [];
|
|
184
|
+
return result.jsModule.findStateObjects(result.virtualJS, result.jsParser);
|
|
185
|
+
}
|
|
186
|
+
|
|
187
|
+
function findCallsInCode(code, parser) {
|
|
188
|
+
const result = extractJS(code, parser);
|
|
189
|
+
if (!result) return [];
|
|
190
|
+
return result.jsModule.findCallsInCode(result.virtualJS, result.jsParser);
|
|
191
|
+
}
|
|
192
|
+
|
|
193
|
+
function findCallbackUsages(code, name, parser) {
|
|
194
|
+
const result = extractJS(code, parser);
|
|
195
|
+
if (!result) return [];
|
|
196
|
+
return result.jsModule.findCallbackUsages(result.virtualJS, name, result.jsParser);
|
|
197
|
+
}
|
|
198
|
+
|
|
199
|
+
function findReExports(code, parser) {
|
|
200
|
+
const result = extractJS(code, parser);
|
|
201
|
+
if (!result) return [];
|
|
202
|
+
return result.jsModule.findReExports(result.virtualJS, result.jsParser);
|
|
203
|
+
}
|
|
204
|
+
|
|
205
|
+
function findImportsInCode(code, parser) {
|
|
206
|
+
const result = extractJS(code, parser);
|
|
207
|
+
if (!result) return [];
|
|
208
|
+
return result.jsModule.findImportsInCode(result.virtualJS, result.jsParser);
|
|
209
|
+
}
|
|
210
|
+
|
|
211
|
+
function findExportsInCode(code, parser) {
|
|
212
|
+
const result = extractJS(code, parser);
|
|
213
|
+
if (!result) return [];
|
|
214
|
+
return result.jsModule.findExportsInCode(result.virtualJS, result.jsParser);
|
|
215
|
+
}
|
|
216
|
+
|
|
217
|
+
function findUsagesInCode(code, name, parser) {
|
|
218
|
+
const result = extractJS(code, parser);
|
|
219
|
+
if (!result) return [];
|
|
220
|
+
return result.jsModule.findUsagesInCode(result.virtualJS, name, result.jsParser);
|
|
221
|
+
}
|
|
222
|
+
|
|
223
|
+
module.exports = {
|
|
224
|
+
parse,
|
|
225
|
+
findFunctions,
|
|
226
|
+
findClasses,
|
|
227
|
+
findStateObjects,
|
|
228
|
+
findCallsInCode,
|
|
229
|
+
findCallbackUsages,
|
|
230
|
+
findReExports,
|
|
231
|
+
findImportsInCode,
|
|
232
|
+
findExportsInCode,
|
|
233
|
+
findUsagesInCode,
|
|
234
|
+
// Exported for testing
|
|
235
|
+
extractScriptBlocks,
|
|
236
|
+
buildVirtualJSContent
|
|
237
|
+
};
|
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). 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.0",
|
|
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",
|
package/test/parser.test.js
CHANGED
|
@@ -12001,5 +12001,541 @@ module.exports = { process_data };
|
|
|
12001
12001
|
});
|
|
12002
12002
|
});
|
|
12003
12003
|
|
|
12004
|
+
// ============================================================================
|
|
12005
|
+
// HTML PARSING
|
|
12006
|
+
// ============================================================================
|
|
12007
|
+
|
|
12008
|
+
describe('HTML Parsing', () => {
|
|
12009
|
+
const { getParser, getLanguageModule } = require('../languages');
|
|
12010
|
+
|
|
12011
|
+
function getHtmlTools() {
|
|
12012
|
+
return {
|
|
12013
|
+
parser: getParser('html'),
|
|
12014
|
+
mod: getLanguageModule('html')
|
|
12015
|
+
};
|
|
12016
|
+
}
|
|
12017
|
+
|
|
12018
|
+
// -- Language detection --
|
|
12019
|
+
|
|
12020
|
+
it('detects HTML files', () => {
|
|
12021
|
+
assert.strictEqual(detectLanguage('file.html'), 'html');
|
|
12022
|
+
assert.strictEqual(detectLanguage('page.htm'), 'html');
|
|
12023
|
+
assert.strictEqual(detectLanguage('INDEX.HTML'), 'html');
|
|
12024
|
+
});
|
|
12025
|
+
|
|
12026
|
+
// -- Script extraction basics --
|
|
12027
|
+
|
|
12028
|
+
it('finds functions in a single script block', () => {
|
|
12029
|
+
const { parser, mod } = getHtmlTools();
|
|
12030
|
+
const html = '<html><body><script>\nfunction hello() { return 1; }\n</script></body></html>';
|
|
12031
|
+
const fns = mod.findFunctions(html, parser);
|
|
12032
|
+
assert.strictEqual(fns.length, 1);
|
|
12033
|
+
assert.strictEqual(fns[0].name, 'hello');
|
|
12034
|
+
});
|
|
12035
|
+
|
|
12036
|
+
it('finds functions from multiple script blocks', () => {
|
|
12037
|
+
const { parser, mod } = getHtmlTools();
|
|
12038
|
+
const html = `<script>
|
|
12039
|
+
function foo() {}
|
|
12040
|
+
</script>
|
|
12041
|
+
<div>content</div>
|
|
12042
|
+
<script>
|
|
12043
|
+
function bar() {}
|
|
12044
|
+
</script>`;
|
|
12045
|
+
const fns = mod.findFunctions(html, parser);
|
|
12046
|
+
const names = fns.map(f => f.name);
|
|
12047
|
+
assert.ok(names.includes('foo'), `Expected foo in ${names}`);
|
|
12048
|
+
assert.ok(names.includes('bar'), `Expected bar in ${names}`);
|
|
12049
|
+
});
|
|
12050
|
+
|
|
12051
|
+
it('returns empty results for HTML with no script tags', () => {
|
|
12052
|
+
const { parser, mod } = getHtmlTools();
|
|
12053
|
+
const html = '<html><body><h1>Hello</h1></body></html>';
|
|
12054
|
+
const result = mod.parse(html, parser);
|
|
12055
|
+
assert.strictEqual(result.functions.length, 0);
|
|
12056
|
+
assert.strictEqual(result.classes.length, 0);
|
|
12057
|
+
assert.strictEqual(result.language, 'html');
|
|
12058
|
+
});
|
|
12059
|
+
|
|
12060
|
+
it('returns empty results for empty script tag', () => {
|
|
12061
|
+
const { parser, mod } = getHtmlTools();
|
|
12062
|
+
const html = '<html><body><script></script></body></html>';
|
|
12063
|
+
const result = mod.parse(html, parser);
|
|
12064
|
+
assert.strictEqual(result.functions.length, 0);
|
|
12065
|
+
});
|
|
12066
|
+
|
|
12067
|
+
it('returns empty results when only external scripts present', () => {
|
|
12068
|
+
const { parser, mod } = getHtmlTools();
|
|
12069
|
+
const html = '<script src="app.js"></script>\n<script src="vendor.js"></script>';
|
|
12070
|
+
const result = mod.parse(html, parser);
|
|
12071
|
+
assert.strictEqual(result.functions.length, 0);
|
|
12072
|
+
});
|
|
12073
|
+
|
|
12074
|
+
// -- Type attribute filtering --
|
|
12075
|
+
|
|
12076
|
+
it('parses type="module" scripts', () => {
|
|
12077
|
+
const { parser, mod } = getHtmlTools();
|
|
12078
|
+
const html = '<script type="module">\nfunction modFn() {}\n</script>';
|
|
12079
|
+
const fns = mod.findFunctions(html, parser);
|
|
12080
|
+
assert.strictEqual(fns.length, 1);
|
|
12081
|
+
assert.strictEqual(fns[0].name, 'modFn');
|
|
12082
|
+
});
|
|
12083
|
+
|
|
12084
|
+
it('parses type="text/javascript" scripts', () => {
|
|
12085
|
+
const { parser, mod } = getHtmlTools();
|
|
12086
|
+
const html = '<script type="text/javascript">\nfunction textJsFn() {}\n</script>';
|
|
12087
|
+
const fns = mod.findFunctions(html, parser);
|
|
12088
|
+
assert.strictEqual(fns.length, 1);
|
|
12089
|
+
assert.strictEqual(fns[0].name, 'textJsFn');
|
|
12090
|
+
});
|
|
12091
|
+
|
|
12092
|
+
it('parses type="application/javascript" scripts', () => {
|
|
12093
|
+
const { parser, mod } = getHtmlTools();
|
|
12094
|
+
const html = '<script type="application/javascript">\nfunction appJsFn() {}\n</script>';
|
|
12095
|
+
const fns = mod.findFunctions(html, parser);
|
|
12096
|
+
assert.strictEqual(fns.length, 1);
|
|
12097
|
+
assert.strictEqual(fns[0].name, 'appJsFn');
|
|
12098
|
+
});
|
|
12099
|
+
|
|
12100
|
+
it('skips type="application/json" scripts', () => {
|
|
12101
|
+
const { parser, mod } = getHtmlTools();
|
|
12102
|
+
const html = '<script type="application/json">{"key": "value"}</script>\n<script>\nfunction realFn() {}\n</script>';
|
|
12103
|
+
const fns = mod.findFunctions(html, parser);
|
|
12104
|
+
assert.strictEqual(fns.length, 1);
|
|
12105
|
+
assert.strictEqual(fns[0].name, 'realFn');
|
|
12106
|
+
});
|
|
12107
|
+
|
|
12108
|
+
it('skips type="importmap" scripts', () => {
|
|
12109
|
+
const { parser, mod } = getHtmlTools();
|
|
12110
|
+
const html = '<script type="importmap">{"imports": {}}</script>';
|
|
12111
|
+
const result = mod.parse(html, parser);
|
|
12112
|
+
assert.strictEqual(result.functions.length, 0);
|
|
12113
|
+
});
|
|
12114
|
+
|
|
12115
|
+
it('parses scripts with no type attribute (default is JS)', () => {
|
|
12116
|
+
const { parser, mod } = getHtmlTools();
|
|
12117
|
+
const html = '<script>\nfunction defaultFn() {}\n</script>';
|
|
12118
|
+
const fns = mod.findFunctions(html, parser);
|
|
12119
|
+
assert.strictEqual(fns.length, 1);
|
|
12120
|
+
assert.strictEqual(fns[0].name, 'defaultFn');
|
|
12121
|
+
});
|
|
12122
|
+
|
|
12123
|
+
it('skips scripts with src attribute', () => {
|
|
12124
|
+
const { parser, mod } = getHtmlTools();
|
|
12125
|
+
const html = '<script src="app.js"></script>\n<script>\nfunction inlineFn() {}\n</script>';
|
|
12126
|
+
const fns = mod.findFunctions(html, parser);
|
|
12127
|
+
assert.strictEqual(fns.length, 1);
|
|
12128
|
+
assert.strictEqual(fns[0].name, 'inlineFn');
|
|
12129
|
+
});
|
|
12130
|
+
|
|
12131
|
+
// -- Line number accuracy --
|
|
12132
|
+
|
|
12133
|
+
it('reports correct line numbers for functions', () => {
|
|
12134
|
+
const { parser, mod } = getHtmlTools();
|
|
12135
|
+
const html = `<html>
|
|
12136
|
+
<head>
|
|
12137
|
+
<title>Test</title>
|
|
12138
|
+
</head>
|
|
12139
|
+
<body>
|
|
12140
|
+
<script>
|
|
12141
|
+
function atLine7() { return 1; }
|
|
12142
|
+
function atLine8() { return 2; }
|
|
12143
|
+
</script>
|
|
12144
|
+
</body>
|
|
12145
|
+
</html>`;
|
|
12146
|
+
const fns = mod.findFunctions(html, parser);
|
|
12147
|
+
const fn7 = fns.find(f => f.name === 'atLine7');
|
|
12148
|
+
const fn8 = fns.find(f => f.name === 'atLine8');
|
|
12149
|
+
assert.ok(fn7, 'atLine7 should be found');
|
|
12150
|
+
assert.ok(fn8, 'atLine8 should be found');
|
|
12151
|
+
assert.strictEqual(fn7.startLine, 7, `atLine7 should be on line 7, got ${fn7.startLine}`);
|
|
12152
|
+
assert.strictEqual(fn8.startLine, 8, `atLine8 should be on line 8, got ${fn8.startLine}`);
|
|
12153
|
+
});
|
|
12154
|
+
|
|
12155
|
+
it('reports correct line numbers across multiple script blocks with HTML gaps', () => {
|
|
12156
|
+
const { parser, mod } = getHtmlTools();
|
|
12157
|
+
const html = `<script>
|
|
12158
|
+
function first() {}
|
|
12159
|
+
</script>
|
|
12160
|
+
<div>gap line 4</div>
|
|
12161
|
+
<div>gap line 5</div>
|
|
12162
|
+
<script>
|
|
12163
|
+
function second() {}
|
|
12164
|
+
</script>`;
|
|
12165
|
+
const fns = mod.findFunctions(html, parser);
|
|
12166
|
+
const first = fns.find(f => f.name === 'first');
|
|
12167
|
+
const second = fns.find(f => f.name === 'second');
|
|
12168
|
+
assert.ok(first && second, 'Both functions should be found');
|
|
12169
|
+
assert.strictEqual(first.startLine, 2, `first should be at line 2, got ${first.startLine}`);
|
|
12170
|
+
assert.strictEqual(second.startLine, 7, `second should be at line 7, got ${second.startLine}`);
|
|
12171
|
+
});
|
|
12172
|
+
|
|
12173
|
+
it('reports correct line numbers for classes', () => {
|
|
12174
|
+
const { parser, mod } = getHtmlTools();
|
|
12175
|
+
const html = `<html>
|
|
12176
|
+
<body>
|
|
12177
|
+
<script>
|
|
12178
|
+
class MyApp {
|
|
12179
|
+
constructor() {}
|
|
12180
|
+
render() {}
|
|
12181
|
+
}
|
|
12182
|
+
</script>
|
|
12183
|
+
</body>
|
|
12184
|
+
</html>`;
|
|
12185
|
+
const classes = mod.findClasses(html, parser);
|
|
12186
|
+
assert.strictEqual(classes.length, 1);
|
|
12187
|
+
assert.strictEqual(classes[0].name, 'MyApp');
|
|
12188
|
+
assert.strictEqual(classes[0].startLine, 4, `MyApp should start at line 4, got ${classes[0].startLine}`);
|
|
12189
|
+
});
|
|
12190
|
+
|
|
12191
|
+
it('reports correct line numbers for state objects', () => {
|
|
12192
|
+
const { parser, mod } = getHtmlTools();
|
|
12193
|
+
const html = `<html>
|
|
12194
|
+
<head>
|
|
12195
|
+
<script>
|
|
12196
|
+
const CONFIG = { debug: true, version: '1.0' };
|
|
12197
|
+
</script>
|
|
12198
|
+
</head>
|
|
12199
|
+
</html>`;
|
|
12200
|
+
const states = mod.findStateObjects(html, parser);
|
|
12201
|
+
const config = states.find(s => s.name === 'CONFIG');
|
|
12202
|
+
assert.ok(config, 'CONFIG should be found');
|
|
12203
|
+
assert.strictEqual(config.startLine, 4, `CONFIG should be at line 4, got ${config.startLine}`);
|
|
12204
|
+
});
|
|
12205
|
+
|
|
12206
|
+
// -- Feature integration --
|
|
12207
|
+
|
|
12208
|
+
it('detects function calls between functions', () => {
|
|
12209
|
+
const { parser, mod } = getHtmlTools();
|
|
12210
|
+
const html = `<script>
|
|
12211
|
+
function initApp() { renderUI(); loadData(); }
|
|
12212
|
+
function renderUI() { console.log('render'); }
|
|
12213
|
+
function loadData() { fetch('/api'); }
|
|
12214
|
+
</script>`;
|
|
12215
|
+
const calls = mod.findCallsInCode(html, parser);
|
|
12216
|
+
const callNames = calls.map(c => c.name);
|
|
12217
|
+
assert.ok(callNames.includes('renderUI'), `Expected renderUI call, got: ${callNames}`);
|
|
12218
|
+
assert.ok(callNames.includes('loadData'), `Expected loadData call, got: ${callNames}`);
|
|
12219
|
+
});
|
|
12220
|
+
|
|
12221
|
+
it('detects cross-block function calls', () => {
|
|
12222
|
+
const { parser, mod } = getHtmlTools();
|
|
12223
|
+
const html = `<script>
|
|
12224
|
+
function initApp() { renderUI(); }
|
|
12225
|
+
</script>
|
|
12226
|
+
<div>separator</div>
|
|
12227
|
+
<script>
|
|
12228
|
+
function renderUI() { console.log('render'); }
|
|
12229
|
+
</script>`;
|
|
12230
|
+
const calls = mod.findCallsInCode(html, parser);
|
|
12231
|
+
const callNames = calls.map(c => c.name);
|
|
12232
|
+
assert.ok(callNames.includes('renderUI'), `Expected cross-block renderUI call, got: ${callNames}`);
|
|
12233
|
+
});
|
|
12234
|
+
|
|
12235
|
+
it('finds usages within script blocks', () => {
|
|
12236
|
+
const { parser, mod } = getHtmlTools();
|
|
12237
|
+
const html = `<script>
|
|
12238
|
+
const API_URL = '/api';
|
|
12239
|
+
function fetchData() { return fetch(API_URL); }
|
|
12240
|
+
</script>`;
|
|
12241
|
+
const usages = mod.findUsagesInCode(html, 'API_URL', parser);
|
|
12242
|
+
assert.ok(usages.length >= 1, `Expected at least 1 usage of API_URL, got ${usages.length}`);
|
|
12243
|
+
});
|
|
12244
|
+
|
|
12245
|
+
it('finds imports in type="module" scripts', () => {
|
|
12246
|
+
const { parser, mod } = getHtmlTools();
|
|
12247
|
+
const html = `<script type="module">
|
|
12248
|
+
import { createApp } from './app.js';
|
|
12249
|
+
createApp();
|
|
12250
|
+
</script>`;
|
|
12251
|
+
const imports = mod.findImportsInCode(html, parser);
|
|
12252
|
+
assert.ok(imports.length >= 1, `Expected imports, got ${imports.length}`);
|
|
12253
|
+
});
|
|
12254
|
+
|
|
12255
|
+
it('finds classes and state objects', () => {
|
|
12256
|
+
const { parser, mod } = getHtmlTools();
|
|
12257
|
+
const html = `<script>
|
|
12258
|
+
const SETTINGS = { theme: 'dark', lang: 'en' };
|
|
12259
|
+
class GameEngine {
|
|
12260
|
+
constructor() {}
|
|
12261
|
+
start() {}
|
|
12262
|
+
}
|
|
12263
|
+
</script>`;
|
|
12264
|
+
const result = mod.parse(html, parser);
|
|
12265
|
+
assert.ok(result.classes.length >= 1, 'Should find GameEngine class');
|
|
12266
|
+
assert.strictEqual(result.classes[0].name, 'GameEngine');
|
|
12267
|
+
const settings = result.stateObjects.find(s => s.name === 'SETTINGS');
|
|
12268
|
+
assert.ok(settings, 'Should find SETTINGS state object');
|
|
12269
|
+
});
|
|
12270
|
+
|
|
12271
|
+
// -- Edge cases --
|
|
12272
|
+
|
|
12273
|
+
it('handles script content on same line as script tag (column offset)', () => {
|
|
12274
|
+
const { parser, mod } = getHtmlTools();
|
|
12275
|
+
const html = '<div><script>function inline() { return 42; }</script></div>';
|
|
12276
|
+
const fns = mod.findFunctions(html, parser);
|
|
12277
|
+
assert.strictEqual(fns.length, 1);
|
|
12278
|
+
assert.strictEqual(fns[0].name, 'inline');
|
|
12279
|
+
});
|
|
12280
|
+
|
|
12281
|
+
it('handles mixed JS and non-JS script blocks in same file', () => {
|
|
12282
|
+
const { parser, mod } = getHtmlTools();
|
|
12283
|
+
const html = `<script type="application/json">{"not": "js"}</script>
|
|
12284
|
+
<script type="importmap">{"imports": {"a": "b"}}</script>
|
|
12285
|
+
<script>function realJS() {}</script>
|
|
12286
|
+
<script type="text/template"><div>{{template}}</div></script>`;
|
|
12287
|
+
const fns = mod.findFunctions(html, parser);
|
|
12288
|
+
assert.strictEqual(fns.length, 1);
|
|
12289
|
+
assert.strictEqual(fns[0].name, 'realJS');
|
|
12290
|
+
});
|
|
12291
|
+
|
|
12292
|
+
it('parse() returns language html', () => {
|
|
12293
|
+
const { parser, mod } = getHtmlTools();
|
|
12294
|
+
const html = '<script>var x = 1;</script>';
|
|
12295
|
+
const result = mod.parse(html, parser);
|
|
12296
|
+
assert.strictEqual(result.language, 'html');
|
|
12297
|
+
});
|
|
12298
|
+
|
|
12299
|
+
it('totalLines matches HTML file line count, not JS line count', () => {
|
|
12300
|
+
const { parser, mod } = getHtmlTools();
|
|
12301
|
+
const html = `<html>
|
|
12302
|
+
<head>
|
|
12303
|
+
<title>Page</title>
|
|
12304
|
+
</head>
|
|
12305
|
+
<body>
|
|
12306
|
+
<script>
|
|
12307
|
+
var x = 1;
|
|
12308
|
+
</script>
|
|
12309
|
+
</body>
|
|
12310
|
+
</html>`;
|
|
12311
|
+
const result = mod.parse(html, parser);
|
|
12312
|
+
assert.strictEqual(result.totalLines, 10, `Expected 10 lines, got ${result.totalLines}`);
|
|
12313
|
+
});
|
|
12314
|
+
|
|
12315
|
+
// -- Project integration --
|
|
12316
|
+
|
|
12317
|
+
it('indexes HTML files in project mode', () => {
|
|
12318
|
+
const tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), 'ucn-html-project-'));
|
|
12319
|
+
fs.writeFileSync(path.join(tmpDir, 'index.html'), `<html>
|
|
12320
|
+
<body>
|
|
12321
|
+
<script>
|
|
12322
|
+
function initApp() { renderUI(); }
|
|
12323
|
+
function renderUI() { console.log('hello'); }
|
|
12324
|
+
const CONFIG = { debug: true };
|
|
12325
|
+
</script>
|
|
12326
|
+
</body>
|
|
12327
|
+
</html>`);
|
|
12328
|
+
fs.writeFileSync(path.join(tmpDir, 'package.json'), '{"name":"test"}');
|
|
12329
|
+
|
|
12330
|
+
const { ProjectIndex } = require('../core/project');
|
|
12331
|
+
const index = new ProjectIndex(tmpDir);
|
|
12332
|
+
index.build(null, { quiet: true });
|
|
12333
|
+
|
|
12334
|
+
// Functions should be found
|
|
12335
|
+
const initDefs = index.find('initApp');
|
|
12336
|
+
assert.ok(initDefs.length > 0, 'initApp should be found in project index');
|
|
12337
|
+
assert.strictEqual(initDefs[0].startLine, 4, 'initApp should be at line 4');
|
|
12338
|
+
|
|
12339
|
+
// Callers should work
|
|
12340
|
+
const renderDefs = index.find('renderUI');
|
|
12341
|
+
assert.ok(renderDefs.length > 0, 'renderUI should be found');
|
|
12342
|
+
const callers = index.findCallers('renderUI');
|
|
12343
|
+
const callerNames = callers.map(c => c.callerName);
|
|
12344
|
+
assert.ok(callerNames.includes('initApp'), `initApp should call renderUI, got: ${callerNames}`);
|
|
12345
|
+
|
|
12346
|
+
// State objects should be found
|
|
12347
|
+
const configDefs = index.find('CONFIG');
|
|
12348
|
+
assert.ok(configDefs.length > 0, 'CONFIG should be found');
|
|
12349
|
+
|
|
12350
|
+
fs.rmSync(tmpDir, { recursive: true });
|
|
12351
|
+
});
|
|
12352
|
+
|
|
12353
|
+
it('extractCode works with HTML files', () => {
|
|
12354
|
+
const tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), 'ucn-html-extract-'));
|
|
12355
|
+
const htmlContent = `<html>
|
|
12356
|
+
<body>
|
|
12357
|
+
<script>
|
|
12358
|
+
function greet(name) {
|
|
12359
|
+
return 'Hello ' + name;
|
|
12360
|
+
}
|
|
12361
|
+
</script>
|
|
12362
|
+
</body>
|
|
12363
|
+
</html>`;
|
|
12364
|
+
fs.writeFileSync(path.join(tmpDir, 'page.html'), htmlContent);
|
|
12365
|
+
fs.writeFileSync(path.join(tmpDir, 'package.json'), '{"name":"test"}');
|
|
12366
|
+
|
|
12367
|
+
const { ProjectIndex } = require('../core/project');
|
|
12368
|
+
const index = new ProjectIndex(tmpDir);
|
|
12369
|
+
index.build(null, { quiet: true });
|
|
12370
|
+
|
|
12371
|
+
const defs = index.find('greet');
|
|
12372
|
+
assert.ok(defs.length > 0, 'greet should be found');
|
|
12373
|
+
const code = index.extractCode(defs[0]);
|
|
12374
|
+
assert.ok(code.includes('function greet'), `Extracted code should contain function: ${code}`);
|
|
12375
|
+
assert.ok(code.includes('Hello'), `Extracted code should contain body: ${code}`);
|
|
12376
|
+
|
|
12377
|
+
fs.rmSync(tmpDir, { recursive: true });
|
|
12378
|
+
});
|
|
12379
|
+
|
|
12380
|
+
// -- extractScriptBlocks and buildVirtualJSContent unit tests --
|
|
12381
|
+
|
|
12382
|
+
it('extractScriptBlocks returns correct block positions', () => {
|
|
12383
|
+
const { extractScriptBlocks } = require('../languages/html');
|
|
12384
|
+
const parser = getParser('html');
|
|
12385
|
+
const html = `<html>
|
|
12386
|
+
<body>
|
|
12387
|
+
<script>
|
|
12388
|
+
var x = 1;
|
|
12389
|
+
</script>
|
|
12390
|
+
</body>
|
|
12391
|
+
</html>`;
|
|
12392
|
+
const blocks = extractScriptBlocks(html, parser);
|
|
12393
|
+
assert.strictEqual(blocks.length, 1);
|
|
12394
|
+
assert.strictEqual(blocks[0].startRow, 2, `Block should start at row 2 (0-indexed, raw_text starts after <script> closing >), got ${blocks[0].startRow}`);
|
|
12395
|
+
assert.ok(blocks[0].text.includes('var x = 1'), `Block text should contain JS: ${blocks[0].text}`);
|
|
12396
|
+
});
|
|
12397
|
+
|
|
12398
|
+
it('buildVirtualJSContent preserves line positions', () => {
|
|
12399
|
+
const { buildVirtualJSContent } = require('../languages/html');
|
|
12400
|
+
const html = `line0
|
|
12401
|
+
line1
|
|
12402
|
+
<script>
|
|
12403
|
+
var x = 1;
|
|
12404
|
+
</script>
|
|
12405
|
+
line5`;
|
|
12406
|
+
const blocks = [{ text: '\nvar x = 1;\n', startRow: 2, startCol: 8 }];
|
|
12407
|
+
const virtual = buildVirtualJSContent(html, blocks);
|
|
12408
|
+
const lines = virtual.split('\n');
|
|
12409
|
+
assert.strictEqual(lines.length, 6, `Should have 6 lines, got ${lines.length}`);
|
|
12410
|
+
assert.strictEqual(lines[0], '', 'Line 0 should be empty (HTML)');
|
|
12411
|
+
assert.strictEqual(lines[1], '', 'Line 1 should be empty (HTML)');
|
|
12412
|
+
assert.strictEqual(lines[3].trim(), 'var x = 1;', 'Line 3 should have JS content');
|
|
12413
|
+
assert.strictEqual(lines[5], '', 'Line 5 should be empty (HTML)');
|
|
12414
|
+
});
|
|
12415
|
+
|
|
12416
|
+
// Bug fix tests
|
|
12417
|
+
// ─────────────────────────────────────────────────────────────
|
|
12418
|
+
|
|
12419
|
+
it('cleanHtmlScriptTags strips script tags from same-line scripts', () => {
|
|
12420
|
+
const { cleanHtmlScriptTags } = require('../core/parser');
|
|
12421
|
+
|
|
12422
|
+
// Same-line script: <script>function foo() { return 1; }</script>
|
|
12423
|
+
const lines1 = ['<script>function foo() { return 1; }</script>'];
|
|
12424
|
+
cleanHtmlScriptTags(lines1, 'html');
|
|
12425
|
+
assert.strictEqual(lines1[0], 'function foo() { return 1; }');
|
|
12426
|
+
|
|
12427
|
+
// Multi-line: only first/last lines affected
|
|
12428
|
+
const lines2 = [' <script type="module">', ' function bar() {', ' }', ' </script>'];
|
|
12429
|
+
cleanHtmlScriptTags(lines2, 'html');
|
|
12430
|
+
assert.strictEqual(lines2[0], ' ', 'First line should have only indentation');
|
|
12431
|
+
assert.strictEqual(lines2[1], ' function bar() {', 'Middle lines unchanged');
|
|
12432
|
+
assert.strictEqual(lines2[3], ' ', 'Last line should have only indentation');
|
|
12433
|
+
|
|
12434
|
+
// Non-HTML language: no changes
|
|
12435
|
+
const lines3 = ['<script>function foo() {}</script>'];
|
|
12436
|
+
cleanHtmlScriptTags(lines3, 'javascript');
|
|
12437
|
+
assert.strictEqual(lines3[0], '<script>function foo() {}</script>', 'Non-HTML should be unchanged');
|
|
12438
|
+
|
|
12439
|
+
// Uppercase SCRIPT tag
|
|
12440
|
+
const lines4 = ['<SCRIPT>function foo() {}</SCRIPT>'];
|
|
12441
|
+
cleanHtmlScriptTags(lines4, 'html');
|
|
12442
|
+
assert.strictEqual(lines4[0], 'function foo() {}');
|
|
12443
|
+
});
|
|
12444
|
+
|
|
12445
|
+
it('extractCode strips script tags for HTML files', () => {
|
|
12446
|
+
const tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), 'ucn-html-extract-'));
|
|
12447
|
+
const htmlFile = path.join(tmpDir, 'test.html');
|
|
12448
|
+
fs.writeFileSync(htmlFile, `<!DOCTYPE html>
|
|
12449
|
+
<html>
|
|
12450
|
+
<body>
|
|
12451
|
+
<script>function oneLiner() { return 42; }</script>
|
|
12452
|
+
<script>
|
|
12453
|
+
function multiLine() {
|
|
12454
|
+
return 99;
|
|
12455
|
+
}
|
|
12456
|
+
</script>
|
|
12457
|
+
</body>
|
|
12458
|
+
</html>`);
|
|
12459
|
+
|
|
12460
|
+
const { ProjectIndex } = require('../core/project');
|
|
12461
|
+
const index = new ProjectIndex(tmpDir);
|
|
12462
|
+
index.build();
|
|
12463
|
+
|
|
12464
|
+
// Find oneLiner and check its extracted code
|
|
12465
|
+
const oneLineDefs = index.find('oneLiner');
|
|
12466
|
+
assert.ok(oneLineDefs.length > 0, 'Should find oneLiner');
|
|
12467
|
+
const code = index.extractCode(oneLineDefs[0]);
|
|
12468
|
+
assert.ok(!code.includes('<script>'), 'extractCode should not include <script> tag');
|
|
12469
|
+
assert.ok(!code.includes('</script>'), 'extractCode should not include </script> tag');
|
|
12470
|
+
assert.ok(code.includes('function oneLiner'), 'extractCode should include function body');
|
|
12471
|
+
|
|
12472
|
+
// Multi-line function should not be affected
|
|
12473
|
+
const multiDefs = index.find('multiLine');
|
|
12474
|
+
assert.ok(multiDefs.length > 0, 'Should find multiLine');
|
|
12475
|
+
const multiCode = index.extractCode(multiDefs[0]);
|
|
12476
|
+
assert.ok(!multiCode.includes('<script>'), 'Multi-line extractCode should not include <script>');
|
|
12477
|
+
assert.ok(multiCode.includes('function multiLine'), 'Multi-line extractCode should include function body');
|
|
12478
|
+
|
|
12479
|
+
fs.rmSync(tmpDir, { recursive: true, force: true });
|
|
12480
|
+
});
|
|
12481
|
+
|
|
12482
|
+
it('smart respects --file disambiguation', () => {
|
|
12483
|
+
const tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), 'ucn-html-smart-'));
|
|
12484
|
+
// Create two files with same function name
|
|
12485
|
+
fs.writeFileSync(path.join(tmpDir, 'a.html'), `<html><body>
|
|
12486
|
+
<script>
|
|
12487
|
+
function myFunc() { return 'from html'; }
|
|
12488
|
+
</script>
|
|
12489
|
+
</body></html>`);
|
|
12490
|
+
fs.writeFileSync(path.join(tmpDir, 'b.js'), `function myFunc() { return 'from js'; }\n`);
|
|
12491
|
+
// Need package.json for discovery
|
|
12492
|
+
fs.writeFileSync(path.join(tmpDir, 'package.json'), '{"name": "test"}');
|
|
12493
|
+
|
|
12494
|
+
const { ProjectIndex } = require('../core/project');
|
|
12495
|
+
const index = new ProjectIndex(tmpDir);
|
|
12496
|
+
index.build();
|
|
12497
|
+
|
|
12498
|
+
// Without --file, picks best scoring (b.js is not in tests/ so it should win)
|
|
12499
|
+
const result1 = index.smart('myFunc');
|
|
12500
|
+
assert.ok(result1, 'smart should find myFunc');
|
|
12501
|
+
|
|
12502
|
+
// With --file=a.html, should pick the HTML file
|
|
12503
|
+
const result2 = index.smart('myFunc', { file: 'a.html' });
|
|
12504
|
+
assert.ok(result2, 'smart with file filter should find myFunc');
|
|
12505
|
+
assert.ok(result2.target.file.endsWith('a.html'), `Should resolve to a.html, got ${result2.target.file}`);
|
|
12506
|
+
|
|
12507
|
+
// With --file=b.js, should pick the JS file
|
|
12508
|
+
const result3 = index.smart('myFunc', { file: 'b.js' });
|
|
12509
|
+
assert.ok(result3, 'smart with file filter should find myFunc in b.js');
|
|
12510
|
+
assert.ok(result3.target.file.endsWith('b.js'), `Should resolve to b.js, got ${result3.target.file}`);
|
|
12511
|
+
|
|
12512
|
+
fs.rmSync(tmpDir, { recursive: true, force: true });
|
|
12513
|
+
});
|
|
12514
|
+
|
|
12515
|
+
it('deadcode buildUsageIndex parses HTML inline scripts', () => {
|
|
12516
|
+
const tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), 'ucn-html-deadcode-'));
|
|
12517
|
+
fs.writeFileSync(path.join(tmpDir, 'app.html'), `<html><body>
|
|
12518
|
+
<script>
|
|
12519
|
+
function helper() { return 42; }
|
|
12520
|
+
function main() { return helper(); }
|
|
12521
|
+
</script>
|
|
12522
|
+
</body></html>`);
|
|
12523
|
+
fs.writeFileSync(path.join(tmpDir, 'package.json'), '{"name": "test"}');
|
|
12524
|
+
|
|
12525
|
+
const { ProjectIndex } = require('../core/project');
|
|
12526
|
+
const index = new ProjectIndex(tmpDir);
|
|
12527
|
+
index.build();
|
|
12528
|
+
|
|
12529
|
+
// helper is called by main, so it should NOT be dead code
|
|
12530
|
+
const dead = index.deadcode({ includeExported: true });
|
|
12531
|
+
const deadNames = dead.map(d => d.name);
|
|
12532
|
+
assert.ok(!deadNames.includes('helper'), `helper should not be dead code (called by main), got: ${deadNames.join(', ')}`);
|
|
12533
|
+
// main has no callers, so it should be dead
|
|
12534
|
+
assert.ok(deadNames.includes('main'), 'main should be dead code (no callers)');
|
|
12535
|
+
|
|
12536
|
+
fs.rmSync(tmpDir, { recursive: true, force: true });
|
|
12537
|
+
});
|
|
12538
|
+
});
|
|
12539
|
+
|
|
12004
12540
|
console.log('UCN v3 Test Suite');
|
|
12005
12541
|
console.log('Run with: node --test test/parser.test.js');
|