ucn 3.7.0 → 3.7.2

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -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
@@ -8,10 +8,10 @@
8
8
  const fs = require('fs');
9
9
  const path = require('path');
10
10
  const crypto = require('crypto');
11
- const { execSync } = require('child_process');
11
+ const { execSync, execFileSync } = 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, cleanHtmlScriptTags } = require('./parser');
14
+ const { parse, parseFile, cleanHtmlScriptTags } = require('./parser');
15
15
  const { detectLanguage, getParser, getLanguageModule, PARSE_OPTIONS, safeParse } = require('../languages');
16
16
  const { getTokenTypeAtPosition } = require('../languages/utils');
17
17
 
@@ -402,7 +402,15 @@ class ProjectIndex {
402
402
  return parent;
403
403
  });
404
404
 
405
- this.extendsGraph.set(symbol.name, resolvedParents);
405
+ // Store with file scope to avoid collisions when same class name
406
+ // appears in multiple files (F-002 fix)
407
+ if (!this.extendsGraph.has(symbol.name)) {
408
+ this.extendsGraph.set(symbol.name, []);
409
+ }
410
+ this.extendsGraph.get(symbol.name).push({
411
+ file: filePath,
412
+ parents: resolvedParents
413
+ });
406
414
 
407
415
  for (const parent of resolvedParents) {
408
416
  if (!this.extendedByGraph.has(parent)) {
@@ -419,6 +427,66 @@ class ProjectIndex {
419
427
  }
420
428
  }
421
429
 
430
+ /**
431
+ * Get inheritance parents for a class, scoped by file to handle
432
+ * duplicate class names across files.
433
+ * @param {string} className - Class name to look up
434
+ * @param {string} contextFile - File path for scoping (prefer same-file match)
435
+ * @returns {string[]|null} Parent class names, or null if none
436
+ */
437
+ _getInheritanceParents(className, contextFile) {
438
+ const entries = this.extendsGraph.get(className);
439
+ if (!entries || entries.length === 0) return null;
440
+
441
+ // New format: array of {file, parents}
442
+ if (typeof entries[0] === 'object' && entries[0].file !== undefined) {
443
+ // Prefer same-file match
444
+ const match = entries.find(e => e.file === contextFile);
445
+ if (match) return match.parents;
446
+
447
+ // Try imported file
448
+ if (contextFile) {
449
+ const imports = this.importGraph.get(contextFile);
450
+ if (imports) {
451
+ const imported = entries.find(e => imports.includes(e.file));
452
+ if (imported) return imported.parents;
453
+ }
454
+ }
455
+
456
+ // Fallback to first entry
457
+ return entries[0].parents;
458
+ }
459
+
460
+ // Old format (cache compat): plain array of parent names
461
+ return entries;
462
+ }
463
+
464
+ /**
465
+ * Resolve which file a class is defined in, preferring contextFile.
466
+ * Used during inheritance BFS to find grandparent chains.
467
+ * @param {string} className - Class name to resolve
468
+ * @param {string} contextFile - Preferred file (e.g., child's file)
469
+ * @returns {string|null} Resolved file path
470
+ */
471
+ _resolveClassFile(className, contextFile) {
472
+ const symbols = this.symbols.get(className);
473
+ if (!symbols) return contextFile;
474
+ const classSymbols = symbols.filter(s =>
475
+ ['class', 'interface', 'struct', 'trait'].includes(s.type));
476
+ if (classSymbols.length === 0) return contextFile;
477
+ // Prefer same file as context
478
+ if (classSymbols.some(s => s.file === contextFile)) return contextFile;
479
+ // Prefer imported
480
+ if (contextFile) {
481
+ const imports = this.importGraph.get(contextFile);
482
+ if (imports) {
483
+ const imported = classSymbols.find(s => imports.includes(s.file));
484
+ if (imported) return imported.file;
485
+ }
486
+ }
487
+ return classSymbols[0].file;
488
+ }
489
+
422
490
 
423
491
  // ========================================================================
424
492
  // QUERY METHODS
@@ -1246,21 +1314,23 @@ class ProjectIndex {
1246
1314
  let matchesDef = call.receiver === 'super'
1247
1315
  ? false
1248
1316
  : definitions.some(d => d.className === callerSymbol.className);
1249
- // Walk inheritance chain if not found in same class
1317
+ // Walk inheritance chain using BFS if not found in same class
1250
1318
  if (!matchesDef) {
1251
- let parents = this.extendsGraph.get(callerSymbol.className);
1252
1319
  const visited = new Set([callerSymbol.className]);
1253
- while (parents && !matchesDef) {
1254
- for (const parent of parents) {
1255
- if (visited.has(parent)) continue;
1256
- visited.add(parent);
1257
- matchesDef = definitions.some(d => d.className === parent);
1258
- if (matchesDef) break;
1259
- }
1320
+ const callerFile = callerSymbol.file || filePath;
1321
+ const startParents = this._getInheritanceParents(callerSymbol.className, callerFile) || [];
1322
+ const queue = startParents.map(p => ({ name: p, contextFile: callerFile }));
1323
+ while (queue.length > 0 && !matchesDef) {
1324
+ const { name: current, contextFile } = queue.shift();
1325
+ if (visited.has(current)) continue;
1326
+ visited.add(current);
1327
+ matchesDef = definitions.some(d => d.className === current);
1260
1328
  if (!matchesDef) {
1261
- const nextParent = parents.find(p => !visited.has(p));
1262
- if (!nextParent) break;
1263
- parents = this.extendsGraph.get(nextParent);
1329
+ const resolvedFile = this._resolveClassFile(current, contextFile);
1330
+ const grandparents = this._getInheritanceParents(current, resolvedFile) || [];
1331
+ for (const gp of grandparents) {
1332
+ if (!visited.has(gp)) queue.push({ name: gp, contextFile: resolvedFile });
1333
+ }
1264
1334
  }
1265
1335
  }
1266
1336
  }
@@ -1665,22 +1735,23 @@ class ProjectIndex {
1665
1735
  ? null
1666
1736
  : symbols.find(s => s.className === def.className);
1667
1737
 
1668
- // Walk inheritance chain if not found in same class
1738
+ // Walk inheritance chain using BFS if not found in same class
1669
1739
  if (!match) {
1670
- let parents = this.extendsGraph.get(def.className);
1671
1740
  const visited = new Set([def.className]);
1672
- while (parents && !match) {
1673
- for (const parent of parents) {
1674
- if (visited.has(parent)) continue;
1675
- visited.add(parent);
1676
- match = symbols.find(s => s.className === parent);
1677
- if (match) break;
1678
- }
1741
+ const defFile = def.file;
1742
+ const startParents = this._getInheritanceParents(def.className, defFile) || [];
1743
+ const queue = startParents.map(p => ({ name: p, contextFile: defFile }));
1744
+ while (queue.length > 0 && !match) {
1745
+ const { name: current, contextFile } = queue.shift();
1746
+ if (visited.has(current)) continue;
1747
+ visited.add(current);
1748
+ match = symbols.find(s => s.className === current);
1679
1749
  if (!match) {
1680
- // Follow first parent's chain (simplified MRO)
1681
- const nextParent = parents.find(p => !visited.has(p));
1682
- if (!nextParent) break;
1683
- parents = this.extendsGraph.get(nextParent);
1750
+ const resolvedFile = this._resolveClassFile(current, contextFile);
1751
+ const grandparents = this._getInheritanceParents(current, resolvedFile) || [];
1752
+ for (const gp of grandparents) {
1753
+ if (!visited.has(gp)) queue.push({ name: gp, contextFile: resolvedFile });
1754
+ }
1684
1755
  }
1685
1756
  }
1686
1757
  }
@@ -2617,9 +2688,11 @@ class ProjectIndex {
2617
2688
  const htmlParser = getParser('html');
2618
2689
  const jsParser = getParser('javascript');
2619
2690
  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);
2691
+ if (blocks.length === 0 && !htmlModule.extractEventHandlerCalls) continue;
2692
+ if (blocks.length > 0) {
2693
+ const virtualJS = htmlModule.buildVirtualJSContent(content, blocks);
2694
+ tree = jsParser.parse(virtualJS, undefined, PARSE_OPTIONS);
2695
+ }
2623
2696
  } else {
2624
2697
  const parser = getParser(language);
2625
2698
  if (!parser) continue;
@@ -2649,7 +2722,25 @@ class ProjectIndex {
2649
2722
  traverse(node.child(i));
2650
2723
  }
2651
2724
  };
2652
- traverse(tree.rootNode);
2725
+ if (tree) traverse(tree.rootNode);
2726
+
2727
+ // For HTML files, also extract identifiers from event handler attributes
2728
+ // (onclick="foo()" etc. — these are in HTML, not in <script> blocks)
2729
+ if (language === 'html') {
2730
+ const htmlModule = getLanguageModule('html');
2731
+ const htmlParser = getParser('html');
2732
+ const handlerCalls = htmlModule.extractEventHandlerCalls(content, htmlParser);
2733
+ for (const call of handlerCalls) {
2734
+ if (!usageIndex.has(call.name)) {
2735
+ usageIndex.set(call.name, []);
2736
+ }
2737
+ usageIndex.get(call.name).push({
2738
+ file: filePath,
2739
+ line: call.line,
2740
+ relativePath: fileEntry.relativePath
2741
+ });
2742
+ }
2743
+ }
2653
2744
  } catch (e) {
2654
2745
  // Skip files that can't be processed
2655
2746
  }
@@ -4810,8 +4901,8 @@ class ProjectIndex {
4810
4901
  throw new Error('Not a git repository. diff-impact requires git.');
4811
4902
  }
4812
4903
 
4813
- // Build git diff command
4814
- const diffArgs = ['git', 'diff', '--unified=0'];
4904
+ // Build git diff command (use execFileSync to avoid shell expansion)
4905
+ const diffArgs = ['diff', '--unified=0'];
4815
4906
  if (staged) {
4816
4907
  diffArgs.push('--staged');
4817
4908
  } else {
@@ -4823,7 +4914,7 @@ class ProjectIndex {
4823
4914
 
4824
4915
  let diffText;
4825
4916
  try {
4826
- diffText = execSync(diffArgs.join(' '), { cwd: this.root, encoding: 'utf-8', maxBuffer: 10 * 1024 * 1024 });
4917
+ diffText = execFileSync('git', diffArgs, { cwd: this.root, encoding: 'utf-8', maxBuffer: 10 * 1024 * 1024 });
4827
4918
  } catch (e) {
4828
4919
  // git diff exits non-zero when there are diff errors, but also for invalid refs
4829
4920
  if (e.stdout) {
@@ -4858,7 +4949,31 @@ class ProjectIndex {
4858
4949
  if (!lang) continue;
4859
4950
 
4860
4951
  const fileEntry = this.files.get(change.filePath);
4861
- if (!fileEntry) continue;
4952
+
4953
+ // Handle deleted files: entire file was removed, all functions are deleted
4954
+ if (!fileEntry) {
4955
+ if (change.isDeleted && change.deletedLines.length > 0) {
4956
+ const ref = staged ? 'HEAD' : base;
4957
+ try {
4958
+ const oldContent = execFileSync(
4959
+ 'git', ['show', `${ref}:${change.relativePath}`],
4960
+ { cwd: this.root, encoding: 'utf-8', maxBuffer: 10 * 1024 * 1024 }
4961
+ );
4962
+ const oldParsed = parse(oldContent, lang);
4963
+ for (const oldFn of extractCallableSymbols(oldParsed)) {
4964
+ deletedFunctions.push({
4965
+ name: oldFn.name,
4966
+ filePath: change.filePath,
4967
+ relativePath: change.relativePath,
4968
+ startLine: oldFn.startLine
4969
+ });
4970
+ }
4971
+ } catch (e) {
4972
+ // git show failed — skip
4973
+ }
4974
+ }
4975
+ continue;
4976
+ }
4862
4977
 
4863
4978
  // Track which functions are affected by added/modified lines
4864
4979
  const affectedSymbols = new Map(); // symbolName -> { symbol, addedLines, deletedLines }
@@ -4940,6 +5055,57 @@ class ProjectIndex {
4940
5055
  }
4941
5056
  }
4942
5057
 
5058
+ // Detect deleted functions: compare old file symbols with current by identity.
5059
+ // Uses name+className counts to handle overloads (e.g. Java method overloading).
5060
+ if (change.deletedLines.length > 0) {
5061
+ const ref = staged ? 'HEAD' : base;
5062
+ try {
5063
+ const oldContent = execFileSync(
5064
+ 'git', ['show', `${ref}:${change.relativePath}`],
5065
+ { cwd: this.root, encoding: 'utf-8', maxBuffer: 10 * 1024 * 1024 }
5066
+ );
5067
+ const fileLang = detectLanguage(change.filePath);
5068
+ if (fileLang) {
5069
+ const oldParsed = parse(oldContent, fileLang);
5070
+ const nonCallableTypes = new Set(['class', 'struct', 'interface', 'type', 'state', 'impl', 'enum', 'trait']);
5071
+ // Count current symbols by identity (name + className)
5072
+ const currentCounts = new Map();
5073
+ for (const s of fileEntry.symbols) {
5074
+ if (nonCallableTypes.has(s.type)) continue;
5075
+ const key = `${s.name}\0${s.className || ''}`;
5076
+ currentCounts.set(key, (currentCounts.get(key) || 0) + 1);
5077
+ }
5078
+ // Count old symbols by identity and detect deletions
5079
+ const oldCounts = new Map();
5080
+ const oldSymbols = extractCallableSymbols(oldParsed);
5081
+ for (const oldFn of oldSymbols) {
5082
+ const key = `${oldFn.name}\0${oldFn.className || ''}`;
5083
+ oldCounts.set(key, (oldCounts.get(key) || 0) + 1);
5084
+ }
5085
+ // For each identity, if old count > current count, the difference are deletions
5086
+ for (const [key, oldCount] of oldCounts) {
5087
+ const curCount = currentCounts.get(key) || 0;
5088
+ if (oldCount > curCount) {
5089
+ // Find the specific old symbols with this identity that were deleted
5090
+ const matching = oldSymbols.filter(s => `${s.name}\0${s.className || ''}` === key);
5091
+ // Report the extra ones (by startLine descending — later ones more likely deleted)
5092
+ const toReport = matching.slice(curCount);
5093
+ for (const oldFn of toReport) {
5094
+ deletedFunctions.push({
5095
+ name: oldFn.name,
5096
+ filePath: change.filePath,
5097
+ relativePath: change.relativePath,
5098
+ startLine: oldFn.startLine
5099
+ });
5100
+ }
5101
+ }
5102
+ }
5103
+ }
5104
+ } catch (e) {
5105
+ // File didn't exist in base, or git error — skip
5106
+ }
5107
+ }
5108
+
4943
5109
  // For each affected function, find callers
4944
5110
  for (const [, data] of affectedSymbols) {
4945
5111
  const { symbol, addedLines: aLines, deletedLines: dLines } = data;
@@ -4992,6 +5158,40 @@ class ProjectIndex {
4992
5158
  }
4993
5159
  }
4994
5160
 
5161
+ /**
5162
+ * Extract all callable symbols (functions + class methods) from a parse result,
5163
+ * matching how indexFile builds the symbol list. Methods get className added.
5164
+ * @param {object} parsed - Result from parse()
5165
+ * @returns {Array<{name, className, startLine}>}
5166
+ */
5167
+ function extractCallableSymbols(parsed) {
5168
+ const symbols = [];
5169
+ for (const fn of parsed.functions) {
5170
+ symbols.push({ name: fn.name, className: fn.className || '', startLine: fn.startLine });
5171
+ }
5172
+ for (const cls of parsed.classes) {
5173
+ if (cls.members) {
5174
+ for (const m of cls.members) {
5175
+ symbols.push({ name: m.name, className: cls.name, startLine: m.startLine });
5176
+ }
5177
+ }
5178
+ }
5179
+ return symbols;
5180
+ }
5181
+
5182
+ /**
5183
+ * Unquote a git diff path: unescape C-style backslash sequences and strip tab metadata.
5184
+ * Git quotes paths containing special chars as "a/path\"with\"quotes".
5185
+ * @param {string} raw - Raw path string (may contain backslash escapes)
5186
+ * @returns {string} Unquoted path
5187
+ */
5188
+ function unquoteDiffPath(raw) {
5189
+ const ESCAPES = { '\\\\': '\\', '\\"': '"', '\\n': '\n', '\\t': '\t' };
5190
+ return raw
5191
+ .split('\t')[0]
5192
+ .replace(/\\[\\"nt]/g, m => ESCAPES[m]);
5193
+ }
5194
+
4995
5195
  /**
4996
5196
  * Parse unified diff output into structured change data
4997
5197
  * @param {string} diffText - Output from `git diff --unified=0`
@@ -5001,16 +5201,39 @@ class ProjectIndex {
5001
5201
  function parseDiff(diffText, root) {
5002
5202
  const changes = [];
5003
5203
  let currentFile = null;
5204
+ let pendingOldPath = null; // Track --- a/ path for deleted files
5004
5205
 
5005
5206
  for (const line of diffText.split('\n')) {
5006
- // Match file header: +++ b/path/to/file.js
5007
- if (line.startsWith('+++ b/')) {
5008
- const relativePath = line.slice(6);
5207
+ // Track old file path from --- header for deleted-file detection
5208
+ // Handles both unquoted (--- a/path) and quoted (--- "a/path") formats
5209
+ const oldMatch = line.match(/^--- (?:"a\/((?:[^"\\]|\\.)*)"|a\/(.+?))\s*$/);
5210
+ if (oldMatch) {
5211
+ const raw = oldMatch[1] !== undefined ? oldMatch[1] : oldMatch[2];
5212
+ pendingOldPath = unquoteDiffPath(raw);
5213
+ continue;
5214
+ }
5215
+
5216
+ // Match file header: +++ b/path or +++ "b/path" or +++ /dev/null
5217
+ if (line.startsWith('+++ ')) {
5218
+ let relativePath;
5219
+ const isDevNull = line.startsWith('+++ /dev/null');
5220
+ if (isDevNull) {
5221
+ // File was deleted — use the --- a/ path
5222
+ if (!pendingOldPath) continue;
5223
+ relativePath = pendingOldPath;
5224
+ } else {
5225
+ const newMatch = line.match(/^\+\+\+ (?:"b\/((?:[^"\\]|\\.)*)"|b\/(.+?))\s*$/);
5226
+ if (!newMatch) continue;
5227
+ const raw = newMatch[1] !== undefined ? newMatch[1] : newMatch[2];
5228
+ relativePath = unquoteDiffPath(raw);
5229
+ }
5230
+ pendingOldPath = null;
5009
5231
  currentFile = {
5010
5232
  filePath: path.join(root, relativePath),
5011
5233
  relativePath,
5012
5234
  addedLines: [],
5013
- deletedLines: []
5235
+ deletedLines: [],
5236
+ ...(isDevNull && { isDeleted: true })
5014
5237
  };
5015
5238
  changes.push(currentFile);
5016
5239
  continue;
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.2",
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": {
@@ -1376,7 +1376,7 @@ class Child(Base):
1376
1376
  } finally { rm(d); }
1377
1377
  });
1378
1378
 
1379
- it('LIMITATION: deep inheritance chain (3+ levels)', () => {
1379
+ it('PASS: deep inheritance chain (3+ levels)', () => {
1380
1380
  const d = tmp({
1381
1381
  'package.json': '{"name":"t"}',
1382
1382
  'lib.js': `
@@ -12535,6 +12535,494 @@ 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
+ });
12691
+ });
12692
+
12693
+ // FIX 91: Deep inheritance chain (3+ levels) — BFS traversal
12694
+ it('FIX 91 — deep inheritance chain resolves callees through 3+ levels', () => {
12695
+ const tmpDir = path.join(os.tmpdir(), `ucn-deep-inherit-${Date.now()}`);
12696
+ fs.mkdirSync(tmpDir, { recursive: true });
12697
+
12698
+ try {
12699
+ fs.writeFileSync(path.join(tmpDir, 'package.json'), '{"name":"test"}');
12700
+ fs.writeFileSync(path.join(tmpDir, 'lib.js'), `
12701
+ class A {
12702
+ helper() { return 1; }
12703
+ }
12704
+ class B extends A {
12705
+ other() { return 2; }
12706
+ }
12707
+ class C extends B {
12708
+ process() { return this.helper(); }
12709
+ }
12710
+ module.exports = { A, B, C };
12711
+ `);
12712
+
12713
+ const { ProjectIndex } = require('../core/project');
12714
+ const index = new ProjectIndex(tmpDir);
12715
+ index.build(null, { quiet: true });
12716
+
12717
+ // Callees: C.process() should resolve this.helper() to A.helper via C → B → A
12718
+ const processDef = index.symbols.get('process')?.[0];
12719
+ const callees = index.findCallees(processDef);
12720
+ assert.ok(callees.some(c => c.name === 'helper' && c.className === 'A'),
12721
+ 'Deep chain callees: C → B → A, this.helper() resolves to A.helper');
12722
+
12723
+ // Callers: A.helper should be found as called from C.process()
12724
+ const callers = index.findCallers('helper');
12725
+ assert.ok(callers.some(c => c.callerName === 'process'),
12726
+ 'Deep chain callers: A.helper() called from C.process() via inheritance');
12727
+ } finally {
12728
+ fs.rmSync(tmpDir, { recursive: true, force: true });
12729
+ }
12730
+ });
12731
+
12732
+ // FIX 92: Inheritance graph key collision with duplicate class names across files
12733
+ it('FIX 92 — duplicate class names across files resolve independently', () => {
12734
+ const tmpDir = path.join(os.tmpdir(), `ucn-dup-class-${Date.now()}`);
12735
+ fs.mkdirSync(tmpDir, { recursive: true });
12736
+
12737
+ try {
12738
+ fs.writeFileSync(path.join(tmpDir, 'package.json'), '{"name":"test"}');
12739
+ fs.writeFileSync(path.join(tmpDir, 'a1.js'), `
12740
+ class A { helper() { return 1; } }
12741
+ class C extends A { process() { return this.helper(); } }
12742
+ module.exports = { A, C };
12743
+ `);
12744
+ fs.writeFileSync(path.join(tmpDir, 'a2.js'), `
12745
+ class B { helper() { return 2; } }
12746
+ class C extends B { run() { return this.helper(); } }
12747
+ module.exports = { B, C };
12748
+ `);
12749
+
12750
+ const { ProjectIndex } = require('../core/project');
12751
+ const index = new ProjectIndex(tmpDir);
12752
+ index.build(null, { quiet: true });
12753
+
12754
+ // C in a1.js extends A → helper should resolve to A.helper (a1.js)
12755
+ const processDef = index.symbols.get('process').find(s => s.file.endsWith('a1.js'));
12756
+ const processCallees = index.findCallees(processDef);
12757
+ const helperFromProcess = processCallees.find(c => c.name === 'helper');
12758
+ assert.ok(helperFromProcess, 'process() resolves this.helper()');
12759
+ assert.strictEqual(helperFromProcess.className, 'A', 'process() helper is from class A');
12760
+ assert.ok(helperFromProcess.file.endsWith('a1.js'), 'process() helper is in a1.js');
12761
+
12762
+ // C in a2.js extends B → helper should resolve to B.helper (a2.js)
12763
+ const runDef = index.symbols.get('run').find(s => s.file.endsWith('a2.js'));
12764
+ const runCallees = index.findCallees(runDef);
12765
+ const helperFromRun = runCallees.find(c => c.name === 'helper');
12766
+ assert.ok(helperFromRun, 'run() resolves this.helper()');
12767
+ assert.strictEqual(helperFromRun.className, 'B', 'run() helper is from class B');
12768
+ assert.ok(helperFromRun.file.endsWith('a2.js'), 'run() helper is in a2.js');
12769
+ } finally {
12770
+ fs.rmSync(tmpDir, { recursive: true, force: true });
12771
+ }
12772
+ });
12773
+
12774
+ // FIX 93: diffImpact detects deleted functions
12775
+ it('FIX 93 — diffImpact detects deleted functions', () => {
12776
+ const { execSync } = require('child_process');
12777
+ const tmpDir = path.join(os.tmpdir(), `ucn-diff-del-${Date.now()}`);
12778
+ fs.mkdirSync(tmpDir, { recursive: true });
12779
+
12780
+ try {
12781
+ execSync('git init', { cwd: tmpDir, stdio: 'pipe' });
12782
+ execSync('git config user.email "test@test.com"', { cwd: tmpDir, stdio: 'pipe' });
12783
+ execSync('git config user.name "Test"', { cwd: tmpDir, stdio: 'pipe' });
12784
+
12785
+ fs.writeFileSync(path.join(tmpDir, 'package.json'), '{"name":"test"}');
12786
+ fs.writeFileSync(path.join(tmpDir, 'app.js'), `function foo() { return 1; }
12787
+ function bar() { return foo(); }
12788
+ `);
12789
+ execSync('git add -A && git commit -m "init"', { cwd: tmpDir, stdio: 'pipe' });
12790
+
12791
+ // Delete foo, keep bar
12792
+ fs.writeFileSync(path.join(tmpDir, 'app.js'), `function bar() { return 2; }
12793
+ `);
12794
+
12795
+ const { ProjectIndex } = require('../core/project');
12796
+ const index = new ProjectIndex(tmpDir);
12797
+ index.build(null, { quiet: true });
12798
+ const result = index.diffImpact({ base: 'HEAD' });
12799
+
12800
+ assert.ok(result.deletedFunctions.length >= 1, 'Should detect deleted function');
12801
+ assert.ok(result.deletedFunctions.some(f => f.name === 'foo'), 'foo should be in deletedFunctions');
12802
+ assert.strictEqual(result.summary.deletedFunctions, result.deletedFunctions.length,
12803
+ 'Summary count should match deletedFunctions length');
12804
+ } finally {
12805
+ fs.rmSync(tmpDir, { recursive: true, force: true });
12806
+ }
12807
+ });
12808
+
12809
+ // FIX 94: diffImpact detects deleted functions when entire file is removed
12810
+ it('FIX 94 — diffImpact detects all functions in a deleted file', () => {
12811
+ const { execSync } = require('child_process');
12812
+ const tmpDir = path.join(os.tmpdir(), `ucn-diff-filedel-${Date.now()}`);
12813
+ fs.mkdirSync(tmpDir, { recursive: true });
12814
+
12815
+ try {
12816
+ execSync('git init', { cwd: tmpDir, stdio: 'pipe' });
12817
+ execSync('git config user.email "test@test.com"', { cwd: tmpDir, stdio: 'pipe' });
12818
+ execSync('git config user.name "Test"', { cwd: tmpDir, stdio: 'pipe' });
12819
+
12820
+ fs.writeFileSync(path.join(tmpDir, 'package.json'), '{"name":"test"}');
12821
+ fs.writeFileSync(path.join(tmpDir, 'a.js'), 'function foo() { return 1; }\nfunction bar() { return 2; }\n');
12822
+ fs.writeFileSync(path.join(tmpDir, 'b.js'), 'function baz() { return 3; }\n');
12823
+ execSync('git add -A && git commit -m "init"', { cwd: tmpDir, stdio: 'pipe' });
12824
+
12825
+ // Delete entire file a.js
12826
+ fs.unlinkSync(path.join(tmpDir, 'a.js'));
12827
+
12828
+ const { ProjectIndex } = require('../core/project');
12829
+ const index = new ProjectIndex(tmpDir);
12830
+ index.build(null, { quiet: true });
12831
+ const result = index.diffImpact({ base: 'HEAD' });
12832
+
12833
+ assert.ok(result.deletedFunctions.some(f => f.name === 'foo'), 'foo should be detected as deleted');
12834
+ assert.ok(result.deletedFunctions.some(f => f.name === 'bar'), 'bar should be detected as deleted');
12835
+ assert.ok(!result.deletedFunctions.some(f => f.name === 'baz'), 'baz should NOT be deleted');
12836
+ assert.strictEqual(result.summary.deletedFunctions, 2);
12837
+ } finally {
12838
+ fs.rmSync(tmpDir, { recursive: true, force: true });
12839
+ }
12840
+ });
12841
+
12842
+ // FIX 95: diffImpact detects same-name method deletion by identity
12843
+ it('FIX 95 — diffImpact detects A.foo deleted while B.foo remains', () => {
12844
+ const { execSync } = require('child_process');
12845
+ const tmpDir = path.join(os.tmpdir(), `ucn-diff-samename-${Date.now()}`);
12846
+ fs.mkdirSync(tmpDir, { recursive: true });
12847
+
12848
+ try {
12849
+ execSync('git init', { cwd: tmpDir, stdio: 'pipe' });
12850
+ execSync('git config user.email "test@test.com"', { cwd: tmpDir, stdio: 'pipe' });
12851
+ execSync('git config user.name "Test"', { cwd: tmpDir, stdio: 'pipe' });
12852
+
12853
+ fs.writeFileSync(path.join(tmpDir, 'package.json'), '{"name":"test"}');
12854
+ fs.writeFileSync(path.join(tmpDir, 'app.js'), `
12855
+ class A { foo() { return 1; } }
12856
+ class B { foo() { return 2; } }
12857
+ module.exports = { A, B };
12858
+ `);
12859
+ execSync('git add -A && git commit -m "init"', { cwd: tmpDir, stdio: 'pipe' });
12860
+
12861
+ // Delete A.foo but keep B.foo
12862
+ fs.writeFileSync(path.join(tmpDir, 'app.js'), `
12863
+ class A { }
12864
+ class B { foo() { return 2; } }
12865
+ module.exports = { A, B };
12866
+ `);
12867
+
12868
+ const { ProjectIndex } = require('../core/project');
12869
+ const index = new ProjectIndex(tmpDir);
12870
+ index.build(null, { quiet: true });
12871
+ const result = index.diffImpact({ base: 'HEAD' });
12872
+
12873
+ assert.strictEqual(result.deletedFunctions.length, 1, 'Exactly one foo should be deleted');
12874
+ assert.strictEqual(result.deletedFunctions[0].name, 'foo');
12875
+ } finally {
12876
+ fs.rmSync(tmpDir, { recursive: true, force: true });
12877
+ }
12878
+ });
12879
+
12880
+ // FIX 96: parseDiff handles filenames with spaces and tab metadata
12881
+ it('FIX 96 — diffImpact works with filenames containing spaces', () => {
12882
+ const { execSync } = require('child_process');
12883
+ const tmpDir = path.join(os.tmpdir(), `ucn-diff-spaces-${Date.now()}`);
12884
+ fs.mkdirSync(tmpDir, { recursive: true });
12885
+
12886
+ try {
12887
+ execSync('git init', { cwd: tmpDir, stdio: 'pipe' });
12888
+ execSync('git config user.email "test@test.com"', { cwd: tmpDir, stdio: 'pipe' });
12889
+ execSync('git config user.name "Test"', { cwd: tmpDir, stdio: 'pipe' });
12890
+
12891
+ fs.writeFileSync(path.join(tmpDir, 'package.json'), '{"name":"test"}');
12892
+ fs.writeFileSync(path.join(tmpDir, 'a b.js'), 'function spaceFn() { return 1; }\nfunction gone() { return 2; }\n');
12893
+ execSync('git add -A && git commit -m "init"', { cwd: tmpDir, stdio: 'pipe' });
12894
+
12895
+ // Modify spaceFn, delete gone
12896
+ fs.writeFileSync(path.join(tmpDir, 'a b.js'), 'function spaceFn() { return 99; }\n');
12897
+
12898
+ const { ProjectIndex } = require('../core/project');
12899
+ const index = new ProjectIndex(tmpDir);
12900
+ index.build(null, { quiet: true });
12901
+ const result = index.diffImpact({ base: 'HEAD' });
12902
+
12903
+ assert.ok(result.functions.some(f => f.name === 'spaceFn'), 'spaceFn should be detected as modified');
12904
+ assert.ok(result.deletedFunctions.some(f => f.name === 'gone'), 'gone should be detected as deleted');
12905
+ } finally {
12906
+ fs.rmSync(tmpDir, { recursive: true, force: true });
12907
+ }
12908
+ });
12909
+
12910
+ // FIX 97: git show uses execFileSync (no shell expansion of $ in filenames)
12911
+ it('FIX 97 — diffImpact works with $ in filenames', () => {
12912
+ const { execSync } = require('child_process');
12913
+ const tmpDir = path.join(os.tmpdir(), `ucn-diff-dollar-${Date.now()}`);
12914
+ fs.mkdirSync(tmpDir, { recursive: true });
12915
+
12916
+ try {
12917
+ execSync('git init', { cwd: tmpDir, stdio: 'pipe' });
12918
+ execSync('git config user.email "test@test.com"', { cwd: tmpDir, stdio: 'pipe' });
12919
+ execSync('git config user.name "Test"', { cwd: tmpDir, stdio: 'pipe' });
12920
+
12921
+ fs.writeFileSync(path.join(tmpDir, 'package.json'), '{"name":"test"}');
12922
+ fs.writeFileSync(path.join(tmpDir, 'a$HOME.js'), 'function dollarFn() { return 1; }\nfunction gone() { return 2; }\n');
12923
+ execSync('git add -A && git commit -m "init"', { cwd: tmpDir, stdio: 'pipe' });
12924
+
12925
+ fs.writeFileSync(path.join(tmpDir, 'a$HOME.js'), 'function dollarFn() { return 99; }\n');
12926
+
12927
+ const { ProjectIndex } = require('../core/project');
12928
+ const index = new ProjectIndex(tmpDir);
12929
+ index.build(null, { quiet: true });
12930
+ const result = index.diffImpact({ base: 'HEAD' });
12931
+
12932
+ assert.ok(result.functions.some(f => f.name === 'dollarFn'), 'dollarFn should be detected as modified');
12933
+ assert.ok(result.deletedFunctions.some(f => f.name === 'gone'), 'gone should be detected as deleted');
12934
+ } finally {
12935
+ fs.rmSync(tmpDir, { recursive: true, force: true });
12936
+ }
12937
+ });
12938
+
12939
+ // FIX 98: deleted-function identity handles overloads (same name+class, different signatures)
12940
+ it('FIX 98 — diffImpact detects deleted overload while sibling remains', () => {
12941
+ const { execSync } = require('child_process');
12942
+ const tmpDir = path.join(os.tmpdir(), `ucn-diff-overload-${Date.now()}`);
12943
+ fs.mkdirSync(tmpDir, { recursive: true });
12944
+
12945
+ try {
12946
+ execSync('git init', { cwd: tmpDir, stdio: 'pipe' });
12947
+ execSync('git config user.email "test@test.com"', { cwd: tmpDir, stdio: 'pipe' });
12948
+ execSync('git config user.name "Test"', { cwd: tmpDir, stdio: 'pipe' });
12949
+
12950
+ fs.writeFileSync(path.join(tmpDir, 'pom.xml'), '<project></project>\n');
12951
+ fs.writeFileSync(path.join(tmpDir, 'A.java'), `
12952
+ public class A {
12953
+ public void foo(int x) { System.out.println(x); }
12954
+ public void foo(String s) { System.out.println(s); }
12955
+ public void bar() { foo(1); }
12956
+ }
12957
+ `);
12958
+ execSync('git add -A && git commit -m "init"', { cwd: tmpDir, stdio: 'pipe' });
12959
+
12960
+ // Delete one overload
12961
+ fs.writeFileSync(path.join(tmpDir, 'A.java'), `
12962
+ public class A {
12963
+ public void foo(String s) { System.out.println(s); }
12964
+ public void bar() { foo("hi"); }
12965
+ }
12966
+ `);
12967
+
12968
+ const { ProjectIndex } = require('../core/project');
12969
+ const index = new ProjectIndex(tmpDir);
12970
+ index.build(null, { quiet: true });
12971
+ const result = index.diffImpact({ base: 'HEAD' });
12972
+
12973
+ assert.strictEqual(result.deletedFunctions.length, 1, 'Exactly one overload should be deleted');
12974
+ assert.strictEqual(result.deletedFunctions[0].name, 'foo');
12975
+ } finally {
12976
+ fs.rmSync(tmpDir, { recursive: true, force: true });
12977
+ }
12978
+ });
12979
+
12980
+ // FIX 99: parseDiff handles quoted diff headers (special chars in filenames)
12981
+ it('FIX 99 — parseDiff handles quoted paths with special characters', () => {
12982
+ const { parseDiff } = require('../core/project');
12983
+
12984
+ // Quoted path with escaped quotes
12985
+ const diffText = `diff --git "a/a\\"b.js" "b/a\\"b.js"
12986
+ --- "a/a\\"b.js"
12987
+ +++ "b/a\\"b.js"
12988
+ @@ -1 +1 @@
12989
+ `;
12990
+ const changes = parseDiff(diffText, '/tmp/test');
12991
+ assert.strictEqual(changes.length, 1);
12992
+ assert.strictEqual(changes[0].relativePath, 'a"b.js');
12993
+
12994
+ // Quoted deleted file
12995
+ const diffText2 = `diff --git "a/a\\"b.js" "b/a\\"b.js"
12996
+ --- "a/a\\"b.js"
12997
+ +++ /dev/null
12998
+ @@ -1,2 +0,0 @@
12999
+ `;
13000
+ const changes2 = parseDiff(diffText2, '/tmp/test');
13001
+ assert.strictEqual(changes2.length, 1);
13002
+ assert.strictEqual(changes2[0].isDeleted, true);
13003
+ assert.strictEqual(changes2[0].relativePath, 'a"b.js');
13004
+
13005
+ // Unquoted path still works
13006
+ const diffText3 = `diff --git a/normal.js b/normal.js
13007
+ --- a/normal.js
13008
+ +++ b/normal.js
13009
+ @@ -1 +1 @@
13010
+ `;
13011
+ const changes3 = parseDiff(diffText3, '/tmp/test');
13012
+ assert.strictEqual(changes3.length, 1);
13013
+ assert.strictEqual(changes3[0].relativePath, 'normal.js');
13014
+
13015
+ // Literal backslash+n in filename must not become a newline (single-pass unescape)
13016
+ // Git represents a\\n.js as a\\\\n.js in quoted headers
13017
+ const diffText4 = `diff --git "a/a\\\\n.js" "b/a\\\\n.js"
13018
+ --- "a/a\\\\n.js"
13019
+ +++ "b/a\\\\n.js"
13020
+ @@ -1 +1 @@
13021
+ `;
13022
+ const changes4 = parseDiff(diffText4, '/tmp/test');
13023
+ assert.strictEqual(changes4.length, 1);
13024
+ assert.strictEqual(changes4[0].relativePath, 'a\\n.js',
13025
+ 'Literal backslash-n in filename must be preserved, not converted to newline');
12538
13026
  });
12539
13027
 
12540
13028
  console.log('UCN v3 Test Suite');