ucn 3.7.1 → 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/core/project.js CHANGED
@@ -8,10 +8,10 @@
8
8
  const fs = require('fs');
9
9
  const path = require('path');
10
10
  const crypto = require('crypto');
11
- const { execSync } = require('child_process');
11
+ const { execSync, execFileSync } = require('child_process');
12
12
  const { expandGlob, findProjectRoot, detectProjectPattern, isTestFile, parseGitignore, DEFAULT_IGNORES } = require('./discovery');
13
13
  const { extractImports, extractExports, resolveImport } = require('./imports');
14
- const { parseFile, cleanHtmlScriptTags } = require('./parser');
14
+ const { parse, parseFile, cleanHtmlScriptTags } = require('./parser');
15
15
  const { detectLanguage, getParser, getLanguageModule, PARSE_OPTIONS, safeParse } = require('../languages');
16
16
  const { getTokenTypeAtPosition } = require('../languages/utils');
17
17
 
@@ -402,7 +402,15 @@ class ProjectIndex {
402
402
  return parent;
403
403
  });
404
404
 
405
- this.extendsGraph.set(symbol.name, resolvedParents);
405
+ // Store with file scope to avoid collisions when same class name
406
+ // appears in multiple files (F-002 fix)
407
+ if (!this.extendsGraph.has(symbol.name)) {
408
+ this.extendsGraph.set(symbol.name, []);
409
+ }
410
+ this.extendsGraph.get(symbol.name).push({
411
+ file: filePath,
412
+ parents: resolvedParents
413
+ });
406
414
 
