ucn 3.7.1 → 3.7.3
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/README.md +21 -41
- package/core/project.js +239 -36
- package/package.json +18 -6
- package/test/accuracy.test.js +1 -1
- package/test/parser.test.js +335 -0
package/README.md
CHANGED
|
@@ -1,12 +1,12 @@
|
|
|
1
1
|
# UCN - Universal Code Navigator
|
|
2
2
|
|
|
3
|
-
UCN
|
|
3
|
+
UCN gives AI agents call-graph-level understanding of code. Instead of reading entire files, agents ask structural questions like: "who calls this function", "what breaks if I change it", "what's unused", and get precise, AST-verified answers. UCN parses JS/TS, Python, Go, Rust, Java, and HTML inline scripts with tree-sitter, then exposes 28 navigation commands as a CLI tool, MCP server, or agent skill.
|
|
4
4
|
|
|
5
|
-
|
|
5
|
+
Designed for large codebases where agents waste context on reading large files. UCN's surgical output means agents spend tokens on reasoning, not on ingesting thousands of lines to find three callers, discourages agents from cutting corners, as without UCN, agents working with large codebases tend to skip parts of the code structure, assuming they have "enough data".
|
|
6
6
|
|
|
7
7
|
---
|
|
8
8
|
|
|
9
|
-
## Three Ways to
|
|
9
|
+
## Three Ways to it: ucn mcp, ucn skill, ucn cli
|
|
10
10
|
|
|
11
11
|
```
|
|
12
12
|
┌──────────────────────────────────────────────────────────────────────┐
|
|
@@ -25,9 +25,9 @@ Supported languages: JS/TS, Python, Go, Rust, Java. Also parses HTML files (inli
|
|
|
25
25
|
|
|
26
26
|
---
|
|
27
27
|
|
|
28
|
-
##
|
|
28
|
+
## How agents understand code today
|
|
29
29
|
|
|
30
|
-
|
|
30
|
+
AI agents working with code typically do this:
|
|
31
31
|
|
|
32
32
|
```
|
|
33
33
|
grep "functionName" → 47 matches, 23 files
|
|
@@ -53,28 +53,7 @@ Typically, AI agents working with code do something like this:
|
|
|
53
53
|
|
|
54
54
|
---
|
|
55
55
|
|
|
56
|
-
##
|
|
57
|
-
|
|
58
|
-
UCN parses the code with tree-sitter and offers semantic navigation tools.
|
|
59
|
-
|
|
60
|
-
Instead of reading entire files, ask precise questions:
|
|
61
|
-
|
|
62
|
-
```
|
|
63
|
-
┌──────────────────────────────────────┐
|
|
64
|
-
│ │
|
|
65
|
-
│ "Who calls this function?" │──→ list of actual callers
|
|
66
|
-
│ │
|
|
67
|
-
│ "What breaks if I change this?" │──→ every call site, with arguments
|
|
68
|
-
│ │
|
|
69
|
-
│ "Show me this function and │──→ source + dependencies inline
|
|
70
|
-
│ everything it depends on" │
|
|
71
|
-
│ │
|
|
72
|
-
└──────────────────────────────────────┘
|
|
73
|
-
```
|
|
74
|
-
|
|
75
|
-
---
|
|
76
|
-
|
|
77
|
-
## How It Works
|
|
56
|
+
## How UCN works: tree-sitter, locally
|
|
78
57
|
|
|
79
58
|
```
|
|
80
59
|
┌──────────────────────────────────────────────┐
|
|
@@ -93,17 +72,17 @@ Instead of reading entire files, ask precise questions:
|
|
|
93
72
|
│
|
|
94
73
|
tree-sitter AST
|
|
95
74
|
│
|
|
96
|
-
|
|
97
|
-
|
|
98
|
-
|
|
99
|
-
|
|
75
|
+
┌─────────────────┴───────────────────┐
|
|
76
|
+
│ Supported Languages │
|
|
77
|
+
│ JS/TS, Python, Go, Rust, Java, HTML │
|
|
78
|
+
└─────────────────────────────────────┘
|
|
100
79
|
```
|
|
101
80
|
|
|
102
81
|
No cloud. No API keys. Parses locally, stays local.
|
|
103
82
|
|
|
104
83
|
---
|
|
105
84
|
|
|
106
|
-
## Before
|
|
85
|
+
## Before and after UCN
|
|
107
86
|
|
|
108
87
|
```
|
|
109
88
|
WITHOUT UCN WITH UCN
|
|
@@ -141,7 +120,7 @@ No cloud. No API keys. Parses locally, stays local.
|
|
|
141
120
|
Context spent on file contents Context spent on reasoning
|
|
142
121
|
```
|
|
143
122
|
|
|
144
|
-
After editing code:
|
|
123
|
+
After editing code, before committing:
|
|
145
124
|
|
|
146
125
|
```
|
|
147
126
|
WITHOUT UCN WITH UCN
|
|
@@ -170,7 +149,7 @@ After editing code:
|
|
|
170
149
|
|
|
171
150
|
---
|
|
172
151
|
|
|
173
|
-
##
|
|
152
|
+
## Text search vs AST
|
|
174
153
|
|
|
175
154
|
```
|
|
176
155
|
Code: processOrder(items, user)
|
|
@@ -206,11 +185,11 @@ After editing code:
|
|
|
206
185
|
└─────────────────────────────────────────────────────────────────┘
|
|
207
186
|
```
|
|
208
187
|
|
|
209
|
-
The tradeoff:
|
|
188
|
+
The tradeoff: text search works on any language and any text. UCN only works on 5 languages + HTML, but gives structural understanding within those.
|
|
210
189
|
|
|
211
190
|
---
|
|
212
191
|
|
|
213
|
-
##
|
|
192
|
+
## UCN commands in action
|
|
214
193
|
|
|
215
194
|
Extract a function from a large file without reading it:
|
|
216
195
|
|
|
@@ -461,7 +440,7 @@ ucn --interactive # Multiple queries, index stays in memory
|
|
|
461
440
|
|
|
462
441
|
---
|
|
463
442
|
|
|
464
|
-
##
|
|
443
|
+
## UCN workflows
|
|
465
444
|
|
|
466
445
|
Investigating a bug:
|
|
467
446
|
```bash
|
|
@@ -492,7 +471,7 @@ ucn toc # Project overview
|
|
|
492
471
|
|
|
493
472
|
---
|
|
494
473
|
|
|
495
|
-
## Limitations
|
|
474
|
+
## Limitations
|
|
496
475
|
|
|
497
476
|
```
|
|
498
477
|
┌──────────────────────────┬──────────────────────────────────────────┐
|
|
@@ -500,8 +479,9 @@ ucn toc # Project overview
|
|
|
500
479
|
├──────────────────────────┼──────────────────────────────────────────┤
|
|
501
480
|
│ │ │
|
|
502
481
|
│ 5 languages + HTML │ JS/TS, Python, Go, Rust, Java. │
|
|
503
|
-
│ (no C, Ruby, PHP, etc.) │ Agents fall back to
|
|
504
|
-
│ │ UCN complements, doesn't
|
|
482
|
+
│ (no C, Ruby, PHP, etc.) │ Agents fall back to text search for │
|
|
483
|
+
│ │ the rest. UCN complements, doesn't │
|
|
484
|
+
│ │ replace. │
|
|
505
485
|
│ │ │
|
|
506
486
|
├──────────────────────────┼──────────────────────────────────────────┤
|
|
507
487
|
│ │ │
|
|
@@ -535,7 +515,7 @@ ucn toc # Project overview
|
|
|
535
515
|
|
|
536
516
|
---
|
|
537
517
|
|
|
538
|
-
## All 28
|
|
518
|
+
## All 28 UCN tools
|
|
539
519
|
|
|
540
520
|
```
|
|
541
521
|
UNDERSTAND MODIFY SAFELY
|
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
|
}
|
|
@@ -4830,8 +4901,8 @@ class ProjectIndex {
|
|
|
4830
4901
|
throw new Error('Not a git repository. diff-impact requires git.');
|
|
4831
4902
|
}
|
|
4832
4903
|
|
|
4833
|
-
// Build git diff command
|
|
4834
|
-
const diffArgs = ['
|
|
4904
|
+
// Build git diff command (use execFileSync to avoid shell expansion)
|
|
4905
|
+
const diffArgs = ['diff', '--unified=0'];
|
|
4835
4906
|
if (staged) {
|
|
4836
4907
|
diffArgs.push('--staged');
|
|
4837
4908
|
} else {
|
|
@@ -4843,7 +4914,7 @@ class ProjectIndex {
|
|
|
4843
4914
|
|
|
4844
4915
|
let diffText;
|
|
4845
4916
|
try {
|
|
4846
|
-
diffText =
|
|
4917
|
+
diffText = execFileSync('git', diffArgs, { cwd: this.root, encoding: 'utf-8', maxBuffer: 10 * 1024 * 1024 });
|
|
4847
4918
|
} catch (e) {
|
|
4848
4919
|
// git diff exits non-zero when there are diff errors, but also for invalid refs
|
|
4849
4920
|
if (e.stdout) {
|
|
@@ -4878,7 +4949,31 @@ class ProjectIndex {
|
|
|
4878
4949
|
if (!lang) continue;
|
|
4879
4950
|
|
|
4880
4951
|
const fileEntry = this.files.get(change.filePath);
|
|
4881
|
-
|
|
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
|
+
}
|
|
4882
4977
|
|
|
4883
4978
|
// Track which functions are affected by added/modified lines
|
|
4884
4979
|
const affectedSymbols = new Map(); // symbolName -> { symbol, addedLines, deletedLines }
|
|
@@ -4960,6 +5055,57 @@ class ProjectIndex {
|
|
|
4960
5055
|
}
|
|
4961
5056
|
}
|
|
4962
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
|
+
|
|
4963
5109
|
// For each affected function, find callers
|
|
4964
5110
|
for (const [, data] of affectedSymbols) {
|
|
4965
5111
|
const { symbol, addedLines: aLines, deletedLines: dLines } = data;
|
|
@@ -5012,6 +5158,40 @@ class ProjectIndex {
|
|
|
5012
5158
|
}
|
|
5013
5159
|
}
|
|
5014
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
|
+
|
|
5015
5195
|
/**
|
|
5016
5196
|
* Parse unified diff output into structured change data
|
|
5017
5197
|
* @param {string} diffText - Output from `git diff --unified=0`
|
|
@@ -5021,16 +5201,39 @@ class ProjectIndex {
|
|
|
5021
5201
|
function parseDiff(diffText, root) {
|
|
5022
5202
|
const changes = [];
|
|
5023
5203
|
let currentFile = null;
|
|
5204
|
+
let pendingOldPath = null; // Track --- a/ path for deleted files
|
|
5024
5205
|
|
|
5025
5206
|
for (const line of diffText.split('\n')) {
|
|
5026
|
-
//
|
|
5027
|
-
|
|
5028
|
-
|
|
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;
|
|
5029
5231
|
currentFile = {
|
|
5030
5232
|
filePath: path.join(root, relativePath),
|
|
5031
5233
|
relativePath,
|
|
5032
5234
|
addedLines: [],
|
|
5033
|
-
deletedLines: []
|
|
5235
|
+
deletedLines: [],
|
|
5236
|
+
...(isDevNull && { isDeleted: true })
|
|
5034
5237
|
};
|
|
5035
5238
|
changes.push(currentFile);
|
|
5036
5239
|
continue;
|
package/package.json
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "ucn",
|
|
3
|
-
"version": "3.7.
|
|
4
|
-
"description": "Universal Code Navigator —
|
|
3
|
+
"version": "3.7.3",
|
|
4
|
+
"description": "Universal Code Navigator — AST-based call graph analysis for AI agents. Find callers, trace impact, detect dead code across JS/TS, Python, Go, Rust, Java, and HTML. CLI, MCP server, and agent skill.",
|
|
5
5
|
"main": "index.js",
|
|
6
6
|
"bin": {
|
|
7
7
|
"ucn": "cli/index.js",
|
|
@@ -11,19 +11,31 @@
|
|
|
11
11
|
"test": "node --test test/parser.test.js test/accuracy.test.js test/systematic-test.js test/mcp-edge-cases.js"
|
|
12
12
|
},
|
|
13
13
|
"keywords": [
|
|
14
|
+
"mcp",
|
|
15
|
+
"mcp-server",
|
|
16
|
+
"model-context-protocol",
|
|
14
17
|
"code-navigation",
|
|
18
|
+
"code-analysis",
|
|
19
|
+
"static-analysis",
|
|
20
|
+
"call-graph",
|
|
21
|
+
"callers",
|
|
22
|
+
"impact-analysis",
|
|
23
|
+
"dead-code",
|
|
24
|
+
"deadcode",
|
|
15
25
|
"ast",
|
|
16
|
-
"parser",
|
|
17
26
|
"tree-sitter",
|
|
27
|
+
"parser",
|
|
28
|
+
"skill",
|
|
29
|
+
"agent-skill",
|
|
30
|
+
"cli",
|
|
31
|
+
"ai-agent",
|
|
18
32
|
"javascript",
|
|
19
33
|
"typescript",
|
|
20
34
|
"python",
|
|
21
35
|
"go",
|
|
22
36
|
"rust",
|
|
23
37
|
"java",
|
|
24
|
-
"html"
|
|
25
|
-
"ai",
|
|
26
|
-
"agent"
|
|
38
|
+
"html"
|
|
27
39
|
],
|
|
28
40
|
"author": "Constantin-Mihail Leoca (https://github.com/mleoca)",
|
|
29
41
|
"repository": {
|
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
|
@@ -12690,5 +12690,340 @@ function doStuff() { return 42; }
|
|
|
12690
12690
|
});
|
|
12691
12691
|
});
|
|
12692
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');
|
|
13026
|
+
});
|
|
13027
|
+
|
|
12693
13028
|
console.log('UCN v3 Test Suite');
|
|
12694
13029
|
console.log('Run with: node --test test/parser.test.js');
|