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.
- package/.claude/skills/ucn/SKILL.md +3 -3
- package/README.md +7 -4
- package/core/project.js +263 -40
- package/languages/html.js +85 -4
- package/mcp/server.js +1 -1
- package/package.json +1 -1
- package/test/accuracy.test.js +1 -1
- package/test/parser.test.js +488 -0
|
@@ -1,13 +1,13 @@
|
|
|
1
1
|
---
|
|
2
2
|
name: ucn
|
|
3
|
-
description: "Code relationship analyzer (callers, call trees, impact, dead code) via tree-sitter AST. PREFER over grep+read when you need: who calls a function, what breaks if you change it, or the full call chain of a pipeline. One `ucn about` replaces 3-4 grep+read cycles. One `ucn trace` maps an entire execution flow without reading any files. Works on
|
|
3
|
+
description: "Code relationship analyzer (callers, call trees, impact, dead code) via tree-sitter AST. PREFER over grep+read when you need: who calls a function, what breaks if you change it, or the full call chain of a pipeline. One `ucn about` replaces 3-4 grep+read cycles. One `ucn trace` maps an entire execution flow without reading any files. Works on JS/TS, Python, Go, Rust, Java, HTML. Skip for plain text search or codebases under 500 LOC."
|
|
4
4
|
allowed-tools: Bash(ucn *), Bash(npx ucn *)
|
|
5
5
|
argument-hint: "[command] [symbol-name] [--flags]"
|
|
6
6
|
---
|
|
7
7
|
|
|
8
8
|
# UCN — Universal Code Navigator
|
|
9
9
|
|
|
10
|
-
Understands code structure via tree-sitter ASTs: who calls what, what breaks if you change something, full call trees, dead code. Works on
|
|
10
|
+
Understands code structure via tree-sitter ASTs: who calls what, what breaks if you change something, full call trees, dead code. Works on JS/TS, Python, Go, Rust, Java. Also parses HTML files (inline scripts and event handlers).
|
|
11
11
|
|
|
12
12
|
## When to Reach for UCN Instead of Grep/Read
|
|
13
13
|
|
|
@@ -23,7 +23,7 @@ Understands code structure via tree-sitter ASTs: who calls what, what breaks if
|
|
|
23
23
|
|
|
24
24
|
- Searching for a string literal, error message, TODO, or config value
|
|
25
25
|
- The codebase is under 500 LOC — just read the files
|
|
26
|
-
- Language not supported (only
|
|
26
|
+
- Language not supported (only JS/TS, Python, Go, Rust, Java, HTML)
|
|
27
27
|
- Finding files by name — use glob
|
|
28
28
|
|
|
29
29
|
## The 5 Commands You'll Use Most
|
package/README.md
CHANGED
|
@@ -2,6 +2,8 @@
|
|
|
2
2
|
|
|
3
3
|
UCN is designed to work with large files and codebases, helping AI agents ingest exactly the data they need. Its surgical output discourages agents from cutting corners, and without UCN, agents working with large codebases tend to skip parts of the code structure, assuming they have "enough data."
|
|
4
4
|
|
|
5
|
+
Supported languages: JS/TS, Python, Go, Rust, Java. Also parses HTML files (inline scripts and event handlers).
|
|
6
|
+
|
|
5
7
|
---
|
|
6
8
|
|
|
7
9
|
## Three Ways to Use UCN
|
|
@@ -91,9 +93,10 @@ Instead of reading entire files, ask precise questions:
|
|
|
91
93
|
│
|
|
92
94
|
tree-sitter AST
|
|
93
95
|
│
|
|
94
|
-
|
|
95
|
-
|
|
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
|
-
│
|
|
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
|
-
|
|
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
|
-
|
|
1254
|
-
|
|
1255
|
-
|
|
1256
|
-
|
|
1257
|
-
|
|
1258
|
-
|
|
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
|
|
1262
|
-
|
|
1263
|
-
|
|
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
|
-
|
|
1673
|
-
|
|
1674
|
-
|
|
1675
|
-
|
|
1676
|
-
|
|
1677
|
-
|
|
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
|
-
|
|
1681
|
-
const
|
|
1682
|
-
|
|
1683
|
-
|
|
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
|
-
|
|
2622
|
-
|
|
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 = ['
|
|
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 =
|
|
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
|
-
|
|
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
|
-
//
|
|
5007
|
-
|
|
5008
|
-
|
|
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
|
|
189
|
-
|
|
190
|
-
|
|
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
package/test/accuracy.test.js
CHANGED
|
@@ -1376,7 +1376,7 @@ class Child(Base):
|
|
|
1376
1376
|
} finally { rm(d); }
|
|
1377
1377
|
});
|
|
1378
1378
|
|
|
1379
|
-
it('
|
|
1379
|
+
it('PASS: deep inheritance chain (3+ levels)', () => {
|
|
1380
1380
|
const d = tmp({
|
|
1381
1381
|
'package.json': '{"name":"t"}',
|
|
1382
1382
|
'lib.js': `
|
package/test/parser.test.js
CHANGED
|
@@ -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');
|