407
415
  for (const parent of resolvedParents) {
408
416
  if (!this.extendedByGraph.has(parent)) {
@@ -419,6 +427,66 @@ class ProjectIndex {
419
427
  }
420
428
  }
421
429
 
430
+ /**
431
+ * Get inheritance parents for a class, scoped by file to handle
432
+ * duplicate class names across files.
433
+ * @param {string} className - Class name to look up
434
+ * @param {string} contextFile - File path for scoping (prefer same-file match)
435
+ * @returns {string[]|null} Parent class names, or null if none
436
+ */
437
+ _getInheritanceParents(className, contextFile) {
438
+ const entries = this.extendsGraph.get(className);
439
+ if (!entries || entries.length === 0) return null;
440
+
441
+ // New format: array of {file, parents}
442
+ if (typeof entries[0] === 'object' && entries[0].file !== undefined) {
443
+ // Prefer same-file match
444
+ const match = entries.find(e => e.file === contextFile);
445
+ if (match) return match.parents;
446
+
447
+ // Try imported file
448
+ if (contextFile) {
449
+ const imports = this.importGraph.get(contextFile);
450
+ if (imports) {
451
+ const imported = entries.find(e => imports.includes(e.file));
452
+ if (imported) return imported.parents;
453
+ }
454
+ }
455
+
456
+ // Fallback to first entry
457
+ return entries[0].parents;
458
+ }
459
+
460
+ // Old format (cache compat): plain array of parent names
461
+ return entries;
462
+ }
463
+
464
+ /**
465
+ * Resolve which file a class is defined in, preferring contextFile.
466
+ * Used during inheritance BFS to find grandparent chains.
467
+ * @param {string} className - Class name to resolve
468
+ * @param {string} contextFile - Preferred file (e.g., child's file)
469
+ * @returns {string|null} Resolved file path
470
+ */
471
+ _resolveClassFile(className, contextFile) {
472
+ const symbols = this.symbols.get(className);
473
+ if (!symbols) return contextFile;
474
+ const classSymbols = symbols.filter(s =>
475
+ ['class', 'interface', 'struct', 'trait'].includes(s.type));
476
+ if (classSymbols.length === 0) return contextFile;
477
+ // Prefer same file as context
478
+ if (classSymbols.some(s => s.file === contextFile)) return contextFile;
479
+ // Prefer imported
480
+ if (contextFile) {
481
+ const imports = this.importGraph.get(contextFile);
482
+ if (imports) {
483
+ const imported = classSymbols.find(s => imports.includes(s.file));
484
+ if (imported) return imported.file;
485
+ }
486
+ }
487
+ return classSymbols[0].file;
488
+ }
489
+
422
490
 
423
491
  // ========================================================================
424
492
  // QUERY METHODS
@@ -1246,21 +1314,23 @@ class ProjectIndex {
1246
1314
  let matchesDef = call.receiver === 'super'
1247
1315
  ? false
1248
1316
  : definitions.some(d => d.className === callerSymbol.className);
1249
- // Walk inheritance chain if not found in same class
1317
+ // Walk inheritance chain using BFS if not found in same class
1250
1318
  if (!matchesDef) {
1251
- let parents = this.extendsGraph.get(callerSymbol.className);
1252
1319
  const visited = new Set([callerSymbol.className]);
1253
- while (parents && !matchesDef) {
1254
- for (const parent of parents) {
1255
- if (visited.has(parent)) continue;
1256
- visited.add(parent);
1257
- matchesDef = definitions.some(d => d.className === parent);
1258
- if (matchesDef) break;
1259
- }
1320
+ const callerFile = callerSymbol.file || filePath;
1321
+ const startParents = this._getInheritanceParents(callerSymbol.className, callerFile) || [];
1322
+ const queue = startParents.map(p => ({ name: p, contextFile: callerFile }));
1323
+ while (queue.length > 0 && !matchesDef) {
1324
+ const { name: current, contextFile } = queue.shift();
1325
+ if (visited.has(current)) continue;
1326
+ visited.add(current);
1327
+ matchesDef = definitions.some(d => d.className === current);
1260
1328
  if (!matchesDef) {
1261
- const nextParent = parents.find(p => !visited.has(p));
1262
- if (!nextParent) break;
1263
- parents = this.extendsGraph.get(nextParent);
1329
+ const resolvedFile = this._resolveClassFile(current, contextFile);
1330
+ const grandparents = this._getInheritanceParents(current, resolvedFile) || [];
1331
+ for (const gp of grandparents) {
1332
+ if (!visited.has(gp)) queue.push({ name: gp, contextFile: resolvedFile });
1333
+ }
1264
1334
  }
1265
1335
  }
1266
1336
  }
@@ -1665,22 +1735,23 @@ class ProjectIndex {
1665
1735
  ? null
1666
1736
  : symbols.find(s => s.className === def.className);
1667
1737
 
1668
- // Walk inheritance chain if not found in same class
1738
+ // Walk inheritance chain using BFS if not found in same class
1669
1739
  if (!match) {
1670
- let parents = this.extendsGraph.get(def.className);
1671
1740
  const visited = new Set([def.className]);
1672
- while (parents && !match) {
1673
- for (const parent of parents) {
1674
- if (visited.has(parent)) continue;
1675
- visited.add(parent);
1676
- match = symbols.find(s => s.className === parent);
1677
- if (match) break;
1678
- }
1741
+ const defFile = def.file;
1742
+ const startParents = this._getInheritanceParents(def.className, defFile) || [];
1743
+ const queue = startParents.map(p => ({ name: p, contextFile: defFile }));
1744
+ while (queue.length > 0 && !match) {
1745
+ const { name: current, contextFile } = queue.shift();
1746
+ if (visited.has(current)) continue;
1747
+ visited.add(current);
1748
+ match = symbols.find(s => s.className === current);
1679
1749
  if (!match) {
1680
- // Follow first parent's chain (simplified MRO)
1681
- const nextParent = parents.find(p => !visited.has(p));
1682
- if (!nextParent) break;
1683
- parents = this.extendsGraph.get(nextParent);
1750
+ const resolvedFile = this._resolveClassFile(current, contextFile);
1751
+ const grandparents = this._getInheritanceParents(current, resolvedFile) || [];
1752
+ for (const gp of grandparents) {
1753
+ if (!visited.has(gp)) queue.push({ name: gp, contextFile: resolvedFile });
1754
+ }
1684
1755
  }
1685
1756
  }
1686
1757
  }
@@ -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 = ['git', 'diff', '--unified=0'];
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 = execSync(diffArgs.join(' '), { cwd: this.root, encoding: 'utf-8', maxBuffer: 10 * 1024 * 1024 });
4917
+ diffText = execFileSync('git', diffArgs, { cwd: this.root, encoding: 'utf-8', maxBuffer: 10 * 1024 * 1024 });
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
- if (!fileEntry) continue;
4952
+
4953
+ // Handle deleted files: entire file was removed, all functions are deleted
4954
+ if (!fileEntry) {
4955
+ if (change.isDeleted && change.deletedLines.length > 0) {
4956
+ const ref = staged ? 'HEAD' : base;
4957
+ try {
4958
+ const oldContent = execFileSync(
4959
+ 'git', ['show', `${ref}:${change.relativePath}`],
4960
+ { cwd: this.root, encoding: 'utf-8', maxBuffer: 10 * 1024 * 1024 }
4961
+ );
4962
+ const oldParsed = parse(oldContent, lang);
4963
+ for (const oldFn of extractCallableSymbols(oldParsed)) {
4964
+ deletedFunctions.push({
4965
+ name: oldFn.name,
4966
+ filePath: change.filePath,
4967
+ relativePath: change.relativePath,
4968
+ startLine: oldFn.startLine
4969
+ });
4970
+ }
4971
+ } catch (e) {
4972
+ // git show failed — skip
4973
+ }
4974
+ }
4975
+ continue;
4976
+ }
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
- // Match file header: +++ b/path/to/file.js
5027
- if (line.startsWith('+++ b/')) {
5028
- const relativePath = line.slice(6);
5207
+ // Track old file path from --- header for deleted-file detection
5208
+ // Handles both unquoted (--- a/path) and quoted (--- "a/path") formats
5209
+ const oldMatch = line.match(/^--- (?:"a\/((?:[^"\\]|\\.)*)"|a\/(.+?))\s*$/);
5210
+ if (oldMatch) {
5211
+ const raw = oldMatch[1] !== undefined ? oldMatch[1] : oldMatch[2];
5212
+ pendingOldPath = unquoteDiffPath(raw);
5213
+ continue;
5214
+ }
5215
+
5216
+ // Match file header: +++ b/path or +++ "b/path" or +++ /dev/null
5217
+ if (line.startsWith('+++ ')) {
5218
+ let relativePath;
5219
+ const isDevNull = line.startsWith('+++ /dev/null');
5220
+ if (isDevNull) {
5221
+ // File was deleted — use the --- a/ path
5222
+ if (!pendingOldPath) continue;
5223
+ relativePath = pendingOldPath;
5224
+ } else {
5225
+ const newMatch = line.match(/^\+\+\+ (?:"b\/((?:[^"\\]|\\.)*)"|b\/(.+?))\s*$/);
5226
+ if (!newMatch) continue;
5227
+ const raw = newMatch[1] !== undefined ? newMatch[1] : newMatch[2];
5228
+ relativePath = unquoteDiffPath(raw);
5229
+ }
5230
+ pendingOldPath = null;
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,6 +1,6 @@
1
1
  {
2
2
  "name": "ucn",
3
- "version": "3.7.1",
3
+ "version": "3.7.2",
4
4
  "description": "Universal Code Navigator — function relationships, call trees, and impact analysis across large codebases without reading entire files.",
5
5
  "main": "index.js",
6
6
  "bin": {
@@ -1376,7 +1376,7 @@ class Child(Base):
1376
1376
  } finally { rm(d); }
1377
1377
  });
1378
1378
 
1379
- it('LIMITATION: deep inheritance chain (3+ levels)', () => {
1379
+ it('PASS: deep inheritance chain (3+ levels)', () => {
1380
1380
  const d = tmp({
1381
1381
  'package.json': '{"name":"t"}',
1382
1382
  'lib.js': `
@@ -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');