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.
@@ -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
- 5 languages only │ JS/TS, Python, Go, Rust, Java.
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 fnCode = lines.slice(m.startLine - 1, m.endLine).join('\n');
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 fnCode = lines.slice(match.startLine - 1, match.endLine).join('\n');
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 clsCode = codeLines.slice(m.startLine - 1, m.endLine).join('\n');
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 clsCode = codeLines.slice(match.startLine - 1, match.endLine).join('\n');
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 fnCode = lines.slice(fn.startLine - 1, fn.endLine).join('\n');
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 clsCode = lines.slice(cls.startLine - 1, cls.endLine).join('\n');
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
- return lines.slice(symbol.startLine - 1, symbol.endLine).join('\n');
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
- const tree = parser.parse(content, undefined, PARSE_OPTIONS);
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
+ };
@@ -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
- if (!e.message?.toLowerCase().includes('buffer') &&
242
- !e.message?.toLowerCase().includes('memory') &&
243
- !e.message?.toLowerCase().includes('alloc')) {
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 Java. Use instead of grep/read for code relationships.
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.6.28",
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",
@@ -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');