ucn 3.7.0 → 3.7.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -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, HTML. 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 JS/TS, Python, Go, Rust, Java, HTML. Skip for plain text search or codebases under 500 LOC."
4
4
  allowed-tools: Bash(ucn *), Bash(npx ucn *)
5
5
  argument-hint: "[command] [symbol-name] [--flags]"
6
6
  ---
7
7
 
8
8
  # UCN — Universal Code Navigator
9
9
 
10
- Understands code structure via tree-sitter ASTs: who calls what, what breaks if you change something, full call trees, dead code. Works on Python, JS/TS, Go, Rust, Java, HTML (inline scripts).
10
+ Understands code structure via tree-sitter ASTs: who calls what, what breaks if you change something, full call trees, dead code. Works on JS/TS, Python, Go, Rust, Java. Also parses HTML files (inline scripts and event handlers).
11
11
 
12
12
  ## When to Reach for UCN Instead of Grep/Read
13
13
 
@@ -23,7 +23,7 @@ Understands code structure via tree-sitter ASTs: who calls what, what breaks if
23
23
 
24
24
  - Searching for a string literal, error message, TODO, or config value
25
25
  - The codebase is under 500 LOC — just read the files
26
- - Language not supported (only Python, JS/TS, Go, Rust, Java, HTML)
26
+ - Language not supported (only JS/TS, Python, Go, Rust, Java, HTML)
27
27
  - Finding files by name — use glob
28
28
 
29
29
  ## The 5 Commands You'll Use Most
package/README.md CHANGED
@@ -2,6 +2,8 @@
2
2
 
3
3
  UCN is designed to work with large files and codebases, helping AI agents ingest exactly the data they need. Its surgical output discourages agents from cutting corners, and without UCN, agents working with large codebases tend to skip parts of the code structure, assuming they have "enough data."
4
4
 
5
+ Supported languages: JS/TS, Python, Go, Rust, Java. Also parses HTML files (inline scripts and event handlers).
6
+
5
7
  ---
6
8
 
7
9
  ## Three Ways to Use UCN
@@ -91,9 +93,10 @@ Instead of reading entire files, ask precise questions:
91
93
 
92
94
  tree-sitter AST
93
95
 
94
- ┌─────────────┴─────────────┐
95
- Supported Languages
96
- └───────────────────────────┘
96
+ ┌───────────────────┴─────────────────┐
97
+ Supported Languages
98
+ │ JS/TS, Python, Go, Rust, Java, HTML │
99
+ └─────────────────────────────────────┘
97
100
  ```
98
101
 
99
102
  No cloud. No API keys. Parses locally, stays local.
@@ -496,7 +499,7 @@ ucn toc # Project overview
496
499
  │ Limitation │ What happens │
497
500
  ├──────────────────────────┼──────────────────────────────────────────┤
498
501
  │ │ │
499
- 6 languages only │ JS/TS, Python, Go, Rust, Java, HTML.
502
+ 5 languages + HTML │ JS/TS, Python, Go, Rust, Java.
500
503
  │ (no C, Ruby, PHP, etc.) │ Agents fall back to grep for the rest. │
501
504
  │ │ UCN complements, doesn't replace. │
502
505
  │ │ │
package/core/project.js CHANGED
@@ -2617,9 +2617,11 @@ class ProjectIndex {
2617
2617
  const htmlParser = getParser('html');
2618
2618
  const jsParser = getParser('javascript');
2619
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);
2620
+ if (blocks.length === 0 && !htmlModule.extractEventHandlerCalls) continue;
2621
+ if (blocks.length > 0) {
2622
+ const virtualJS = htmlModule.buildVirtualJSContent(content, blocks);
2623
+ tree = jsParser.parse(virtualJS, undefined, PARSE_OPTIONS);
2624
+ }
2623
2625
  } else {
2624
2626
  const parser = getParser(language);
2625
2627
  if (!parser) continue;
@@ -2649,7 +2651,25 @@ class ProjectIndex {
2649
2651
  traverse(node.child(i));
2650
2652
  }
2651
2653
  };
2652
- traverse(tree.rootNode);
2654
+ if (tree) traverse(tree.rootNode);
2655
+
2656
+ // For HTML files, also extract identifiers from event handler attributes
2657
+ // (onclick="foo()" etc. — these are in HTML, not in <script> blocks)
2658
+ if (language === 'html') {
2659
+ const htmlModule = getLanguageModule('html');
2660
+ const htmlParser = getParser('html');
2661
+ const handlerCalls = htmlModule.extractEventHandlerCalls(content, htmlParser);
2662
+ for (const call of handlerCalls) {
2663
+ if (!usageIndex.has(call.name)) {
2664
+ usageIndex.set(call.name, []);
2665
+ }
2666
+ usageIndex.get(call.name).push({
2667
+ file: filePath,
2668
+ line: call.line,
2669
+ relativePath: fileEntry.relativePath
2670
+ });
2671
+ }
2672
+ }
2653
2673
  } catch (e) {
2654
2674
  // Skip files that can't be processed
2655
2675
  }
package/languages/html.js CHANGED
@@ -144,6 +144,81 @@ function extractJS(htmlContent, htmlParser) {
144
144
  return { virtualJS, jsParser, jsModule };
145
145
  }
146
146
 
147
+ /**
148
+ * Extract function calls from HTML event handler attributes (onclick, onchange, etc.).
149
+ * Walks the HTML AST for elements with on* attributes, extracts function names
150
+ * from the attribute values using regex, and returns call objects.
151
+ *
152
+ * @param {string} htmlContent - Raw HTML source
153
+ * @param {object} htmlParser - tree-sitter parser configured for HTML
154
+ * @returns {Array<{name: string, line: number, isMethod: boolean, enclosingFunction: null, uncertain: boolean, isEventHandler: boolean}>}
155
+ */
156
+ function extractEventHandlerCalls(htmlContent, htmlParser) {
157
+ const { safeParse, getParseOptions } = require('./index');
158
+ const tree = safeParse(htmlParser, htmlContent, undefined, getParseOptions(htmlContent.length));
159
+ const calls = [];
160
+
161
+ const JS_KEYWORDS = new Set([
162
+ 'if', 'for', 'while', 'switch', 'catch', 'function', 'return',
163
+ 'typeof', 'void', 'delete', 'new', 'throw', 'class', 'const',
164
+ 'let', 'var', 'true', 'false', 'null', 'undefined', 'this'
165
+ ]);
166
+
167
+ const visit = (node) => {
168
+ // Skip script elements — their content is handled separately
169
+ if (node.type === 'script_element') return;
170
+
171
+ if (node.type === 'attribute') {
172
+ const nameNode = node.children.find(c => c.type === 'attribute_name');
173
+ if (!nameNode || !nameNode.text.toLowerCase().startsWith('on')) {
174
+ for (let i = 0; i < node.childCount; i++) visit(node.child(i));
175
+ return;
176
+ }
177
+
178
+ const valueNode = node.children.find(c =>
179
+ c.type === 'quoted_attribute_value' || c.type === 'attribute_value'
180
+ );
181
+ if (!valueNode) return;
182
+
183
+ let valueText;
184
+ if (valueNode.type === 'quoted_attribute_value') {
185
+ const inner = valueNode.children.find(c => c.type === 'attribute_value');
186
+ valueText = inner ? inner.text : '';
187
+ } else {
188
+ valueText = valueNode.text;
189
+ }
190
+ if (!valueText) return;
191
+
192
+ const line = nameNode.startPosition.row + 1; // 1-indexed
193
+
194
+ // Extract standalone function calls (not method calls like obj.method())
195
+ const regex = /([a-zA-Z_$][a-zA-Z0-9_$]*)\s*\(/g;
196
+ let match;
197
+ while ((match = regex.exec(valueText)) !== null) {
198
+ const fnName = match[1];
199
+ if (JS_KEYWORDS.has(fnName)) continue;
200
+ // Skip if preceded by dot (method call on object)
201
+ if (match.index > 0 && valueText[match.index - 1] === '.') continue;
202
+
203
+ calls.push({
204
+ name: fnName,
205
+ line,
206
+ isMethod: false,
207
+ enclosingFunction: null,
208
+ uncertain: false,
209
+ isEventHandler: true
210
+ });
211
+ }
212
+ return;
213
+ }
214
+
215
+ for (let i = 0; i < node.childCount; i++) visit(node.child(i));
216
+ };
217
+
218
+ visit(tree.rootNode);
219
+ return calls;
220
+ }
221
+
147
222
  // ── Exported language module interface ──────────────────────────────────────
148
223
 
149
224
  function parse(code, parser) {
@@ -185,9 +260,14 @@ function findStateObjects(code, parser) {
185
260
  }
186
261
 
187
262
  function findCallsInCode(code, parser) {
188
- const result = extractJS(code, parser);
189
- if (!result) return [];
190
- return result.jsModule.findCallsInCode(result.virtualJS, result.jsParser);
263
+ const scriptCalls = (() => {
264
+ const result = extractJS(code, parser);
265
+ if (!result) return [];
266
+ return result.jsModule.findCallsInCode(result.virtualJS, result.jsParser);
267
+ })();
268
+ const handlerCalls = extractEventHandlerCalls(code, parser);
269
+ if (handlerCalls.length === 0) return scriptCalls;
270
+ return scriptCalls.concat(handlerCalls);
191
271
  }
192
272
 
193
273
  function findCallbackUsages(code, name, parser) {
@@ -233,5 +313,6 @@ module.exports = {
233
313
  findUsagesInCode,
234
314
  // Exported for testing
235
315
  extractScriptBlocks,
236
- buildVirtualJSContent
316
+ buildVirtualJSContent,
317
+ extractEventHandlerCalls
237
318
  };
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, Java, and HTML (inline scripts). 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 and event handlers). Use instead of grep/read for code relationships.
173
173
 
174
174
  TOP 5 (covers 90% of tasks): about, impact, trace, find, deadcode
175
175
 
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "ucn",
3
- "version": "3.7.0",
3
+ "version": "3.7.1",
4
4
  "description": "Universal Code Navigator — function relationships, call trees, and impact analysis across large codebases without reading entire files.",
5
5
  "main": "index.js",
6
6
  "bin": {
@@ -12535,6 +12535,159 @@ function main() { return helper(); }
12535
12535
 
12536
12536
  fs.rmSync(tmpDir, { recursive: true, force: true });
12537
12537
  });
12538
+
12539
+ // ── HTML event handler tests (fix #90) ──────────────────────────────────
12540
+
12541
+ it('extractEventHandlerCalls extracts calls from onclick attributes', () => {
12542
+ const { extractEventHandlerCalls } = require('../languages/html');
12543
+ const parser = getParser('html');
12544
+ const html = '<button onclick="resetGame()">Click</button>';
12545
+ const calls = extractEventHandlerCalls(html, parser);
12546
+ assert.strictEqual(calls.length, 1);
12547
+ assert.strictEqual(calls[0].name, 'resetGame');
12548
+ assert.strictEqual(calls[0].line, 1);
12549
+ assert.strictEqual(calls[0].isMethod, false);
12550
+ assert.strictEqual(calls[0].isEventHandler, true);
12551
+ });
12552
+
12553
+ it('extractEventHandlerCalls handles multiple calls in one handler', () => {
12554
+ const { extractEventHandlerCalls } = require('../languages/html');
12555
+ const parser = getParser('html');
12556
+ const html = '<button onclick="validateForm(); submitData()">Go</button>';
12557
+ const calls = extractEventHandlerCalls(html, parser);
12558
+ assert.strictEqual(calls.length, 2);
12559
+ assert.strictEqual(calls[0].name, 'validateForm');
12560
+ assert.strictEqual(calls[1].name, 'submitData');
12561
+ });
12562
+
12563
+ it('extractEventHandlerCalls skips method calls on objects', () => {
12564
+ const { extractEventHandlerCalls } = require('../languages/html');
12565
+ const parser = getParser('html');
12566
+ const html = '<button onclick="event.stopPropagation(); selectCar(\'abc\')">Buy</button>';
12567
+ const calls = extractEventHandlerCalls(html, parser);
12568
+ const names = calls.map(c => c.name);
12569
+ assert.ok(!names.includes('stopPropagation'), 'should skip event.stopPropagation()');
12570
+ assert.ok(names.includes('selectCar'), 'should detect selectCar()');
12571
+ });
12572
+
12573
+ it('extractEventHandlerCalls skips JS keywords', () => {
12574
+ const { extractEventHandlerCalls } = require('../languages/html');
12575
+ const parser = getParser('html');
12576
+ const html = '<button onclick="if (confirm(\'sure?\')) deleteItem(id)">Del</button>';
12577
+ const calls = extractEventHandlerCalls(html, parser);
12578
+ const names = calls.map(c => c.name);
12579
+ assert.ok(!names.includes('if'), 'should skip keyword if');
12580
+ assert.ok(names.includes('confirm'));
12581
+ assert.ok(names.includes('deleteItem'));
12582
+ });
12583
+
12584
+ it('extractEventHandlerCalls works with various on* attributes', () => {
12585
+ const { extractEventHandlerCalls } = require('../languages/html');
12586
+ const parser = getParser('html');
12587
+ const html = `<input onchange="updateValue()" onfocus="highlight()" onblur="unhighlight()">`;
12588
+ const calls = extractEventHandlerCalls(html, parser);
12589
+ const names = calls.map(c => c.name);
12590
+ assert.ok(names.includes('updateValue'));
12591
+ assert.ok(names.includes('highlight'));
12592
+ assert.ok(names.includes('unhighlight'));
12593
+ });
12594
+
12595
+ it('extractEventHandlerCalls does not extract from script elements', () => {
12596
+ const { extractEventHandlerCalls } = require('../languages/html');
12597
+ const parser = getParser('html');
12598
+ const html = `<script>function foo() { bar(); }</script>
12599
+ <button onclick="foo()">Click</button>`;
12600
+ const calls = extractEventHandlerCalls(html, parser);
12601
+ // Should only find foo from onclick, not bar from <script>
12602
+ assert.strictEqual(calls.length, 1);
12603
+ assert.strictEqual(calls[0].name, 'foo');
12604
+ });
12605
+
12606
+ it('extractEventHandlerCalls reports correct line numbers', () => {
12607
+ const { extractEventHandlerCalls } = require('../languages/html');
12608
+ const parser = getParser('html');
12609
+ const html = `<html>
12610
+ <body>
12611
+ <div>text</div>
12612
+ <button onclick="doA()">A</button>
12613
+ <button onclick="doB()">B</button>
12614
+ </body>
12615
+ </html>`;
12616
+ const calls = extractEventHandlerCalls(html, parser);
12617
+ assert.strictEqual(calls.length, 2);
12618
+ assert.strictEqual(calls[0].name, 'doA');
12619
+ assert.strictEqual(calls[0].line, 4);
12620
+ assert.strictEqual(calls[1].name, 'doB');
12621
+ assert.strictEqual(calls[1].line, 5);
12622
+ });
12623
+
12624
+ it('findCallsInCode includes event handler calls for HTML', () => {
12625
+ const { parser, mod } = getHtmlTools();
12626
+ const html = `<button onclick="handleClick()">Click</button>
12627
+ <script>
12628
+ function handleClick() { doWork(); }
12629
+ function doWork() { return 42; }
12630
+ </script>`;
12631
+ const calls = mod.findCallsInCode(html, parser);
12632
+ const names = calls.map(c => c.name);
12633
+ assert.ok(names.includes('handleClick'), 'should find handleClick from onclick');
12634
+ assert.ok(names.includes('doWork'), 'should find doWork from script');
12635
+ });
12636
+
12637
+ it('deadcode does not report functions called from HTML event handlers', () => {
12638
+ const tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), 'ucn-html-onclick-'));
12639
+ fs.writeFileSync(path.join(tmpDir, 'page.html'), `<html><body>
12640
+ <button onclick="resetGame()">Reset</button>
12641
+ <button onclick="startGame('easy')">Start</button>
12642
+ <script>
12643
+ function resetGame() { init(); }
12644
+ function startGame(mode) { setup(mode); }
12645
+ function init() { return 1; }
12646
+ function setup(m) { return m; }
12647
+ function unusedFn() { return 0; }
12648
+ </script>
12649
+ </body></html>`);
12650
+ fs.writeFileSync(path.join(tmpDir, 'package.json'), '{"name": "test"}');
12651
+
12652
+ const { ProjectIndex } = require('../core/project');
12653
+ const index = new ProjectIndex(tmpDir);
12654
+ index.build();
12655
+
12656
+ const dead = index.deadcode({ includeExported: true });
12657
+ const deadNames = dead.map(d => d.name);
12658
+ // resetGame and startGame are called from onclick — NOT dead
12659
+ assert.ok(!deadNames.includes('resetGame'), 'resetGame should not be dead (called from onclick)');
12660
+ assert.ok(!deadNames.includes('startGame'), 'startGame should not be dead (called from onclick)');
12661
+ // init and setup are called from script — NOT dead
12662
+ assert.ok(!deadNames.includes('init'), 'init should not be dead (called from resetGame)');
12663
+ assert.ok(!deadNames.includes('setup'), 'setup should not be dead (called from startGame)');
12664
+ // unusedFn has no callers — dead
12665
+ assert.ok(deadNames.includes('unusedFn'), 'unusedFn should be dead');
12666
+
12667
+ fs.rmSync(tmpDir, { recursive: true, force: true });
12668
+ });
12669
+
12670
+ it('findCallers detects callers from HTML event handlers', () => {
12671
+ const tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), 'ucn-html-callers-'));
12672
+ fs.writeFileSync(path.join(tmpDir, 'page.html'), `<html><body>
12673
+ <button onclick="doStuff()">Go</button>
12674
+ <script>
12675
+ function doStuff() { return 42; }
12676
+ </script>
12677
+ </body></html>`);
12678
+ fs.writeFileSync(path.join(tmpDir, 'package.json'), '{"name": "test"}');
12679
+
12680
+ const { ProjectIndex } = require('../core/project');
12681
+ const index = new ProjectIndex(tmpDir);
12682
+ index.build();
12683
+
12684
+ const callers = index.findCallers('doStuff');
12685
+ assert.strictEqual(callers.length, 1);
12686
+ assert.strictEqual(callers[0].line, 2);
12687
+ assert.ok(callers[0].content.includes('onclick="doStuff()"'));
12688
+
12689
+ fs.rmSync(tmpDir, { recursive: true, force: true });
12690
+ });
12538
12691
  });
12539
12692
 
12540
12693
  console.log('UCN v3 Test Suite');