ucn 3.0.0

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.

Potentially problematic release.


This version of ucn might be problematic. Click here for more details.

Files changed (45) hide show
  1. package/.claude/skills/ucn/SKILL.md +77 -0
  2. package/LICENSE +21 -0
  3. package/README.md +135 -0
  4. package/cli/index.js +2437 -0
  5. package/core/discovery.js +513 -0
  6. package/core/imports.js +558 -0
  7. package/core/output.js +1274 -0
  8. package/core/parser.js +279 -0
  9. package/core/project.js +3261 -0
  10. package/index.js +52 -0
  11. package/languages/go.js +653 -0
  12. package/languages/index.js +267 -0
  13. package/languages/java.js +826 -0
  14. package/languages/javascript.js +1346 -0
  15. package/languages/python.js +667 -0
  16. package/languages/rust.js +950 -0
  17. package/languages/utils.js +457 -0
  18. package/package.json +42 -0
  19. package/test/fixtures/go/go.mod +3 -0
  20. package/test/fixtures/go/main.go +257 -0
  21. package/test/fixtures/go/service.go +187 -0
  22. package/test/fixtures/java/DataService.java +279 -0
  23. package/test/fixtures/java/Main.java +287 -0
  24. package/test/fixtures/java/Utils.java +199 -0
  25. package/test/fixtures/java/pom.xml +6 -0
  26. package/test/fixtures/javascript/main.js +109 -0
  27. package/test/fixtures/javascript/package.json +1 -0
  28. package/test/fixtures/javascript/service.js +88 -0
  29. package/test/fixtures/javascript/utils.js +67 -0
  30. package/test/fixtures/python/main.py +198 -0
  31. package/test/fixtures/python/pyproject.toml +3 -0
  32. package/test/fixtures/python/service.py +166 -0
  33. package/test/fixtures/python/utils.py +118 -0
  34. package/test/fixtures/rust/Cargo.toml +3 -0
  35. package/test/fixtures/rust/main.rs +253 -0
  36. package/test/fixtures/rust/service.rs +210 -0
  37. package/test/fixtures/rust/utils.rs +154 -0
  38. package/test/fixtures/typescript/main.ts +154 -0
  39. package/test/fixtures/typescript/package.json +1 -0
  40. package/test/fixtures/typescript/repository.ts +149 -0
  41. package/test/fixtures/typescript/types.ts +114 -0
  42. package/test/parser.test.js +3661 -0
  43. package/test/public-repos-test.js +477 -0
  44. package/test/systematic-test.js +619 -0
  45. package/ucn.js +8 -0
@@ -0,0 +1,3661 @@
1
+ /**
2
+ * UCN v3 Parser Test Suite
3
+ */
4
+
5
+ const { describe, it } = require('node:test');
6
+ const assert = require('node:assert');
7
+ const path = require('path');
8
+ const fs = require('fs');
9
+ const os = require('os');
10
+
11
+ // Test the library
12
+ const { parse, parseFile, detectLanguage, isSupported } = require('../core/parser');
13
+ const { ProjectIndex } = require('../core/project');
14
+ const { expandGlob } = require('../core/discovery');
15
+
16
+ // ============================================================================
17
+ // LANGUAGE DETECTION
18
+ // ============================================================================
19
+
20
+ describe('Language Detection', () => {
21
+ it('detects JavaScript files', () => {
22
+ assert.strictEqual(detectLanguage('file.js'), 'javascript');
23
+ assert.strictEqual(detectLanguage('file.jsx'), 'javascript');
24
+ assert.strictEqual(detectLanguage('file.mjs'), 'javascript');
25
+ });
26
+
27
+ it('detects TypeScript files', () => {
28
+ assert.strictEqual(detectLanguage('file.ts'), 'typescript');
29
+ assert.strictEqual(detectLanguage('file.tsx'), 'tsx');
30
+ });
31
+
32
+ it('detects Python files', () => {
33
+ assert.strictEqual(detectLanguage('file.py'), 'python');
34
+ });
35
+
36
+ it('detects Go files', () => {
37
+ assert.strictEqual(detectLanguage('file.go'), 'go');
38
+ });
39
+
40
+ it('detects Rust files', () => {
41
+ assert.strictEqual(detectLanguage('file.rs'), 'rust');
42
+ });
43
+
44
+ it('detects Java files', () => {
45
+ assert.strictEqual(detectLanguage('file.java'), 'java');
46
+ });
47
+
48
+ it('returns null for unsupported files', () => {
49
+ assert.strictEqual(detectLanguage('file.txt'), null);
50
+ assert.strictEqual(detectLanguage('file.md'), null);
51
+ });
52
+ });
53
+
54
+ // ============================================================================
55
+ // JAVASCRIPT PARSING
56
+ // ============================================================================
57
+
58
+ describe('JavaScript Parsing', () => {
59
+ it('parses function declarations', () => {
60
+ const code = `
61
+ function hello(name) {
62
+ return 'Hello ' + name;
63
+ }`;
64
+ const result = parse(code, 'javascript');
65
+ assert.strictEqual(result.functions.length, 1);
66
+ assert.strictEqual(result.functions[0].name, 'hello');
67
+ assert.strictEqual(result.functions[0].params, 'name');
68
+ });
69
+
70
+ it('parses arrow functions', () => {
71
+ const code = `
72
+ const add = (a, b) => a + b;`;
73
+ const result = parse(code, 'javascript');
74
+ assert.strictEqual(result.functions.length, 1);
75
+ assert.strictEqual(result.functions[0].name, 'add');
76
+ assert.strictEqual(result.functions[0].isArrow, true);
77
+ });
78
+
79
+ it('parses async functions', () => {
80
+ const code = `
81
+ async function fetchData(url) {
82
+ return await fetch(url);
83
+ }`;
84
+ const result = parse(code, 'javascript');
85
+ assert.strictEqual(result.functions.length, 1);
86
+ assert.ok(result.functions[0].modifiers.includes('async'));
87
+ });
88
+
89
+ it('parses classes', () => {
90
+ const code = `
91
+ class User {
92
+ constructor(name) {
93
+ this.name = name;
94
+ }
95
+
96
+ greet() {
97
+ return 'Hello ' + this.name;
98
+ }
99
+ }`;
100
+ const result = parse(code, 'javascript');
101
+ assert.strictEqual(result.classes.length, 1);
102
+ assert.strictEqual(result.classes[0].name, 'User');
103
+ assert.strictEqual(result.classes[0].members.length, 2);
104
+ });
105
+
106
+ it('parses generator functions', () => {
107
+ const code = `
108
+ function* generateNumbers() {
109
+ yield 1;
110
+ yield 2;
111
+ }`;
112
+ const result = parse(code, 'javascript');
113
+ assert.strictEqual(result.functions.length, 1);
114
+ assert.strictEqual(result.functions[0].isGenerator, true);
115
+ });
116
+
117
+ it('parses exported functions', () => {
118
+ const code = `
119
+ export function publicFn() {}
120
+ export default function main() {}`;
121
+ const result = parse(code, 'javascript');
122
+ assert.strictEqual(result.functions.length, 2);
123
+ // Both should have export in modifiers (checked via presence of export keyword)
124
+ assert.ok(result.functions.some(f => f.name === 'publicFn'));
125
+ assert.ok(result.functions.some(f => f.name === 'main' || f.name === 'default'));
126
+ });
127
+ });
128
+
129
+ // ============================================================================
130
+ // TYPESCRIPT PARSING
131
+ // ============================================================================
132
+
133
+ describe('TypeScript Parsing', () => {
134
+ it('parses typed functions', () => {
135
+ const code = `
136
+ function greet(name: string): string {
137
+ return 'Hello ' + name;
138
+ }`;
139
+ const result = parse(code, 'typescript');
140
+ assert.strictEqual(result.functions.length, 1);
141
+ assert.strictEqual(result.functions[0].returnType, 'string');
142
+ });
143
+
144
+ it('parses interfaces', () => {
145
+ const code = `
146
+ interface User {
147
+ name: string;
148
+ age: number;
149
+ }`;
150
+ const result = parse(code, 'typescript');
151
+ assert.strictEqual(result.classes.length, 1);
152
+ assert.strictEqual(result.classes[0].type, 'interface');
153
+ });
154
+
155
+ it('parses type aliases', () => {
156
+ const code = `
157
+ type ID = string | number;`;
158
+ const result = parse(code, 'typescript');
159
+ assert.strictEqual(result.classes.length, 1);
160
+ assert.strictEqual(result.classes[0].type, 'type');
161
+ });
162
+
163
+ it('parses enums', () => {
164
+ const code = `
165
+ enum Status {
166
+ Active,
167
+ Inactive
168
+ }`;
169
+ const result = parse(code, 'typescript');
170
+ assert.strictEqual(result.classes.length, 1);
171
+ assert.strictEqual(result.classes[0].type, 'enum');
172
+ });
173
+
174
+ it('parses generic functions', () => {
175
+ const code = `
176
+ function identity<T>(arg: T): T {
177
+ return arg;
178
+ }`;
179
+ const result = parse(code, 'typescript');
180
+ assert.strictEqual(result.functions.length, 1);
181
+ assert.ok(result.functions[0].generics);
182
+ });
183
+ });
184
+
185
+ // ============================================================================
186
+ // PYTHON PARSING
187
+ // ============================================================================
188
+
189
+ describe('Python Parsing', () => {
190
+ it('parses function definitions', () => {
191
+ const code = `
192
+ def hello(name):
193
+ return 'Hello ' + name`;
194
+ const result = parse(code, 'python');
195
+ assert.strictEqual(result.functions.length, 1);
196
+ assert.strictEqual(result.functions[0].name, 'hello');
197
+ });
198
+
199
+ it('parses typed functions', () => {
200
+ const code = `
201
+ def greet(name: str) -> str:
202
+ return 'Hello ' + name`;
203
+ const result = parse(code, 'python');
204
+ assert.strictEqual(result.functions.length, 1);
205
+ assert.strictEqual(result.functions[0].returnType, 'str');
206
+ });
207
+
208
+ it('parses async functions', () => {
209
+ const code = `
210
+ async def fetch_data(url):
211
+ return await get(url)`;
212
+ const result = parse(code, 'python');
213
+ assert.strictEqual(result.functions.length, 1);
214
+ assert.strictEqual(result.functions[0].isAsync, true);
215
+ });
216
+
217
+ it('parses decorated functions', () => {
218
+ const code = `
219
+ @staticmethod
220
+ def helper():
221
+ pass`;
222
+ const result = parse(code, 'python');
223
+ assert.strictEqual(result.functions.length, 1);
224
+ assert.ok(result.functions[0].decorators);
225
+ });
226
+
227
+ it('parses classes', () => {
228
+ const code = `
229
+ class User:
230
+ def __init__(self, name):
231
+ self.name = name
232
+
233
+ def greet(self):
234
+ return 'Hello ' + self.name`;
235
+ const result = parse(code, 'python');
236
+ assert.strictEqual(result.classes.length, 1);
237
+ assert.strictEqual(result.classes[0].name, 'User');
238
+ });
239
+ });
240
+
241
+ // ============================================================================
242
+ // GO PARSING
243
+ // ============================================================================
244
+
245
+ describe('Go Parsing', () => {
246
+ it('parses function declarations', () => {
247
+ const code = `
248
+ func Hello(name string) string {
249
+ return "Hello " + name
250
+ }`;
251
+ const result = parse(code, 'go');
252
+ assert.strictEqual(result.functions.length, 1);
253
+ assert.strictEqual(result.functions[0].name, 'Hello');
254
+ });
255
+
256
+ it('parses methods', () => {
257
+ const code = `
258
+ func (u *User) Greet() string {
259
+ return "Hello " + u.Name
260
+ }`;
261
+ const result = parse(code, 'go');
262
+ assert.strictEqual(result.functions.length, 1);
263
+ assert.strictEqual(result.functions[0].isMethod, true);
264
+ assert.strictEqual(result.functions[0].receiver, '*User');
265
+ });
266
+
267
+ it('parses structs', () => {
268
+ const code = `
269
+ type User struct {
270
+ Name string
271
+ Age int
272
+ }`;
273
+ const result = parse(code, 'go');
274
+ assert.strictEqual(result.classes.length, 1);
275
+ assert.strictEqual(result.classes[0].type, 'struct');
276
+ });
277
+
278
+ it('parses interfaces', () => {
279
+ const code = `
280
+ type Reader interface {
281
+ Read(p []byte) (n int, err error)
282
+ }`;
283
+ const result = parse(code, 'go');
284
+ assert.strictEqual(result.classes.length, 1);
285
+ assert.strictEqual(result.classes[0].type, 'interface');
286
+ });
287
+ });
288
+
289
+ // ============================================================================
290
+ // RUST PARSING
291
+ // ============================================================================
292
+
293
+ describe('Rust Parsing', () => {
294
+ it('parses function definitions', () => {
295
+ const code = `
296
+ fn hello(name: &str) -> String {
297
+ format!("Hello {}", name)
298
+ }`;
299
+ const result = parse(code, 'rust');
300
+ assert.strictEqual(result.functions.length, 1);
301
+ assert.strictEqual(result.functions[0].name, 'hello');
302
+ });
303
+
304
+ it('parses async functions', () => {
305
+ const code = `
306
+ async fn fetch_data(url: &str) -> Result<String, Error> {
307
+ Ok(String::new())
308
+ }`;
309
+ const result = parse(code, 'rust');
310
+ assert.strictEqual(result.functions.length, 1);
311
+ assert.ok(result.functions[0].modifiers.includes('async'));
312
+ });
313
+
314
+ it('parses structs', () => {
315
+ const code = `
316
+ struct User {
317
+ name: String,
318
+ age: u32,
319
+ }`;
320
+ const result = parse(code, 'rust');
321
+ assert.strictEqual(result.classes.length, 1);
322
+ assert.strictEqual(result.classes[0].type, 'struct');
323
+ });
324
+
325
+ it('parses impl blocks', () => {
326
+ const code = `
327
+ impl User {
328
+ fn new(name: String) -> Self {
329
+ User { name, age: 0 }
330
+ }
331
+ }`;
332
+ const result = parse(code, 'rust');
333
+ assert.strictEqual(result.classes.length, 1);
334
+ assert.strictEqual(result.classes[0].type, 'impl');
335
+ });
336
+
337
+ it('parses traits', () => {
338
+ const code = `
339
+ trait Greet {
340
+ fn greet(&self) -> String;
341
+ }`;
342
+ const result = parse(code, 'rust');
343
+ assert.strictEqual(result.classes.length, 1);
344
+ assert.strictEqual(result.classes[0].type, 'trait');
345
+ });
346
+ });
347
+
348
+ // ============================================================================
349
+ // JAVA PARSING
350
+ // ============================================================================
351
+
352
+ describe('Java Parsing', () => {
353
+ it('parses class declarations', () => {
354
+ const code = `
355
+ public class User {
356
+ private String name;
357
+
358
+ public User(String name) {
359
+ this.name = name;
360
+ }
361
+
362
+ public String getName() {
363
+ return name;
364
+ }
365
+ }`;
366
+ const result = parse(code, 'java');
367
+ assert.strictEqual(result.classes.length, 1);
368
+ assert.strictEqual(result.classes[0].name, 'User');
369
+ });
370
+
371
+ it('parses interfaces', () => {
372
+ const code = `
373
+ public interface UserService {
374
+ User getUser(int id);
375
+ }`;
376
+ const result = parse(code, 'java');
377
+ assert.strictEqual(result.classes.length, 1);
378
+ assert.strictEqual(result.classes[0].type, 'interface');
379
+ });
380
+
381
+ it('parses methods with annotations', () => {
382
+ const code = `
383
+ public class Controller {
384
+ @Override
385
+ public void handle() {}
386
+ }`;
387
+ const result = parse(code, 'java');
388
+ // Methods are indexed as part of the class
389
+ assert.strictEqual(result.functions.length >= 0, true);
390
+ });
391
+ });
392
+
393
+ // ============================================================================
394
+ // OUTPUT FORMAT
395
+ // ============================================================================
396
+
397
+ describe('Output Format', () => {
398
+ it('includes full params without truncation', () => {
399
+ const code = `
400
+ function processData(input: { name: string; age: number; address: { street: string; city: string } }): Promise<Result> {
401
+ return Promise.resolve({});
402
+ }`;
403
+ const result = parse(code, 'typescript');
404
+ assert.strictEqual(result.functions.length, 1);
405
+ // Params should NOT be truncated
406
+ assert.ok(result.functions[0].params.includes('address'));
407
+ assert.ok(result.functions[0].params.includes('city'));
408
+ });
409
+
410
+ it('includes return types', () => {
411
+ const code = `
412
+ function getData(): Promise<User[]> {
413
+ return Promise.resolve([]);
414
+ }`;
415
+ const result = parse(code, 'typescript');
416
+ assert.strictEqual(result.functions[0].returnType, 'Promise<User[]>');
417
+ });
418
+
419
+ it('includes generics', () => {
420
+ const code = `
421
+ function map<T, U>(arr: T[], fn: (x: T) => U): U[] {
422
+ return arr.map(fn);
423
+ }`;
424
+ const result = parse(code, 'typescript');
425
+ assert.ok(result.functions[0].generics);
426
+ });
427
+
428
+ it('includes docstrings', () => {
429
+ const code = `
430
+ /**
431
+ * Greets a user by name.
432
+ * @param name - The user's name
433
+ */
434
+ function greet(name: string) {
435
+ return 'Hello ' + name;
436
+ }`;
437
+ const result = parse(code, 'typescript');
438
+ assert.ok(result.functions[0].docstring);
439
+ assert.ok(result.functions[0].docstring.includes('Greets'));
440
+ });
441
+ });
442
+
443
+ // ============================================================================
444
+ // INTEGRATION TESTS
445
+ // ============================================================================
446
+
447
+ describe('Integration Tests', () => {
448
+ it('can parse a simple JS file', () => {
449
+ // Create a simple JS file in memory and parse it
450
+ const code = `
451
+ function hello() {
452
+ return 'Hello';
453
+ }
454
+ class Greeter {
455
+ greet() { return 'Hi'; }
456
+ }`;
457
+ const result = parse(code, 'javascript');
458
+ assert.ok(result.functions.length > 0);
459
+ assert.ok(result.classes.length > 0);
460
+ });
461
+ });
462
+
463
+ // ============================================================================
464
+ // ProjectIndex Tests (v2 Migration)
465
+ // ============================================================================
466
+
467
+ describe('ProjectIndex', () => {
468
+ const { ProjectIndex } = require('../core/project');
469
+ const path = require('path');
470
+
471
+ it('builds index and finds symbols', () => {
472
+ const index = new ProjectIndex('.');
473
+ index.build(null, { quiet: true });
474
+
475
+ const stats = index.getStats();
476
+ assert.ok(stats.files > 0, 'Should index files');
477
+ assert.ok(stats.symbols > 0, 'Should find symbols');
478
+
479
+ const found = index.find('parse');
480
+ assert.ok(found.length > 0, 'Should find parse function');
481
+ });
482
+
483
+ it('gets imports for a file', () => {
484
+ const index = new ProjectIndex('.');
485
+ index.build(null, { quiet: true });
486
+
487
+ const imports = index.imports('core/parser.js');
488
+ assert.ok(imports.length > 0, 'Should find imports');
489
+ assert.ok(imports.some(i => i.module.includes('languages')), 'Should find languages import');
490
+ });
491
+
492
+ it('gets exporters for a file', () => {
493
+ const index = new ProjectIndex('.');
494
+ index.build(null, { quiet: true });
495
+
496
+ const exporters = index.exporters('core/parser.js');
497
+ assert.ok(exporters.length > 0, 'Should find files that import parser.js');
498
+ });
499
+
500
+ it('finds type definitions', () => {
501
+ const index = new ProjectIndex('.');
502
+ index.build(null, { quiet: true });
503
+
504
+ const types = index.typedef('ProjectIndex');
505
+ assert.ok(types.length > 0, 'Should find ProjectIndex class');
506
+ assert.strictEqual(types[0].type, 'class', 'Should be a class');
507
+ });
508
+
509
+ it('finds tests for a function', () => {
510
+ const index = new ProjectIndex('.');
511
+ index.build(null, { quiet: true });
512
+
513
+ const tests = index.tests('parse');
514
+ assert.ok(tests.length > 0, 'Should find tests for parse');
515
+ assert.ok(tests[0].matches.length > 0, 'Should have test matches');
516
+ });
517
+
518
+ it('gets usages grouped by type', () => {
519
+ const index = new ProjectIndex('.');
520
+ index.build(null, { quiet: true });
521
+
522
+ const usages = index.usages('parseFile');
523
+ const defs = usages.filter(u => u.isDefinition);
524
+ const calls = usages.filter(u => u.usageType === 'call');
525
+
526
+ assert.ok(defs.length > 0, 'Should find definition');
527
+ assert.ok(calls.length > 0, 'Should find calls');
528
+ });
529
+
530
+ it('gets context (callers + callees)', () => {
531
+ const index = new ProjectIndex('.');
532
+ index.build(null, { quiet: true });
533
+
534
+ const ctx = index.context('parseFile');
535
+ assert.strictEqual(ctx.function, 'parseFile');
536
+ assert.ok(Array.isArray(ctx.callers), 'Should have callers array');
537
+ assert.ok(Array.isArray(ctx.callees), 'Should have callees array');
538
+ });
539
+
540
+ it('searches across project', () => {
541
+ const index = new ProjectIndex('.');
542
+ index.build(null, { quiet: true });
543
+
544
+ const results = index.search('TODO');
545
+ assert.ok(Array.isArray(results), 'Should return array');
546
+ });
547
+
548
+ it('gets API (exported symbols)', () => {
549
+ const index = new ProjectIndex('.');
550
+ index.build(null, { quiet: true });
551
+
552
+ const api = index.api();
553
+ assert.ok(api.length > 0, 'Should find exported symbols');
554
+ });
555
+ });
556
+
557
+ // ============================================================================
558
+ // Import/Export Parsing Tests
559
+ // ============================================================================
560
+
561
+ describe('Import/Export Parsing', () => {
562
+ const { extractImports, extractExports } = require('../core/imports');
563
+
564
+ it('extracts CommonJS module.exports', () => {
565
+ const code = `
566
+ module.exports = {
567
+ parse,
568
+ parseFile,
569
+ findSymbol
570
+ };
571
+ `;
572
+ const { exports } = extractExports(code, 'javascript');
573
+ assert.ok(exports.length >= 3, 'Should find 3 exports');
574
+ assert.ok(exports.some(e => e.name === 'parse'), 'Should find parse export');
575
+ });
576
+
577
+ it('extracts ES module exports', () => {
578
+ const code = `
579
+ export function hello() {}
580
+ export const world = 42;
581
+ export default class MyClass {}
582
+ `;
583
+ const { exports } = extractExports(code, 'javascript');
584
+ assert.ok(exports.some(e => e.name === 'hello'), 'Should find hello export');
585
+ assert.ok(exports.some(e => e.name === 'world'), 'Should find world export');
586
+ });
587
+
588
+ it('extracts ES module imports', () => {
589
+ const code = `
590
+ import fs from 'fs';
591
+ import { parse, parseFile } from './parser';
592
+ import * as utils from './utils';
593
+ `;
594
+ const { imports } = extractImports(code, 'javascript');
595
+ assert.ok(imports.some(i => i.module === 'fs'), 'Should find fs import');
596
+ assert.ok(imports.some(i => i.module === './parser'), 'Should find parser import');
597
+ assert.ok(imports.some(i => i.module === './utils'), 'Should find utils import');
598
+ });
599
+ });
600
+
601
+ // ============================================================================
602
+ // Output Formatting Tests
603
+ // ============================================================================
604
+
605
+ describe('Output Formatting', () => {
606
+ const output = require('../core/output');
607
+
608
+ it('formats disambiguation output', () => {
609
+ const matches = [
610
+ { name: 'parse', relativePath: 'file1.js', startLine: 10, params: 'code', usageCount: 5 },
611
+ { name: 'parse', relativePath: 'file2.js', startLine: 20, params: 'input', usageCount: 3 }
612
+ ];
613
+ const result = output.formatDisambiguation(matches, 'parse', 'fn');
614
+ assert.ok(result.includes('Multiple matches'), 'Should show multiple matches');
615
+ assert.ok(result.includes('file1.js'), 'Should include file paths');
616
+ assert.ok(result.includes('--file'), 'Should suggest --file flag');
617
+ });
618
+
619
+ it('formats imports output', () => {
620
+ const imports = [
621
+ { module: './parser', resolved: 'core/parser.js', isExternal: false, names: ['parse'] },
622
+ { module: 'fs', resolved: null, isExternal: true, names: ['fs'] }
623
+ ];
624
+ const result = output.formatImports(imports, 'test.js');
625
+ assert.ok(result.includes('INTERNAL'), 'Should show internal section');
626
+ assert.ok(result.includes('EXTERNAL'), 'Should show external section');
627
+ });
628
+
629
+ it('formats tests output', () => {
630
+ const tests = [{
631
+ file: 'test.spec.js',
632
+ matches: [
633
+ { line: 10, content: 'it("should parse")', matchType: 'test-case' }
634
+ ]
635
+ }];
636
+ const result = output.formatTests(tests, 'parse');
637
+ assert.ok(result.includes('[test]'), 'Should show test-case label');
638
+ assert.ok(result.includes('test.spec.js'), 'Should show file name');
639
+ });
640
+ });
641
+
642
+ // ============================================================================
643
+ // Cache Behavior Tests
644
+ // ============================================================================
645
+
646
+ describe('Cache Behavior', () => {
647
+ const os = require('os');
648
+ const crypto = require('crypto');
649
+
650
+ function createTempDir() {
651
+ const tmpDir = path.join(os.tmpdir(), `ucn-test-${Date.now()}-${Math.random().toString(36).slice(2)}`);
652
+ fs.mkdirSync(tmpDir, { recursive: true });
653
+ return tmpDir;
654
+ }
655
+
656
+ function cleanup(dir) {
657
+ if (dir && fs.existsSync(dir)) {
658
+ fs.rmSync(dir, { recursive: true, force: true });
659
+ }
660
+ }
661
+
662
+ it('should save and load cache correctly', () => {
663
+ const tmpDir = createTempDir();
664
+ try {
665
+ // Create a test file
666
+ const testFile = path.join(tmpDir, 'test.js');
667
+ fs.writeFileSync(testFile, 'function hello() { return "world"; }');
668
+
669
+ // Build index and save cache
670
+ const index1 = new ProjectIndex(tmpDir);
671
+ index1.build('**/*.js', { quiet: true });
672
+ index1.saveCache();
673
+
674
+ // Verify cache file exists
675
+ const cacheFile = path.join(tmpDir, '.ucn-cache', 'index.json');
676
+ assert.ok(fs.existsSync(cacheFile), 'Cache file should exist');
677
+
678
+ // Create new index and load cache
679
+ const index2 = new ProjectIndex(tmpDir);
680
+ const loaded = index2.loadCache();
681
+ assert.ok(loaded, 'Cache should load successfully');
682
+
683
+ // Verify symbols match
684
+ assert.strictEqual(index2.symbols.size, index1.symbols.size, 'Symbol count should match');
685
+ assert.ok(index2.symbols.has('hello'), 'Should have hello symbol');
686
+ } finally {
687
+ cleanup(tmpDir);
688
+ }
689
+ });
690
+
691
+ it('should detect modified files as stale', () => {
692
+ const tmpDir = createTempDir();
693
+ try {
694
+ // Create test file
695
+ const testFile = path.join(tmpDir, 'test.js');
696
+ fs.writeFileSync(testFile, 'function original() {}');
697
+
698
+ // Build and save cache
699
+ const index1 = new ProjectIndex(tmpDir);
700
+ index1.build('**/*.js', { quiet: true });
701
+ index1.saveCache();
702
+
703
+ // Modify file
704
+ fs.writeFileSync(testFile, 'function modified() { return 42; }');
705
+
706
+ // Load cache and check staleness
707
+ const index2 = new ProjectIndex(tmpDir);
708
+ index2.loadCache();
709
+ assert.ok(index2.isCacheStale(), 'Cache should be stale after file modification');
710
+ } finally {
711
+ cleanup(tmpDir);
712
+ }
713
+ });
714
+
715
+ it('should detect new files added to project', () => {
716
+ const tmpDir = createTempDir();
717
+ try {
718
+ // Create initial file
719
+ const testFile = path.join(tmpDir, 'test.js');
720
+ fs.writeFileSync(testFile, 'function first() {}');
721
+
722
+ // Build and save cache
723
+ const index1 = new ProjectIndex(tmpDir);
724
+ index1.build('**/*.js', { quiet: true });
725
+ index1.saveCache();
726
+
727
+ // Add new file
728
+ const newFile = path.join(tmpDir, 'new.js');
729
+ fs.writeFileSync(newFile, 'function second() {}');
730
+
731
+ // Load cache and check staleness
732
+ const index2 = new ProjectIndex(tmpDir);
733
+ index2.loadCache();
734
+ assert.ok(index2.isCacheStale(), 'Cache should be stale after adding new file');
735
+ } finally {
736
+ cleanup(tmpDir);
737
+ }
738
+ });
739
+
740
+ it('should detect deleted files', () => {
741
+ const tmpDir = createTempDir();
742
+ try {
743
+ // Create two files
744
+ fs.writeFileSync(path.join(tmpDir, 'file1.js'), 'function one() {}');
745
+ fs.writeFileSync(path.join(tmpDir, 'file2.js'), 'function two() {}');
746
+
747
+ // Build and save cache
748
+ const index1 = new ProjectIndex(tmpDir);
749
+ index1.build('**/*.js', { quiet: true });
750
+ index1.saveCache();
751
+
752
+ // Delete one file
753
+ fs.unlinkSync(path.join(tmpDir, 'file2.js'));
754
+
755
+ // Load cache and check staleness
756
+ const index2 = new ProjectIndex(tmpDir);
757
+ index2.loadCache();
758
+ assert.ok(index2.isCacheStale(), 'Cache should be stale after deleting file');
759
+ } finally {
760
+ cleanup(tmpDir);
761
+ }
762
+ });
763
+
764
+ it('should handle corrupted cache gracefully', () => {
765
+ const tmpDir = createTempDir();
766
+ try {
767
+ // Create cache directory with invalid JSON
768
+ const cacheDir = path.join(tmpDir, '.ucn-cache');
769
+ fs.mkdirSync(cacheDir, { recursive: true });
770
+ fs.writeFileSync(path.join(cacheDir, 'index.json'), 'not valid json {{{');
771
+
772
+ // loadCache should return false
773
+ const index = new ProjectIndex(tmpDir);
774
+ const loaded = index.loadCache();
775
+ assert.strictEqual(loaded, false, 'Should not load corrupted cache');
776
+ } finally {
777
+ cleanup(tmpDir);
778
+ }
779
+ });
780
+
781
+ it('should handle version mismatch gracefully', () => {
782
+ const tmpDir = createTempDir();
783
+ try {
784
+ // Create cache with wrong version
785
+ const cacheDir = path.join(tmpDir, '.ucn-cache');
786
+ fs.mkdirSync(cacheDir, { recursive: true });
787
+ fs.writeFileSync(path.join(cacheDir, 'index.json'), JSON.stringify({
788
+ version: 999,
789
+ files: [],
790
+ symbols: [],
791
+ importGraph: [],
792
+ exportGraph: []
793
+ }));
794
+
795
+ // loadCache should return false
796
+ const index = new ProjectIndex(tmpDir);
797
+ const loaded = index.loadCache();
798
+ assert.strictEqual(loaded, false, 'Should not load cache with wrong version');
799
+ } finally {
800
+ cleanup(tmpDir);
801
+ }
802
+ });
803
+
804
+ it('should report not stale when files unchanged', () => {
805
+ const tmpDir = createTempDir();
806
+ try {
807
+ // Create test file
808
+ const testFile = path.join(tmpDir, 'test.js');
809
+ fs.writeFileSync(testFile, 'function unchanged() {}');
810
+
811
+ // Build and save cache
812
+ const index1 = new ProjectIndex(tmpDir);
813
+ index1.build('**/*.js', { quiet: true });
814
+ index1.saveCache();
815
+
816
+ // Load cache without modifications
817
+ const index2 = new ProjectIndex(tmpDir);
818
+ index2.loadCache();
819
+ assert.strictEqual(index2.isCacheStale(), false, 'Cache should not be stale when files unchanged');
820
+ } finally {
821
+ cleanup(tmpDir);
822
+ }
823
+ });
824
+ });
825
+
826
+ // ============================================================================
827
+ // File Discovery Tests (conditional ignores)
828
+ // ============================================================================
829
+
830
+ describe('File Discovery', () => {
831
+ const os = require('os');
832
+
833
+ function createTempDir() {
834
+ const tmpDir = path.join(os.tmpdir(), `ucn-test-${Date.now()}-${Math.random().toString(36).slice(2)}`);
835
+ fs.mkdirSync(tmpDir, { recursive: true });
836
+ return tmpDir;
837
+ }
838
+
839
+ function cleanup(dir) {
840
+ if (dir && fs.existsSync(dir)) {
841
+ fs.rmSync(dir, { recursive: true, force: true });
842
+ }
843
+ }
844
+
845
+ it('should ignore vendor/ when go.mod exists (Go project)', () => {
846
+ const tmpDir = createTempDir();
847
+ try {
848
+ // Create Go project structure
849
+ fs.writeFileSync(path.join(tmpDir, 'go.mod'), 'module example.com/test');
850
+ fs.writeFileSync(path.join(tmpDir, 'main.go'), 'package main\nfunc main() {}');
851
+ fs.mkdirSync(path.join(tmpDir, 'vendor'));
852
+ fs.writeFileSync(path.join(tmpDir, 'vendor', 'dep.go'), 'package vendor');
853
+
854
+ const files = expandGlob('**/*.go', { root: tmpDir });
855
+ const relativePaths = files.map(f => path.relative(tmpDir, f));
856
+
857
+ assert.ok(relativePaths.includes('main.go'), 'Should find main.go');
858
+ assert.ok(!relativePaths.some(p => p.includes('vendor')), 'Should NOT find vendor files');
859
+ } finally {
860
+ cleanup(tmpDir);
861
+ }
862
+ });
863
+
864
+ it('should NOT ignore vendor/ when no marker exists (user code)', () => {
865
+ const tmpDir = createTempDir();
866
+ try {
867
+ // Create project WITHOUT go.mod/composer.json
868
+ fs.writeFileSync(path.join(tmpDir, 'main.js'), 'function main() {}');
869
+ fs.mkdirSync(path.join(tmpDir, 'vendor'));
870
+ fs.writeFileSync(path.join(tmpDir, 'vendor', 'management.js'), 'function vendorMgmt() {}');
871
+
872
+ const files = expandGlob('**/*.js', { root: tmpDir });
873
+ const relativePaths = files.map(f => path.relative(tmpDir, f));
874
+
875
+ assert.ok(relativePaths.includes('main.js'), 'Should find main.js');
876
+ assert.ok(relativePaths.some(p => p.includes('vendor')), 'Should find vendor files (user code)');
877
+ } finally {
878
+ cleanup(tmpDir);
879
+ }
880
+ });
881
+
882
+ it('should ignore Pods/ when Podfile exists (iOS project)', () => {
883
+ const tmpDir = createTempDir();
884
+ try {
885
+ // Create iOS project structure
886
+ fs.writeFileSync(path.join(tmpDir, 'Podfile'), "platform :ios, '14.0'");
887
+ fs.writeFileSync(path.join(tmpDir, 'App.swift'), 'class App {}');
888
+ fs.mkdirSync(path.join(tmpDir, 'Pods'));
889
+ fs.writeFileSync(path.join(tmpDir, 'Pods', 'Dep.swift'), 'class Dep {}');
890
+
891
+ const files = expandGlob('**/*.swift', { root: tmpDir });
892
+ const relativePaths = files.map(f => path.relative(tmpDir, f));
893
+
894
+ assert.ok(relativePaths.includes('App.swift'), 'Should find App.swift');
895
+ assert.ok(!relativePaths.some(p => p.includes('Pods')), 'Should NOT find Pods files');
896
+ } finally {
897
+ cleanup(tmpDir);
898
+ }
899
+ });
900
+
901
+ it('should always ignore node_modules (unconditional)', () => {
902
+ const tmpDir = createTempDir();
903
+ try {
904
+ fs.writeFileSync(path.join(tmpDir, 'main.js'), 'function main() {}');
905
+ fs.mkdirSync(path.join(tmpDir, 'node_modules'));
906
+ fs.writeFileSync(path.join(tmpDir, 'node_modules', 'dep.js'), 'module.exports = {}');
907
+
908
+ const files = expandGlob('**/*.js', { root: tmpDir });
909
+ const relativePaths = files.map(f => path.relative(tmpDir, f));
910
+
911
+ assert.ok(relativePaths.includes('main.js'), 'Should find main.js');
912
+ assert.ok(!relativePaths.some(p => p.includes('node_modules')), 'Should NOT find node_modules');
913
+ } finally {
914
+ cleanup(tmpDir);
915
+ }
916
+ });
917
+ });
918
+
919
+ // ============================================================================
920
+ // BUG TESTS: Callers should not include definitions
921
+ // ============================================================================
922
+
923
+ describe('Bug: CALLERS should exclude definitions', () => {
924
+ it('context command should not show definitions in CALLERS', () => {
925
+ const tmpDir = path.join(require('os').tmpdir(), `ucn-test-callers-${Date.now()}`);
926
+ fs.mkdirSync(tmpDir, { recursive: true });
927
+
928
+ try {
929
+ // Create files: one with definition, one with call
930
+ fs.writeFileSync(path.join(tmpDir, 'lib.js'), `
931
+ function myFunc() {
932
+ return 42;
933
+ }
934
+ module.exports = { myFunc };
935
+ `);
936
+ fs.writeFileSync(path.join(tmpDir, 'app.js'), `
937
+ const { myFunc } = require('./lib');
938
+ const result = myFunc();
939
+ console.log(result);
940
+ `);
941
+
942
+ const index = new ProjectIndex(tmpDir);
943
+ index.build('**/*.js', { quiet: true });
944
+
945
+ const ctx = index.context('myFunc');
946
+
947
+ // Callers should only have actual calls, not definitions
948
+ const callerLines = ctx.callers.map(c => c.content || c.line);
949
+ const hasDefinition = callerLines.some(line =>
950
+ line.includes('function myFunc') ||
951
+ line.includes('myFunc()') === false && line.includes('myFunc')
952
+ );
953
+
954
+ // This test documents the bug - callers should not include the definition
955
+ assert.ok(ctx.callers.length > 0, 'Should have some callers');
956
+ // After fix: assert.ok(!hasDefinition, 'Callers should not include function definitions');
957
+ } finally {
958
+ fs.rmSync(tmpDir, { recursive: true, force: true });
959
+ }
960
+ });
961
+ });
962
+
963
+ // ============================================================================
964
+ // BUG TESTS: Callers should be numbered for expand command
965
+ // ============================================================================
966
+
967
+ describe('Bug: callers should be numbered for expand command', () => {
968
+ it('context command should return caller info for expand', () => {
969
+ const tmpDir = path.join(require('os').tmpdir(), `ucn-test-caller-num-${Date.now()}`);
970
+ fs.mkdirSync(tmpDir, { recursive: true });
971
+
972
+ try {
973
+ fs.writeFileSync(path.join(tmpDir, 'lib.js'), `
974
+ function helper() {
975
+ return 42;
976
+ }
977
+ module.exports = { helper };
978
+ `);
979
+ fs.writeFileSync(path.join(tmpDir, 'app.js'), `
980
+ const { helper } = require('./lib');
981
+ function caller1() {
982
+ return helper(); // Call from caller1
983
+ }
984
+ function caller2() {
985
+ return helper(); // Call from caller2
986
+ }
987
+ `);
988
+
989
+ const index = new ProjectIndex(tmpDir);
990
+ index.build('**/*.js', { quiet: true });
991
+
992
+ const ctx = index.context('helper');
993
+
994
+ // Callers should have file and line info for expand
995
+ assert.ok(ctx.callers.length >= 2, 'Should have at least 2 callers');
996
+ for (const caller of ctx.callers) {
997
+ assert.ok(caller.file, 'Caller should have file');
998
+ assert.ok(caller.line, 'Caller should have line');
999
+ // callerFile should be set when there's an enclosing function
1000
+ if (caller.callerName) {
1001
+ assert.ok(caller.callerFile, 'Caller with name should have callerFile');
1002
+ assert.ok(caller.callerStartLine, 'Caller with name should have callerStartLine');
1003
+ assert.ok(caller.callerEndLine, 'Caller with name should have callerEndLine');
1004
+ }
1005
+ }
1006
+ } finally {
1007
+ fs.rmSync(tmpDir, { recursive: true, force: true });
1008
+ }
1009
+ });
1010
+ });
1011
+
1012
+ // ============================================================================
1013
+ // BUG TESTS: Name matching should distinguish method calls
1014
+ // ============================================================================
1015
+
1016
+ describe('Bug: usages should distinguish method calls from standalone calls', () => {
1017
+ it('should not include JSON.parse when searching for parse', () => {
1018
+ const tmpDir = path.join(require('os').tmpdir(), `ucn-test-method-${Date.now()}`);
1019
+ fs.mkdirSync(tmpDir, { recursive: true });
1020
+
1021
+ try {
1022
+ // File with own parse function + JSON.parse usage
1023
+ fs.writeFileSync(path.join(tmpDir, 'parser.js'), `
1024
+ function parse(code) {
1025
+ return code.trim();
1026
+ }
1027
+
1028
+ function loadConfig() {
1029
+ const data = JSON.parse('{}'); // Should NOT be counted as usage of parse()
1030
+ return parse(data); // Should be counted
1031
+ }
1032
+
1033
+ module.exports = { parse };
1034
+ `);
1035
+
1036
+ const index = new ProjectIndex(tmpDir);
1037
+ index.build('**/*.js', { quiet: true });
1038
+
1039
+ const usages = index.usages('parse');
1040
+ const calls = usages.filter(u => u.usageType === 'call');
1041
+
1042
+ // Check if JSON.parse is incorrectly included
1043
+ const hasJsonParse = calls.some(c =>
1044
+ c.content && c.content.includes('JSON.parse')
1045
+ );
1046
+
1047
+ // Bug fixed: JSON.parse should not be counted as a call to parse()
1048
+ assert.strictEqual(hasJsonParse, false, 'JSON.parse should not be counted as usage of parse()');
1049
+ } finally {
1050
+ fs.rmSync(tmpDir, { recursive: true, force: true });
1051
+ }
1052
+ });
1053
+
1054
+ it('should not include path.parse when searching for parse', () => {
1055
+ const tmpDir = path.join(require('os').tmpdir(), `ucn-test-path-${Date.now()}`);
1056
+ fs.mkdirSync(tmpDir, { recursive: true });
1057
+
1058
+ try {
1059
+ fs.writeFileSync(path.join(tmpDir, 'util.js'), `
1060
+ const path = require('path');
1061
+
1062
+ function parse(input) {
1063
+ return input.split(',');
1064
+ }
1065
+
1066
+ function getRoot() {
1067
+ const parsed = path.parse('/foo/bar'); // Should NOT be counted
1068
+ return parse(parsed.dir); // Should be counted
1069
+ }
1070
+
1071
+ module.exports = { parse };
1072
+ `);
1073
+
1074
+ const index = new ProjectIndex(tmpDir);
1075
+ index.build('**/*.js', { quiet: true });
1076
+
1077
+ const usages = index.usages('parse');
1078
+ const calls = usages.filter(u => u.usageType === 'call');
1079
+
1080
+ const hasPathParse = calls.some(c =>
1081
+ c.content && c.content.includes('path.parse')
1082
+ );
1083
+
1084
+ // Bug fixed: path.parse should not be counted as a call to parse()
1085
+ assert.strictEqual(hasPathParse, false, 'path.parse should not be counted');
1086
+ } finally {
1087
+ fs.rmSync(tmpDir, { recursive: true, force: true });
1088
+ }
1089
+ });
1090
+ });
1091
+
1092
+ // ============================================================================
1093
+ // BUG TESTS: smart command should not duplicate main function
1094
+ // ============================================================================
1095
+
1096
+ describe('Bug: smart command should not duplicate main function', () => {
1097
+ it('main function should not appear in dependencies', () => {
1098
+ const tmpDir = path.join(require('os').tmpdir(), `ucn-test-smart-${Date.now()}`);
1099
+ fs.mkdirSync(tmpDir, { recursive: true });
1100
+
1101
+ try {
1102
+ fs.writeFileSync(path.join(tmpDir, 'app.js'), `
1103
+ function helper() {
1104
+ return 'helped';
1105
+ }
1106
+
1107
+ function main() {
1108
+ return helper();
1109
+ }
1110
+
1111
+ module.exports = { main };
1112
+ `);
1113
+
1114
+ const index = new ProjectIndex(tmpDir);
1115
+ index.build('**/*.js', { quiet: true });
1116
+
1117
+ const smart = index.smart('main');
1118
+
1119
+ // Dependencies should NOT include main itself
1120
+ const depNames = smart.dependencies.map(d => d.name);
1121
+ const hasSelf = depNames.includes('main');
1122
+
1123
+ // After fix: assert.strictEqual(hasSelf, false, 'Dependencies should not include the main function itself');
1124
+ if (hasSelf) {
1125
+ console.log('BUG CONFIRMED: smart includes main function in its own dependencies');
1126
+ }
1127
+ } finally {
1128
+ fs.rmSync(tmpDir, { recursive: true, force: true });
1129
+ }
1130
+ });
1131
+ });
1132
+
1133
+ // ============================================================================
1134
+ // BUG TESTS: plan should not pick up string literals
1135
+ // ============================================================================
1136
+
1137
+ describe('Bug: plan should ignore string literals', () => {
1138
+ it('should not count string literals as call sites', () => {
1139
+ const tmpDir = path.join(require('os').tmpdir(), `ucn-test-plan-${Date.now()}`);
1140
+ fs.mkdirSync(tmpDir, { recursive: true });
1141
+
1142
+ try {
1143
+ // Main file with actual function
1144
+ fs.writeFileSync(path.join(tmpDir, 'lib.js'), `
1145
+ function myFunc(x) {
1146
+ return x * 2;
1147
+ }
1148
+ module.exports = { myFunc };
1149
+ `);
1150
+ // Test file with string literal containing function name
1151
+ fs.writeFileSync(path.join(tmpDir, 'test.js'), `
1152
+ const code = 'function myFunc() {}'; // String literal - NOT a call site
1153
+ const result = myFunc(5); // Actual call site
1154
+ `);
1155
+
1156
+ const index = new ProjectIndex(tmpDir);
1157
+ index.build('**/*.js', { quiet: true });
1158
+
1159
+ // Note: plan() might not be directly exposed, testing via impact instead
1160
+ const usages = index.usages('myFunc');
1161
+ const calls = usages.filter(u => u.usageType === 'call');
1162
+
1163
+ // Check if string literal is incorrectly counted
1164
+ const hasStringLiteral = calls.some(c =>
1165
+ c.content && c.content.includes("'function myFunc")
1166
+ );
1167
+
1168
+ // Bug fixed: String literals should not be counted as calls
1169
+ assert.strictEqual(hasStringLiteral, false, 'String literals should not be counted as calls');
1170
+ } finally {
1171
+ fs.rmSync(tmpDir, { recursive: true, force: true });
1172
+ }
1173
+ });
1174
+ });
1175
+
1176
+ // ============================================================================
1177
+ // BUG TESTS: file mode should filter string literals
1178
+ // ============================================================================
1179
+
1180
+ describe('Bug: file mode usages should filter string literals', () => {
1181
+ it('should not count string literals as usages in file mode', () => {
1182
+ const tmpDir = path.join(require('os').tmpdir(), `ucn-test-filemode-${Date.now()}`);
1183
+ fs.mkdirSync(tmpDir, { recursive: true });
1184
+
1185
+ try {
1186
+ fs.writeFileSync(path.join(tmpDir, 'app.js'), `
1187
+ function main() {
1188
+ console.log('about main'); // "main" in string should NOT be a reference
1189
+ return helper();
1190
+ }
1191
+
1192
+ function helper() {
1193
+ return 42;
1194
+ }
1195
+
1196
+ main(); // Actual call
1197
+ `);
1198
+
1199
+ // Use CLI file mode via child process
1200
+ const { execSync } = require('child_process');
1201
+ const result = execSync(`node cli/index.js ${path.join(tmpDir, 'app.js')} usages main`, {
1202
+ encoding: 'utf-8',
1203
+ cwd: process.cwd()
1204
+ });
1205
+
1206
+ // Should have 1 def, 1 call, 0 references (string literal excluded)
1207
+ assert.ok(result.includes('1 definitions'), 'Should have 1 definition');
1208
+ assert.ok(result.includes('1 calls'), 'Should have 1 call');
1209
+ assert.ok(result.includes('0 references') || !result.includes('REFERENCES'),
1210
+ 'Should have 0 references (string literal excluded)');
1211
+ } finally {
1212
+ fs.rmSync(tmpDir, { recursive: true, force: true });
1213
+ }
1214
+ });
1215
+ });
1216
+
1217
+ // ============================================================================
1218
+ // BUG TESTS: stats symbol count mismatch
1219
+ // ============================================================================
1220
+
1221
+ describe('Bug: stats symbol count consistency', () => {
1222
+ it('total symbols should equal sum of type counts', () => {
1223
+ const index = new ProjectIndex('.');
1224
+ index.build(null, { quiet: true });
1225
+
1226
+ const stats = index.getStats();
1227
+
1228
+ // Calculate sum of type counts
1229
+ let typeSum = 0;
1230
+ if (stats.byType) {
1231
+ for (const [type, count] of Object.entries(stats.byType)) {
1232
+ typeSum += count;
1233
+ }
1234
+ }
1235
+
1236
+ // This documents the bug - symbol count doesn't match type breakdown
1237
+ // After fix: assert.strictEqual(stats.symbols, typeSum, 'Total symbols should equal sum of types');
1238
+ if (stats.symbols !== typeSum) {
1239
+ console.log(`BUG CONFIRMED: stats.symbols (${stats.symbols}) !== sum of byType (${typeSum})`);
1240
+ }
1241
+ });
1242
+ });
1243
+
1244
+ // ============================================================================
1245
+ // BUG TESTS: --context flag output
1246
+ // ============================================================================
1247
+
1248
+ describe('Bug: --context flag should show complete lines', () => {
1249
+ it('context lines should not be truncated with ...', () => {
1250
+ const tmpDir = path.join(require('os').tmpdir(), `ucn-test-context-${Date.now()}`);
1251
+ fs.mkdirSync(tmpDir, { recursive: true });
1252
+
1253
+ try {
1254
+ fs.writeFileSync(path.join(tmpDir, 'app.js'), `
1255
+ // Line 1 - before context
1256
+ // Line 2 - before context
1257
+ function processData(input) {
1258
+ // Line 4 - before context
1259
+ const result = helper(input);
1260
+ // Line 6 - after context
1261
+ return result;
1262
+ }
1263
+
1264
+ function helper(x) {
1265
+ return x * 2;
1266
+ }
1267
+ `);
1268
+
1269
+ const index = new ProjectIndex(tmpDir);
1270
+ index.build('**/*.js', { quiet: true });
1271
+
1272
+ // Test usages with context
1273
+ const usages = index.usages('helper', { context: 2 });
1274
+ assert.ok(usages.length > 0, 'Should find usages');
1275
+
1276
+ // Find the call usage (not the definition)
1277
+ const callUsage = usages.find(u => u.usageType === 'call');
1278
+ assert.ok(callUsage, 'Should have a call usage');
1279
+
1280
+ // Verify context lines are present and complete
1281
+ if (callUsage.before) {
1282
+ assert.strictEqual(callUsage.before.length, 2, 'Should have 2 before context lines');
1283
+ assert.ok(!callUsage.before.some(l => l.includes('...')), 'Before context should not be truncated');
1284
+ }
1285
+
1286
+ if (callUsage.after) {
1287
+ assert.strictEqual(callUsage.after.length, 2, 'Should have 2 after context lines');
1288
+ assert.ok(!callUsage.after.some(l => l.includes('...')), 'After context should not be truncated');
1289
+ }
1290
+ } finally {
1291
+ fs.rmSync(tmpDir, { recursive: true, force: true });
1292
+ }
1293
+ });
1294
+
1295
+ it('context should handle file boundaries correctly', () => {
1296
+ const tmpDir = path.join(require('os').tmpdir(), `ucn-test-context-boundary-${Date.now()}`);
1297
+ fs.mkdirSync(tmpDir, { recursive: true });
1298
+
1299
+ try {
1300
+ // File where match is at the beginning
1301
+ fs.writeFileSync(path.join(tmpDir, 'start.js'), `const result = helper();
1302
+ // Line 2
1303
+ // Line 3
1304
+ `);
1305
+
1306
+ const index = new ProjectIndex(tmpDir);
1307
+ index.build('**/*.js', { quiet: true });
1308
+
1309
+ const usages = index.usages('helper', { context: 3 });
1310
+ const usage = usages.find(u => u.usageType === 'call');
1311
+
1312
+ if (usage) {
1313
+ // Before context should be empty or less than 3 (at file start)
1314
+ assert.ok(!usage.before || usage.before.length < 3, 'Should handle file start boundary');
1315
+ }
1316
+ } finally {
1317
+ fs.rmSync(tmpDir, { recursive: true, force: true });
1318
+ }
1319
+ });
1320
+
1321
+ it('search command should support context lines', () => {
1322
+ const tmpDir = path.join(require('os').tmpdir(), `ucn-test-search-context-${Date.now()}`);
1323
+ fs.mkdirSync(tmpDir, { recursive: true });
1324
+
1325
+ try {
1326
+ fs.writeFileSync(path.join(tmpDir, 'app.js'), `
1327
+ // Line 1
1328
+ // Line 2
1329
+ // TODO: fix this issue
1330
+ // Line 4
1331
+ // Line 5
1332
+ `);
1333
+
1334
+ const index = new ProjectIndex(tmpDir);
1335
+ index.build('**/*.js', { quiet: true });
1336
+
1337
+ const results = index.search('TODO', { context: 2 });
1338
+ assert.ok(results.length > 0, 'Should find search results');
1339
+
1340
+ const match = results[0].matches[0];
1341
+ assert.ok(match.before && match.before.length > 0, 'Should have before context');
1342
+ assert.ok(match.after && match.after.length > 0, 'Should have after context');
1343
+ } finally {
1344
+ fs.rmSync(tmpDir, { recursive: true, force: true });
1345
+ }
1346
+ });
1347
+ });
1348
+
1349
+ // ============================================================================
1350
+ // FEATURE TESTS: file-exports command (currently missing)
1351
+ // ============================================================================
1352
+
1353
+ describe('Feature: file-exports command', () => {
1354
+ it('should return exports for a file (when implemented)', () => {
1355
+ const index = new ProjectIndex('.');
1356
+ index.build(null, { quiet: true });
1357
+
1358
+ // Check if fileExports method exists
1359
+ if (typeof index.fileExports === 'function') {
1360
+ const exports = index.fileExports('core/parser.js');
1361
+ assert.ok(Array.isArray(exports), 'Should return array of exports');
1362
+ assert.ok(exports.some(e => e.name === 'parse'), 'Should export parse function');
1363
+ } else {
1364
+ // Document that feature is missing
1365
+ console.log('FEATURE MISSING: index.fileExports() not implemented');
1366
+ }
1367
+ });
1368
+ });
1369
+
1370
+ // ============================================================================
1371
+ // FEATURE TESTS: deadcode command (currently missing)
1372
+ // ============================================================================
1373
+
1374
+ describe('Feature: deadcode detection', () => {
1375
+ it('should find unused functions (when implemented)', () => {
1376
+ const tmpDir = path.join(require('os').tmpdir(), `ucn-test-deadcode-${Date.now()}`);
1377
+ fs.mkdirSync(tmpDir, { recursive: true });
1378
+
1379
+ try {
1380
+ fs.writeFileSync(path.join(tmpDir, 'lib.js'), `
1381
+ function usedFunction() {
1382
+ return 42;
1383
+ }
1384
+
1385
+ function unusedFunction() { // This should be detected as dead code
1386
+ return 'never called';
1387
+ }
1388
+
1389
+ const result = usedFunction();
1390
+ console.log(result);
1391
+ `);
1392
+
1393
+ const index = new ProjectIndex(tmpDir);
1394
+ index.build('**/*.js', { quiet: true });
1395
+
1396
+ // Check if deadcode method exists
1397
+ if (typeof index.deadcode === 'function') {
1398
+ const dead = index.deadcode();
1399
+ assert.ok(Array.isArray(dead), 'Should return array');
1400
+ assert.ok(dead.some(d => d.name === 'unusedFunction'), 'Should find unused function');
1401
+ } else {
1402
+ console.log('FEATURE MISSING: index.deadcode() not implemented');
1403
+ }
1404
+ } finally {
1405
+ fs.rmSync(tmpDir, { recursive: true, force: true });
1406
+ }
1407
+ });
1408
+ });
1409
+
1410
+ // FEATURE TESTS: graph command
1411
+ describe('Feature: graph command', () => {
1412
+ it('returns dependency tree for a file', () => {
1413
+ const tmpDir = path.join(require('os').tmpdir(), `ucn-test-graph-${Date.now()}`);
1414
+ fs.mkdirSync(tmpDir, { recursive: true });
1415
+
1416
+ try {
1417
+ // Create files with import relationships
1418
+ fs.writeFileSync(path.join(tmpDir, 'main.js'), `
1419
+ import { helper } from './utils.js';
1420
+ import { api } from './api.js';
1421
+
1422
+ export function main() {
1423
+ return helper() + api();
1424
+ }
1425
+ `);
1426
+ fs.writeFileSync(path.join(tmpDir, 'utils.js'), `
1427
+ export function helper() { return 1; }
1428
+ `);
1429
+ fs.writeFileSync(path.join(tmpDir, 'api.js'), `
1430
+ import { helper } from './utils.js';
1431
+ export function api() { return helper() + 2; }
1432
+ `);
1433
+
1434
+ const index = new ProjectIndex(tmpDir);
1435
+ index.build('**/*.js', { quiet: true });
1436
+
1437
+ const graph = index.graph('main.js', { direction: 'both', maxDepth: 3 });
1438
+
1439
+ // Should have root
1440
+ assert.ok(graph.root.endsWith('main.js'), 'Root should be main.js');
1441
+
1442
+ // Should have nodes
1443
+ assert.ok(graph.nodes.length >= 3, 'Should have at least 3 nodes');
1444
+
1445
+ // Should have edges (imports)
1446
+ assert.ok(graph.edges.length >= 2, 'Should have at least 2 edges');
1447
+
1448
+ // Check that utils.js and api.js are in the graph
1449
+ const nodeNames = graph.nodes.map(n => n.relativePath);
1450
+ assert.ok(nodeNames.some(n => n.includes('utils.js')), 'Should include utils.js');
1451
+ assert.ok(nodeNames.some(n => n.includes('api.js')), 'Should include api.js');
1452
+ } finally {
1453
+ fs.rmSync(tmpDir, { recursive: true, force: true });
1454
+ }
1455
+ });
1456
+
1457
+ it('handles circular dependencies', () => {
1458
+ const tmpDir = path.join(require('os').tmpdir(), `ucn-test-graph-circular-${Date.now()}`);
1459
+ fs.mkdirSync(tmpDir, { recursive: true });
1460
+
1461
+ try {
1462
+ // Create circular import relationship
1463
+ fs.writeFileSync(path.join(tmpDir, 'a.js'), `
1464
+ import { b } from './b.js';
1465
+ export function a() { return b() + 1; }
1466
+ `);
1467
+ fs.writeFileSync(path.join(tmpDir, 'b.js'), `
1468
+ import { a } from './a.js';
1469
+ export function b() { return a() + 2; }
1470
+ `);
1471
+
1472
+ const index = new ProjectIndex(tmpDir);
1473
+ index.build('**/*.js', { quiet: true });
1474
+
1475
+ // Should not infinite loop
1476
+ const graph = index.graph('a.js', { direction: 'both', maxDepth: 5 });
1477
+
1478
+ // Should have both files
1479
+ assert.ok(graph.nodes.length === 2, 'Should have exactly 2 nodes');
1480
+ } finally {
1481
+ fs.rmSync(tmpDir, { recursive: true, force: true });
1482
+ }
1483
+ });
1484
+ });
1485
+
1486
+ // ============================================================================
1487
+ // EDGE CASE TESTS
1488
+ // ============================================================================
1489
+
1490
+ describe('Edge Cases', () => {
1491
+ it('should handle recursive function calls correctly', () => {
1492
+ const tmpDir = path.join(require('os').tmpdir(), `ucn-test-recursive-${Date.now()}`);
1493
+ fs.mkdirSync(tmpDir, { recursive: true });
1494
+
1495
+ try {
1496
+ fs.writeFileSync(path.join(tmpDir, 'recursive.js'), `
1497
+ function factorial(n) {
1498
+ if (n <= 1) return 1;
1499
+ return n * factorial(n - 1); // Recursive call
1500
+ }
1501
+
1502
+ const result = factorial(5);
1503
+ `);
1504
+
1505
+ const index = new ProjectIndex(tmpDir);
1506
+ index.build('**/*.js', { quiet: true });
1507
+
1508
+ const ctx = index.context('factorial');
1509
+
1510
+ // Should have callers (including the recursive call)
1511
+ assert.ok(ctx.callers.length > 0, 'Should find callers including recursive call');
1512
+
1513
+ // Definition should NOT be in callers
1514
+ const hasDefinitionInCallers = ctx.callers.some(c =>
1515
+ c.content && c.content.includes('function factorial')
1516
+ );
1517
+ // After fix: assert.strictEqual(hasDefinitionInCallers, false);
1518
+ } finally {
1519
+ fs.rmSync(tmpDir, { recursive: true, force: true });
1520
+ }
1521
+ });
1522
+
1523
+ it('should handle aliased imports correctly', () => {
1524
+ const tmpDir = path.join(require('os').tmpdir(), `ucn-test-alias-${Date.now()}`);
1525
+ fs.mkdirSync(tmpDir, { recursive: true });
1526
+
1527
+ try {
1528
+ fs.writeFileSync(path.join(tmpDir, 'lib.js'), `
1529
+ function parse(code) {
1530
+ return code.trim();
1531
+ }
1532
+ module.exports = { parse };
1533
+ `);
1534
+ fs.writeFileSync(path.join(tmpDir, 'app.js'), `
1535
+ const { parse: myParse } = require('./lib');
1536
+ const result = myParse(' hello '); // Should be counted as usage of parse
1537
+ `);
1538
+
1539
+ const index = new ProjectIndex(tmpDir);
1540
+ index.build('**/*.js', { quiet: true });
1541
+
1542
+ const usages = index.usages('parse');
1543
+ // Aliased usage is tricky - should ideally track the alias
1544
+ assert.ok(usages.length > 0, 'Should find at least the definition');
1545
+ } finally {
1546
+ fs.rmSync(tmpDir, { recursive: true, force: true });
1547
+ }
1548
+ });
1549
+
1550
+ it('should handle same function name in different files', () => {
1551
+ const tmpDir = path.join(require('os').tmpdir(), `ucn-test-samename-${Date.now()}`);
1552
+ fs.mkdirSync(tmpDir, { recursive: true });
1553
+
1554
+ try {
1555
+ fs.writeFileSync(path.join(tmpDir, 'file1.js'), `
1556
+ function process(x) { return x + 1; }
1557
+ module.exports = { process };
1558
+ `);
1559
+ fs.writeFileSync(path.join(tmpDir, 'file2.js'), `
1560
+ function process(x) { return x * 2; }
1561
+ module.exports = { process };
1562
+ `);
1563
+ fs.writeFileSync(path.join(tmpDir, 'app.js'), `
1564
+ const m1 = require('./file1');
1565
+ const m2 = require('./file2');
1566
+ console.log(m1.process(5));
1567
+ console.log(m2.process(5));
1568
+ `);
1569
+
1570
+ const index = new ProjectIndex(tmpDir);
1571
+ index.build('**/*.js', { quiet: true });
1572
+
1573
+ const found = index.find('process');
1574
+ assert.strictEqual(found.length, 2, 'Should find both process functions');
1575
+ } finally {
1576
+ fs.rmSync(tmpDir, { recursive: true, force: true });
1577
+ }
1578
+ });
1579
+ });
1580
+
1581
+ // ============================================================================
1582
+ // NON-EXISTENT SYMBOL HANDLING
1583
+ // ============================================================================
1584
+
1585
+ describe('Non-existent symbol handling', () => {
1586
+ // Helper to create and cleanup temp project
1587
+ function withTempProject(fn) {
1588
+ const tmpDir = path.join(require('os').tmpdir(), `ucn-test-nonexist-${Date.now()}`);
1589
+ fs.mkdirSync(tmpDir, { recursive: true });
1590
+ fs.writeFileSync(path.join(tmpDir, 'app.js'), `
1591
+ function existingFunc() {
1592
+ return 42;
1593
+ }
1594
+ module.exports = { existingFunc };
1595
+ `);
1596
+ const index = new ProjectIndex(tmpDir);
1597
+ index.build('**/*.js', { quiet: true });
1598
+
1599
+ try {
1600
+ fn(index);
1601
+ } finally {
1602
+ fs.rmSync(tmpDir, { recursive: true, force: true });
1603
+ }
1604
+ }
1605
+
1606
+ it('find should return empty array for non-existent symbol', () => {
1607
+ withTempProject((index) => {
1608
+ const found = index.find('nonExistentSymbol');
1609
+ assert.ok(Array.isArray(found), 'Should return array');
1610
+ assert.strictEqual(found.length, 0, 'Should be empty');
1611
+ });
1612
+ });
1613
+
1614
+ it('usages should return empty array for non-existent symbol', () => {
1615
+ withTempProject((index) => {
1616
+ const usages = index.usages('nonExistentSymbol');
1617
+ assert.ok(Array.isArray(usages), 'Should return array');
1618
+ assert.strictEqual(usages.length, 0, 'Should be empty');
1619
+ });
1620
+ });
1621
+
1622
+ it('context should return empty callers/callees for non-existent symbol', () => {
1623
+ withTempProject((index) => {
1624
+ const ctx = index.context('nonExistentSymbol');
1625
+ assert.ok(ctx, 'Should return context object');
1626
+ assert.strictEqual(ctx.function, 'nonExistentSymbol', 'Should include queried name');
1627
+ assert.ok(Array.isArray(ctx.callers), 'Callers should be array');
1628
+ assert.ok(Array.isArray(ctx.callees), 'Callees should be array');
1629
+ assert.strictEqual(ctx.callers.length, 0, 'Callers should be empty');
1630
+ assert.strictEqual(ctx.callees.length, 0, 'Callees should be empty');
1631
+ });
1632
+ });
1633
+
1634
+ it('smart should return null for non-existent function', () => {
1635
+ withTempProject((index) => {
1636
+ const smart = index.smart('nonExistentSymbol');
1637
+ assert.strictEqual(smart, null, 'Should return null');
1638
+ });
1639
+ });
1640
+
1641
+ it('about should return null for non-existent symbol', () => {
1642
+ withTempProject((index) => {
1643
+ const about = index.about('nonExistentSymbol');
1644
+ assert.strictEqual(about, null, 'Should return null');
1645
+ });
1646
+ });
1647
+
1648
+ it('impact should return null for non-existent function', () => {
1649
+ withTempProject((index) => {
1650
+ const impact = index.impact('nonExistentSymbol');
1651
+ assert.strictEqual(impact, null, 'Should return null');
1652
+ });
1653
+ });
1654
+
1655
+ it('tests should return empty array for non-existent symbol', () => {
1656
+ withTempProject((index) => {
1657
+ const tests = index.tests('nonExistentSymbol');
1658
+ assert.ok(Array.isArray(tests), 'Should return array');
1659
+ assert.strictEqual(tests.length, 0, 'Should be empty');
1660
+ });
1661
+ });
1662
+
1663
+ it('typedef should return empty array for non-existent type', () => {
1664
+ withTempProject((index) => {
1665
+ const typedefs = index.typedef('NonExistentType');
1666
+ assert.ok(Array.isArray(typedefs), 'Should return array');
1667
+ assert.strictEqual(typedefs.length, 0, 'Should be empty');
1668
+ });
1669
+ });
1670
+ });
1671
+
1672
+ // ============================================================================
1673
+ // COMPREHENSIVE COMMAND AND FLAG TESTS
1674
+ // ============================================================================
1675
+
1676
+ describe('Comprehensive command tests', () => {
1677
+ let tmpDir;
1678
+ let index;
1679
+
1680
+ // Setup test project
1681
+ function setupProject() {
1682
+ tmpDir = path.join(require('os').tmpdir(), `ucn-comprehensive-${Date.now()}`);
1683
+ fs.mkdirSync(tmpDir, { recursive: true });
1684
+ fs.mkdirSync(path.join(tmpDir, 'src'), { recursive: true });
1685
+ fs.mkdirSync(path.join(tmpDir, 'test'), { recursive: true });
1686
+
1687
+ // Main source file
1688
+ fs.writeFileSync(path.join(tmpDir, 'src', 'main.js'), `
1689
+ /**
1690
+ * Main entry point
1691
+ */
1692
+ function main() {
1693
+ const result = helper();
1694
+ return processData(result);
1695
+ }
1696
+
1697
+ function helper() {
1698
+ return { value: 42 };
1699
+ }
1700
+
1701
+ function processData(data) {
1702
+ return data.value * 2;
1703
+ }
1704
+
1705
+ // Unused function
1706
+ function unusedFunc() {
1707
+ return 'never called';
1708
+ }
1709
+
1710
+ module.exports = { main, helper, processData };
1711
+ `);
1712
+
1713
+ // Test file
1714
+ fs.writeFileSync(path.join(tmpDir, 'test', 'main.test.js'), `
1715
+ const { main, helper } = require('../src/main');
1716
+
1717
+ describe('main', () => {
1718
+ it('should work', () => {
1719
+ const result = main();
1720
+ expect(result).toBe(84);
1721
+ });
1722
+ });
1723
+ `);
1724
+
1725
+ index = new ProjectIndex(tmpDir);
1726
+ index.build('**/*.js', { quiet: true });
1727
+ }
1728
+
1729
+ function cleanupProject() {
1730
+ if (tmpDir) {
1731
+ fs.rmSync(tmpDir, { recursive: true, force: true });
1732
+ }
1733
+ }
1734
+
1735
+ it('find command with --exclude filter', () => {
1736
+ setupProject();
1737
+ try {
1738
+ // Find without exclude - should find in both src and test
1739
+ const allResults = index.find('main');
1740
+ assert.ok(allResults.length >= 1, 'Should find main');
1741
+
1742
+ // Find with exclude - should exclude test files
1743
+ const filtered = index.find('main', { exclude: ['test'] });
1744
+ const hasTestFile = filtered.some(r => r.relativePath && r.relativePath.includes('test'));
1745
+ // Test files might be excluded by default in find
1746
+ } finally {
1747
+ cleanupProject();
1748
+ }
1749
+ });
1750
+
1751
+ it('find command with --in filter', () => {
1752
+ setupProject();
1753
+ try {
1754
+ // Find only in src directory
1755
+ const srcOnly = index.find('main', { in: 'src' });
1756
+ assert.ok(srcOnly.every(r => r.relativePath && r.relativePath.includes('src')),
1757
+ 'All results should be in src directory');
1758
+ } finally {
1759
+ cleanupProject();
1760
+ }
1761
+ });
1762
+
1763
+ it('usages command groups by type correctly', () => {
1764
+ setupProject();
1765
+ try {
1766
+ const usages = index.usages('helper');
1767
+
1768
+ // Check that usages are properly categorized
1769
+ const defs = usages.filter(u => u.isDefinition);
1770
+ const calls = usages.filter(u => u.usageType === 'call' && !u.isDefinition);
1771
+ const imports = usages.filter(u => u.usageType === 'import');
1772
+
1773
+ assert.ok(defs.length >= 1, 'Should have at least 1 definition');
1774
+ assert.ok(calls.length >= 1, 'Should have at least 1 call');
1775
+ } finally {
1776
+ cleanupProject();
1777
+ }
1778
+ });
1779
+
1780
+ it('smart command returns function with dependencies', () => {
1781
+ setupProject();
1782
+ try {
1783
+ const smart = index.smart('main');
1784
+ assert.ok(smart, 'Should return smart result');
1785
+ assert.ok(smart.target, 'Should have target');
1786
+ assert.strictEqual(smart.target.name, 'main', 'Target should be main');
1787
+ assert.ok(smart.target.code, 'Should have code');
1788
+ assert.ok(Array.isArray(smart.dependencies), 'Should have dependencies array');
1789
+ } finally {
1790
+ cleanupProject();
1791
+ }
1792
+ });
1793
+
1794
+ it('trace command returns call tree', () => {
1795
+ setupProject();
1796
+ try {
1797
+ const trace = index.trace('main', { depth: 2 });
1798
+ assert.ok(trace, 'Should return trace result');
1799
+ assert.strictEqual(trace.root, 'main', 'Should be for main');
1800
+ assert.ok(trace.tree, 'Should have tree');
1801
+ } finally {
1802
+ cleanupProject();
1803
+ }
1804
+ });
1805
+
1806
+ it('related command finds related functions', () => {
1807
+ setupProject();
1808
+ try {
1809
+ const related = index.related('main');
1810
+ assert.ok(related, 'Should return related result');
1811
+ assert.ok(Array.isArray(related.sameFile), 'Should have sameFile array');
1812
+ } finally {
1813
+ cleanupProject();
1814
+ }
1815
+ });
1816
+
1817
+ it('imports command returns file imports', () => {
1818
+ setupProject();
1819
+ try {
1820
+ const imports = index.imports('test/main.test.js');
1821
+ assert.ok(Array.isArray(imports), 'Should return array');
1822
+ // Test file imports from src/main
1823
+ const hasMainImport = imports.some(i =>
1824
+ i.module && i.module.includes('main')
1825
+ );
1826
+ assert.ok(hasMainImport, 'Should find import from main');
1827
+ } finally {
1828
+ cleanupProject();
1829
+ }
1830
+ });
1831
+
1832
+ it('exporters command returns files that import a module', () => {
1833
+ setupProject();
1834
+ try {
1835
+ const exporters = index.exporters('src/main.js');
1836
+ assert.ok(Array.isArray(exporters), 'Should return array');
1837
+ } finally {
1838
+ cleanupProject();
1839
+ }
1840
+ });
1841
+
1842
+ it('fileExports command returns module exports', () => {
1843
+ setupProject();
1844
+ try {
1845
+ const exports = index.fileExports('src/main.js');
1846
+ assert.ok(Array.isArray(exports), 'Should return array');
1847
+ const exportNames = exports.map(e => e.name);
1848
+ assert.ok(exportNames.includes('main'), 'Should export main');
1849
+ assert.ok(exportNames.includes('helper'), 'Should export helper');
1850
+ } finally {
1851
+ cleanupProject();
1852
+ }
1853
+ });
1854
+
1855
+ it('api command returns public/exported symbols', () => {
1856
+ setupProject();
1857
+ try {
1858
+ const api = index.api();
1859
+ assert.ok(Array.isArray(api), 'Should return array');
1860
+ } finally {
1861
+ cleanupProject();
1862
+ }
1863
+ });
1864
+
1865
+ it('plan command analyzes refactoring impact', () => {
1866
+ setupProject();
1867
+ try {
1868
+ const plan = index.plan('helper', { addParam: 'options' });
1869
+ assert.ok(plan, 'Should return plan');
1870
+ assert.ok(plan.function === 'helper', 'Should be for helper');
1871
+ } finally {
1872
+ cleanupProject();
1873
+ }
1874
+ });
1875
+
1876
+ it('verify command checks call site consistency', () => {
1877
+ setupProject();
1878
+ try {
1879
+ const verify = index.verify('helper');
1880
+ assert.ok(verify, 'Should return verify result');
1881
+ assert.ok(typeof verify.totalCalls === 'number', 'Should have totalCalls');
1882
+ } finally {
1883
+ cleanupProject();
1884
+ }
1885
+ });
1886
+ });
1887
+
1888
+ describe('JSON output format', () => {
1889
+ it('find returns valid JSON structure', () => {
1890
+ const index = new ProjectIndex('.');
1891
+ index.build(null, { quiet: true });
1892
+
1893
+ const found = index.find('parse');
1894
+ assert.ok(Array.isArray(found), 'Should be array');
1895
+ if (found.length > 0) {
1896
+ assert.ok(found[0].name, 'Should have name');
1897
+ assert.ok(found[0].file || found[0].relativePath, 'Should have file info');
1898
+ }
1899
+ });
1900
+
1901
+ it('usages returns valid JSON structure', () => {
1902
+ const index = new ProjectIndex('.');
1903
+ index.build(null, { quiet: true });
1904
+
1905
+ const usages = index.usages('parse');
1906
+ assert.ok(Array.isArray(usages), 'Should be array');
1907
+ if (usages.length > 0) {
1908
+ assert.ok(typeof usages[0].isDefinition === 'boolean', 'Should have isDefinition');
1909
+ assert.ok(usages[0].usageType || usages[0].isDefinition, 'Should have usageType or isDefinition');
1910
+ }
1911
+ });
1912
+
1913
+ it('stats returns valid JSON structure', () => {
1914
+ const index = new ProjectIndex('.');
1915
+ index.build(null, { quiet: true });
1916
+
1917
+ const stats = index.getStats();
1918
+ assert.ok(stats.root, 'Should have root');
1919
+ assert.ok(typeof stats.files === 'number', 'Should have files count');
1920
+ assert.ok(typeof stats.symbols === 'number', 'Should have symbols count');
1921
+ assert.ok(stats.byLanguage, 'Should have byLanguage');
1922
+ assert.ok(stats.byType, 'Should have byType');
1923
+ });
1924
+ });
1925
+
1926
+ // ============================================================================
1927
+ // CACHE STALENESS REGRESSION TESTS
1928
+ // ============================================================================
1929
+
1930
+ describe('Cache staleness handling', () => {
1931
+ it('should not create duplicate symbols when cache is stale', () => {
1932
+ const tmpDir = path.join(require('os').tmpdir(), `ucn-cache-test-${Date.now()}`);
1933
+ fs.mkdirSync(tmpDir, { recursive: true });
1934
+
1935
+ // Create initial file
1936
+ fs.writeFileSync(path.join(tmpDir, 'app.js'), `
1937
+ function myFunc() {
1938
+ return 42;
1939
+ }
1940
+ module.exports = { myFunc };
1941
+ `);
1942
+
1943
+ try {
1944
+ // Build initial index
1945
+ const index1 = new ProjectIndex(tmpDir);
1946
+ index1.build('**/*.js', { quiet: true });
1947
+
1948
+ // Save cache
1949
+ const cacheDir = path.join(tmpDir, '.ucn-cache');
1950
+ fs.mkdirSync(cacheDir, { recursive: true });
1951
+ index1.saveCache(path.join(cacheDir, 'index.json'));
1952
+
1953
+ // Verify initial state - should have exactly 1 symbol
1954
+ const found1 = index1.find('myFunc');
1955
+ assert.strictEqual(found1.length, 1, 'Should find exactly 1 symbol initially');
1956
+
1957
+ // Modify the file to make cache stale
1958
+ fs.writeFileSync(path.join(tmpDir, 'app.js'), `
1959
+ function myFunc() {
1960
+ return 43; // modified
1961
+ }
1962
+ module.exports = { myFunc };
1963
+ `);
1964
+
1965
+ // Create new index, load cache, detect stale, and rebuild with forceRebuild
1966
+ const index2 = new ProjectIndex(tmpDir);
1967
+ const loaded = index2.loadCache(path.join(cacheDir, 'index.json'));
1968
+ assert.ok(loaded, 'Cache should load');
1969
+
1970
+ const stale = index2.isCacheStale();
1971
+ assert.ok(stale, 'Cache should be stale after file modification');
1972
+
1973
+ // This is the key fix: forceRebuild clears maps before rebuilding
1974
+ index2.build('**/*.js', { quiet: true, forceRebuild: true });
1975
+
1976
+ // Should still have exactly 1 symbol, not duplicates
1977
+ const found2 = index2.find('myFunc');
1978
+ assert.strictEqual(found2.length, 1, 'Should still find exactly 1 symbol after stale rebuild (no duplicates)');
1979
+ } finally {
1980
+ fs.rmSync(tmpDir, { recursive: true, force: true });
1981
+ }
1982
+ });
1983
+
1984
+ it('should create duplicates WITHOUT forceRebuild (demonstrates the bug)', () => {
1985
+ const tmpDir = path.join(require('os').tmpdir(), `ucn-cache-bug-${Date.now()}`);
1986
+ fs.mkdirSync(tmpDir, { recursive: true });
1987
+
1988
+ fs.writeFileSync(path.join(tmpDir, 'app.js'), `
1989
+ function testFunc() { return 1; }
1990
+ module.exports = { testFunc };
1991
+ `);
1992
+
1993
+ try {
1994
+ // Build and cache
1995
+ const index1 = new ProjectIndex(tmpDir);
1996
+ index1.build('**/*.js', { quiet: true });
1997
+
1998
+ const cacheDir = path.join(tmpDir, '.ucn-cache');
1999
+ fs.mkdirSync(cacheDir, { recursive: true });
2000
+ index1.saveCache(path.join(cacheDir, 'index.json'));
2001
+
2002
+ // Modify file
2003
+ fs.writeFileSync(path.join(tmpDir, 'app.js'), `
2004
+ function testFunc() { return 2; }
2005
+ module.exports = { testFunc };
2006
+ `);
2007
+
2008
+ // Load cache and rebuild WITHOUT forceRebuild
2009
+ const index2 = new ProjectIndex(tmpDir);
2010
+ index2.loadCache(path.join(cacheDir, 'index.json'));
2011
+ index2.build('**/*.js', { quiet: true }); // No forceRebuild!
2012
+
2013
+ // Without the fix, this would create duplicates
2014
+ const found = index2.find('testFunc');
2015
+ // This test documents the expected behavior with forceRebuild
2016
+ // Without it, duplicates could appear
2017
+ assert.ok(found.length >= 1, 'Should find at least 1 symbol');
2018
+ } finally {
2019
+ fs.rmSync(tmpDir, { recursive: true, force: true });
2020
+ }
2021
+ });
2022
+ });
2023
+
2024
+ // ============================================================================
2025
+ // AUTO-ROUTING REGRESSION TESTS
2026
+ // ============================================================================
2027
+
2028
+ describe('Auto-routing file commands to project mode', () => {
2029
+ it('should handle imports command on file path', () => {
2030
+ const index = new ProjectIndex('.');
2031
+ index.build(null, { quiet: true });
2032
+
2033
+ // Test that imports works with a file path
2034
+ const imports = index.imports('cli/index.js');
2035
+ assert.ok(Array.isArray(imports), 'Should return imports array');
2036
+ assert.ok(imports.length > 0, 'Should have some imports');
2037
+ // Check structure of import entry
2038
+ const hasInternal = imports.some(i => !i.isExternal);
2039
+ const hasExternal = imports.some(i => i.isExternal);
2040
+ assert.ok(hasInternal || hasExternal, 'Should have internal or external imports');
2041
+ });
2042
+
2043
+ it('should handle exporters command on file path', () => {
2044
+ const index = new ProjectIndex('.');
2045
+ index.build(null, { quiet: true });
2046
+
2047
+ // Test that exporters works with a file path
2048
+ const exporters = index.exporters('core/parser.js');
2049
+ assert.ok(Array.isArray(exporters), 'Should return exporters array');
2050
+ });
2051
+
2052
+ it('should handle graph command on file path', () => {
2053
+ const index = new ProjectIndex('.');
2054
+ index.build(null, { quiet: true });
2055
+
2056
+ // Test that graph works with a file path
2057
+ const graph = index.graph('cli/index.js', { direction: 'both', maxDepth: 2 });
2058
+ assert.ok(graph, 'Should return graph result');
2059
+ assert.ok(graph.nodes, 'Should have nodes');
2060
+ assert.ok(graph.edges, 'Should have edges');
2061
+ });
2062
+ });
2063
+
2064
+ // ============================================================================
2065
+ // HELPER FUNCTION TESTS
2066
+ // ============================================================================
2067
+
2068
+ describe('CLI helper functions', () => {
2069
+ // These test the helper behavior indirectly through the API
2070
+ // The actual requireArg and printOutput are CLI-internal
2071
+
2072
+ it('find should work with various options', () => {
2073
+ const index = new ProjectIndex('.');
2074
+ index.build(null, { quiet: true });
2075
+
2076
+ // Test exact match
2077
+ const exactResults = index.find('parse', { exact: true });
2078
+ assert.ok(Array.isArray(exactResults), 'Should return array');
2079
+
2080
+ // Test file filter
2081
+ const filteredResults = index.find('parse', { file: 'parser' });
2082
+ assert.ok(Array.isArray(filteredResults), 'Should return array with file filter');
2083
+ });
2084
+
2085
+ it('context should return proper structure', () => {
2086
+ const index = new ProjectIndex('.');
2087
+ index.build(null, { quiet: true });
2088
+
2089
+ const ctx = index.context('parse');
2090
+ assert.ok(ctx, 'Should return context');
2091
+ assert.ok(ctx.function === 'parse', 'Should have function name');
2092
+ assert.ok(Array.isArray(ctx.callers), 'Should have callers array');
2093
+ assert.ok(Array.isArray(ctx.callees), 'Should have callees array');
2094
+ });
2095
+ });
2096
+
2097
+ // ============================================================================
2098
+ // REGRESSION TESTS: isInsideString fixes
2099
+ // ============================================================================
2100
+
2101
+ describe('Regression: isInsideString string literal detection', () => {
2102
+ it('should NOT treat function calls between string literals as inside strings', () => {
2103
+ const tmpDir = path.join(require('os').tmpdir(), `ucn-test-string-between-${Date.now()}`);
2104
+ fs.mkdirSync(tmpDir, { recursive: true });
2105
+
2106
+ try {
2107
+ // Code with function call between string literals (the original bug case)
2108
+ // Uses simpler escaping to avoid test complexity
2109
+ const code = [
2110
+ 'function helper(x) { return x; }',
2111
+ '',
2112
+ 'function buildMessage(name) {',
2113
+ " const msg = 'Hello ' + helper(name) + '!';",
2114
+ ' return msg;',
2115
+ '}',
2116
+ '',
2117
+ 'module.exports = { helper, buildMessage };'
2118
+ ].join('\n');
2119
+ fs.writeFileSync(path.join(tmpDir, 'app.js'), code);
2120
+
2121
+ const index = new ProjectIndex(tmpDir);
2122
+ index.build('**/*.js', { quiet: true });
2123
+
2124
+ const usages = index.usages('helper');
2125
+ const calls = usages.filter(u => u.usageType === 'call' && !u.isDefinition);
2126
+
2127
+ // Should find the call in buildMessage (not be confused by surrounding strings)
2128
+ assert.ok(calls.length >= 1, 'Should find call to helper in buildMessage');
2129
+ assert.ok(calls.some(c => c.content && c.content.includes('helper(name)')),
2130
+ 'Should include the actual call site');
2131
+ } finally {
2132
+ fs.rmSync(tmpDir, { recursive: true, force: true });
2133
+ }
2134
+ });
2135
+
2136
+ it('should correctly identify function names inside string literals', () => {
2137
+ const tmpDir = path.join(require('os').tmpdir(), `ucn-test-string-inside-${Date.now()}`);
2138
+ fs.mkdirSync(tmpDir, { recursive: true });
2139
+
2140
+ try {
2141
+ fs.writeFileSync(path.join(tmpDir, 'app.js'), `
2142
+ function myFunc() {
2143
+ return 42;
2144
+ }
2145
+
2146
+ // These should NOT be counted as calls
2147
+ const str1 = 'myFunc is a function';
2148
+ const str2 = "call myFunc()";
2149
+ const str3 = \`myFunc documentation\`;
2150
+
2151
+ // This should be counted as a call
2152
+ const result = myFunc();
2153
+
2154
+ module.exports = { myFunc };
2155
+ `);
2156
+
2157
+ const index = new ProjectIndex(tmpDir);
2158
+ index.build('**/*.js', { quiet: true });
2159
+
2160
+ const usages = index.usages('myFunc');
2161
+ const calls = usages.filter(u => u.usageType === 'call' && !u.isDefinition);
2162
+
2163
+ // Should only find the actual call, not the string literals
2164
+ assert.strictEqual(calls.length, 1, 'Should find exactly 1 call (not string literals)');
2165
+ assert.ok(calls[0].content.includes('const result = myFunc()'),
2166
+ 'Should only include the actual function call');
2167
+ } finally {
2168
+ fs.rmSync(tmpDir, { recursive: true, force: true });
2169
+ }
2170
+ });
2171
+
2172
+ it('should correctly handle template literal expressions ${...}', () => {
2173
+ const tmpDir = path.join(require('os').tmpdir(), `ucn-test-template-expr-${Date.now()}`);
2174
+ fs.mkdirSync(tmpDir, { recursive: true });
2175
+
2176
+ try {
2177
+ fs.writeFileSync(path.join(tmpDir, 'app.js'), `
2178
+ function formatValue(x) {
2179
+ return x.toFixed(2);
2180
+ }
2181
+
2182
+ // This IS a call (inside template expression)
2183
+ const msg1 = \`Result: \${formatValue(42)}\`;
2184
+
2185
+ // This is NOT a call (plain text in template)
2186
+ const msg2 = \`The function formatValue is useful\`;
2187
+
2188
+ // Nested expression IS a call
2189
+ const msg3 = \`Value: \${flag ? formatValue(x) : 'none'}\`;
2190
+
2191
+ module.exports = { formatValue };
2192
+ `);
2193
+
2194
+ const index = new ProjectIndex(tmpDir);
2195
+ index.build('**/*.js', { quiet: true });
2196
+
2197
+ const usages = index.usages('formatValue');
2198
+ const calls = usages.filter(u => u.usageType === 'call' && !u.isDefinition);
2199
+
2200
+ // Should find the 2 calls in template expressions
2201
+ assert.strictEqual(calls.length, 2, 'Should find exactly 2 calls (template expressions)');
2202
+ assert.ok(calls.some(c => c.content.includes('${formatValue(42)}')),
2203
+ 'Should include first template expression call');
2204
+ assert.ok(calls.some(c => c.content.includes('${flag ? formatValue(x)')),
2205
+ 'Should include nested template expression call');
2206
+ } finally {
2207
+ fs.rmSync(tmpDir, { recursive: true, force: true });
2208
+ }
2209
+ });
2210
+
2211
+ it('should handle escaped quotes correctly', () => {
2212
+ const tmpDir = path.join(require('os').tmpdir(), `ucn-test-escaped-quotes-${Date.now()}`);
2213
+ fs.mkdirSync(tmpDir, { recursive: true });
2214
+
2215
+ try {
2216
+ // Use array join to avoid escaping issues in template literals
2217
+ const code = [
2218
+ 'function process(x) {',
2219
+ ' return x;',
2220
+ '}',
2221
+ '',
2222
+ '// Escaped quotes should not confuse the parser',
2223
+ "const str = 'Don\\'t call process() here'; // NOT a call (inside string with escaped quote)",
2224
+ 'const result = process(42); // IS a call',
2225
+ '',
2226
+ 'module.exports = { process };'
2227
+ ].join('\n');
2228
+ fs.writeFileSync(path.join(tmpDir, 'app.js'), code);
2229
+
2230
+ const index = new ProjectIndex(tmpDir);
2231
+ index.build('**/*.js', { quiet: true });
2232
+
2233
+ const usages = index.usages('process');
2234
+ const calls = usages.filter(u => u.usageType === 'call' && !u.isDefinition);
2235
+
2236
+ // Should only find the actual call
2237
+ assert.strictEqual(calls.length, 1, 'Should find exactly 1 call');
2238
+ assert.ok(calls[0].content.includes('const result = process(42)'),
2239
+ 'Should only include the actual function call');
2240
+ } finally {
2241
+ fs.rmSync(tmpDir, { recursive: true, force: true });
2242
+ }
2243
+ });
2244
+
2245
+ it('should handle mixed quote types correctly', () => {
2246
+ const tmpDir = path.join(require('os').tmpdir(), `ucn-test-mixed-quotes-${Date.now()}`);
2247
+ fs.mkdirSync(tmpDir, { recursive: true });
2248
+
2249
+ try {
2250
+ fs.writeFileSync(path.join(tmpDir, 'app.js'), `
2251
+ function helper(x) {
2252
+ return x;
2253
+ }
2254
+
2255
+ // Double quotes containing single quotes
2256
+ const a = "don't call helper()"; // NOT a call
2257
+
2258
+ // Single quotes containing double quotes
2259
+ const b = '"helper" is the name'; // NOT a call
2260
+
2261
+ // Template containing both
2262
+ const c = \`"helper" and 'helper'\`; // NOT a call
2263
+
2264
+ // Actual call
2265
+ const d = helper(1); // IS a call
2266
+
2267
+ module.exports = { helper };
2268
+ `);
2269
+
2270
+ const index = new ProjectIndex(tmpDir);
2271
+ index.build('**/*.js', { quiet: true });
2272
+
2273
+ const usages = index.usages('helper');
2274
+ const calls = usages.filter(u => u.usageType === 'call' && !u.isDefinition);
2275
+
2276
+ // Should only find the actual call
2277
+ assert.strictEqual(calls.length, 1, 'Should find exactly 1 call');
2278
+ } finally {
2279
+ fs.rmSync(tmpDir, { recursive: true, force: true });
2280
+ }
2281
+ });
2282
+ });
2283
+
2284
+ // ============================================================================
2285
+ // REGRESSION TESTS: deadcode detection accuracy
2286
+ // ============================================================================
2287
+
2288
+ describe('Regression: deadcode detection accuracy', () => {
2289
+ it('should NOT report functions used in concatenated string patterns as dead', () => {
2290
+ const tmpDir = path.join(require('os').tmpdir(), `ucn-test-deadcode-regex-${Date.now()}`);
2291
+ fs.mkdirSync(tmpDir, { recursive: true });
2292
+
2293
+ try {
2294
+ // Simpler test case without complex regex escaping
2295
+ const code = [
2296
+ 'function helper(x) { return x; }',
2297
+ '',
2298
+ 'function buildMessage(name) {',
2299
+ " return 'Hello ' + helper(name) + '!';",
2300
+ '}',
2301
+ '',
2302
+ 'module.exports = { helper, buildMessage };'
2303
+ ].join('\n');
2304
+ fs.writeFileSync(path.join(tmpDir, 'utils.js'), code);
2305
+
2306
+ const index = new ProjectIndex(tmpDir);
2307
+ index.build('**/*.js', { quiet: true });
2308
+
2309
+ const deadcode = index.deadcode();
2310
+ const deadNames = deadcode.map(d => d.name);
2311
+
2312
+ // helper is used in buildMessage, should NOT be dead
2313
+ assert.ok(!deadNames.includes('helper'),
2314
+ 'helper should NOT be reported as dead (it is used in buildMessage)');
2315
+ } finally {
2316
+ fs.rmSync(tmpDir, { recursive: true, force: true });
2317
+ }
2318
+ });
2319
+
2320
+ it('should NOT report functions used in template literal expressions as dead', () => {
2321
+ const tmpDir = path.join(require('os').tmpdir(), `ucn-test-deadcode-template-${Date.now()}`);
2322
+ fs.mkdirSync(tmpDir, { recursive: true });
2323
+
2324
+ try {
2325
+ fs.writeFileSync(path.join(tmpDir, 'format.js'), `
2326
+ function formatScore(score) {
2327
+ return score.toFixed(1);
2328
+ }
2329
+
2330
+ function displayResult(data) {
2331
+ console.log(\`Score: \${formatScore(data.value)}\`);
2332
+ }
2333
+
2334
+ displayResult({ value: 42 });
2335
+ module.exports = { formatScore, displayResult };
2336
+ `);
2337
+
2338
+ const index = new ProjectIndex(tmpDir);
2339
+ index.build('**/*.js', { quiet: true });
2340
+
2341
+ const deadcode = index.deadcode();
2342
+ const deadNames = deadcode.map(d => d.name);
2343
+
2344
+ // formatScore is used inside template expression, should NOT be dead
2345
+ assert.ok(!deadNames.includes('formatScore'),
2346
+ 'formatScore should NOT be reported as dead (used in template expression)');
2347
+ } finally {
2348
+ fs.rmSync(tmpDir, { recursive: true, force: true });
2349
+ }
2350
+ });
2351
+
2352
+ it('should correctly identify actually unused functions', () => {
2353
+ const tmpDir = path.join(require('os').tmpdir(), `ucn-test-deadcode-real-${Date.now()}`);
2354
+ fs.mkdirSync(tmpDir, { recursive: true });
2355
+
2356
+ try {
2357
+ fs.writeFileSync(path.join(tmpDir, 'lib.js'), `
2358
+ function usedFunction() {
2359
+ return 42;
2360
+ }
2361
+
2362
+ function unusedFunction() {
2363
+ return 'never called';
2364
+ }
2365
+
2366
+ function anotherUnused() {
2367
+ return 'also never called';
2368
+ }
2369
+
2370
+ const result = usedFunction();
2371
+ console.log(result);
2372
+ `);
2373
+
2374
+ const index = new ProjectIndex(tmpDir);
2375
+ index.build('**/*.js', { quiet: true });
2376
+
2377
+ const deadcode = index.deadcode();
2378
+ const deadNames = deadcode.map(d => d.name);
2379
+
2380
+ // Check correct identification
2381
+ assert.ok(!deadNames.includes('usedFunction'), 'usedFunction should NOT be dead');
2382
+ assert.ok(deadNames.includes('unusedFunction'), 'unusedFunction SHOULD be dead');
2383
+ assert.ok(deadNames.includes('anotherUnused'), 'anotherUnused SHOULD be dead');
2384
+ } finally {
2385
+ fs.rmSync(tmpDir, { recursive: true, force: true });
2386
+ }
2387
+ });
2388
+ });
2389
+
2390
+ // ============================================================================
2391
+ // REGRESSION TESTS: regex global flag bug
2392
+ // ============================================================================
2393
+
2394
+ describe('Regression: regex global flag bug', () => {
2395
+ it('usages should find ALL matching lines (not alternate due to g flag)', () => {
2396
+ const tmpDir = path.join(require('os').tmpdir(), `ucn-test-regex-g-${Date.now()}`);
2397
+ fs.mkdirSync(tmpDir, { recursive: true });
2398
+
2399
+ try {
2400
+ // Create a file with multiple calls to the same function
2401
+ const code = [
2402
+ 'function helper(x) { return x; }',
2403
+ '',
2404
+ 'const a = helper(1);',
2405
+ 'const b = helper(2);',
2406
+ 'const c = helper(3);',
2407
+ 'const d = helper(4);',
2408
+ 'const e = helper(5);',
2409
+ '',
2410
+ 'module.exports = { helper };'
2411
+ ].join('\n');
2412
+ fs.writeFileSync(path.join(tmpDir, 'app.js'), code);
2413
+
2414
+ const index = new ProjectIndex(tmpDir);
2415
+ index.build('**/*.js', { quiet: true });
2416
+
2417
+ const usages = index.usages('helper');
2418
+ const calls = usages.filter(u => u.usageType === 'call' && !u.isDefinition);
2419
+
2420
+ // Should find ALL 5 calls, not just some due to g flag lastIndex bug
2421
+ assert.strictEqual(calls.length, 5, 'Should find all 5 calls to helper');
2422
+ } finally {
2423
+ fs.rmSync(tmpDir, { recursive: true, force: true });
2424
+ }
2425
+ });
2426
+
2427
+ it('findCallers should find ALL callers (not alternate due to g flag)', () => {
2428
+ const tmpDir = path.join(require('os').tmpdir(), `ucn-test-regex-callers-${Date.now()}`);
2429
+ fs.mkdirSync(tmpDir, { recursive: true });
2430
+
2431
+ try {
2432
+ const code = [
2433
+ 'function target() { return 42; }',
2434
+ '',
2435
+ 'function caller1() { return target(); }',
2436
+ 'function caller2() { return target(); }',
2437
+ 'function caller3() { return target(); }',
2438
+ 'function caller4() { return target(); }',
2439
+ '',
2440
+ 'module.exports = { target };'
2441
+ ].join('\n');
2442
+ fs.writeFileSync(path.join(tmpDir, 'app.js'), code);
2443
+
2444
+ const index = new ProjectIndex(tmpDir);
2445
+ index.build('**/*.js', { quiet: true });
2446
+
2447
+ const ctx = index.context('target');
2448
+
2449
+ // Should find ALL 4 callers
2450
+ assert.strictEqual(ctx.callers.length, 4, 'Should find all 4 callers');
2451
+ } finally {
2452
+ fs.rmSync(tmpDir, { recursive: true, force: true });
2453
+ }
2454
+ });
2455
+
2456
+ it('search should find ALL matching lines', () => {
2457
+ const tmpDir = path.join(require('os').tmpdir(), `ucn-test-regex-search-${Date.now()}`);
2458
+ fs.mkdirSync(tmpDir, { recursive: true });
2459
+
2460
+ try {
2461
+ const code = [
2462
+ '// TODO: fix this',
2463
+ '// TODO: add tests',
2464
+ '// TODO: refactor',
2465
+ '// TODO: document',
2466
+ '// TODO: cleanup'
2467
+ ].join('\n');
2468
+ fs.writeFileSync(path.join(tmpDir, 'app.js'), code);
2469
+
2470
+ const index = new ProjectIndex(tmpDir);
2471
+ index.build('**/*.js', { quiet: true });
2472
+
2473
+ const results = index.search('TODO');
2474
+ const matches = results[0]?.matches || [];
2475
+
2476
+ // Should find ALL 5 TODOs
2477
+ assert.strictEqual(matches.length, 5, 'Should find all 5 TODO comments');
2478
+ } finally {
2479
+ fs.rmSync(tmpDir, { recursive: true, force: true });
2480
+ }
2481
+ });
2482
+ });
2483
+
2484
+ // ============================================================================
2485
+ // REGRESSION TESTS: Bug fixes from code review
2486
+ // ============================================================================
2487
+
2488
+ describe('Regression: findCallers should filter comment lines', () => {
2489
+ it('should not include comment lines as callers', () => {
2490
+ const tmpDir = path.join(require('os').tmpdir(), `ucn-test-caller-comments-${Date.now()}`);
2491
+ fs.mkdirSync(tmpDir, { recursive: true });
2492
+
2493
+ try {
2494
+ // Create a file with a function and comments mentioning it
2495
+ fs.writeFileSync(path.join(tmpDir, 'util.js'), `
2496
+ function processData(input) {
2497
+ return input.toUpperCase();
2498
+ }
2499
+
2500
+ function main() {
2501
+ // Call processData() to handle input
2502
+ const result = processData("hello");
2503
+ // processData() should not be included as a caller from this comment
2504
+ return result;
2505
+ }
2506
+
2507
+ module.exports = { processData, main };
2508
+ `);
2509
+
2510
+ const index = new ProjectIndex(tmpDir);
2511
+ index.build('**/*.js', { quiet: true });
2512
+
2513
+ const ctx = index.context('processData');
2514
+ const callers = ctx.callers;
2515
+
2516
+ // Should find main as a caller, but not the comment lines
2517
+ const callerLines = callers.map(c => c.line);
2518
+
2519
+ // Line 8 is the actual call
2520
+ assert.ok(callerLines.includes(8), 'Should include actual call on line 8');
2521
+
2522
+ // Comment lines should NOT be included
2523
+ const commentCallers = callers.filter(c => c.content.trim().startsWith('//'));
2524
+ assert.strictEqual(commentCallers.length, 0, 'Should not include comment lines as callers');
2525
+ } finally {
2526
+ fs.rmSync(tmpDir, { recursive: true, force: true });
2527
+ }
2528
+ });
2529
+ });
2530
+
2531
+ describe('Regression: findCallees should filter comments and strings', () => {
2532
+ it('should not count function names in comments as callees', () => {
2533
+ const tmpDir = path.join(require('os').tmpdir(), `ucn-test-callee-comments-${Date.now()}`);
2534
+ fs.mkdirSync(tmpDir, { recursive: true });
2535
+
2536
+ try {
2537
+ fs.writeFileSync(path.join(tmpDir, 'util.js'), `
2538
+ function helperFunction() {
2539
+ return 42;
2540
+ }
2541
+
2542
+ function mainFunction() {
2543
+ // helperFunction() does something useful
2544
+ // We call helperFunction() below
2545
+ const result = helperFunction();
2546
+ return result;
2547
+ }
2548
+
2549
+ module.exports = { helperFunction, mainFunction };
2550
+ `);
2551
+
2552
+ const index = new ProjectIndex(tmpDir);
2553
+ index.build('**/*.js', { quiet: true });
2554
+
2555
+ const mainDef = index.symbols.get('mainFunction')?.[0];
2556
+ const callees = index.findCallees(mainDef);
2557
+
2558
+ // helperFunction should only be counted once (from the actual call)
2559
+ const helperCallee = callees.find(c => c.name === 'helperFunction');
2560
+ assert.ok(helperCallee, 'Should find helperFunction as a callee');
2561
+ assert.strictEqual(helperCallee.callCount, 1, 'Should count helperFunction only once (not from comments)');
2562
+ } finally {
2563
+ fs.rmSync(tmpDir, { recursive: true, force: true });
2564
+ }
2565
+ });
2566
+ });
2567
+
2568
+ describe('Regression: search should escape regex special characters', () => {
2569
+ it('should not crash when searching for regex special chars', () => {
2570
+ const tmpDir = path.join(require('os').tmpdir(), `ucn-test-search-regex-${Date.now()}`);
2571
+ fs.mkdirSync(tmpDir, { recursive: true });
2572
+
2573
+ try {
2574
+ fs.writeFileSync(path.join(tmpDir, 'code.js'), `
2575
+ function process(x) {
2576
+ return x + 1;
2577
+ }
2578
+ // Call process(x) here
2579
+ const result = process(42);
2580
+ `);
2581
+
2582
+ const index = new ProjectIndex(tmpDir);
2583
+ index.build('**/*.js', { quiet: true });
2584
+
2585
+ // These should not throw
2586
+ assert.doesNotThrow(() => index.search('process('));
2587
+ assert.doesNotThrow(() => index.search('(x)'));
2588
+ assert.doesNotThrow(() => index.search('[test]'));
2589
+ assert.doesNotThrow(() => index.search('x + 1'));
2590
+
2591
+ const results = index.search('process(');
2592
+ assert.ok(results.length > 0, 'Should find matches for process(');
2593
+ } finally {
2594
+ fs.rmSync(tmpDir, { recursive: true, force: true });
2595
+ }
2596
+ });
2597
+ });
2598
+
2599
+ describe('Regression: trace depth=0 should work correctly', () => {
2600
+ it('should show only root function when depth=0', () => {
2601
+ const tmpDir = path.join(require('os').tmpdir(), `ucn-test-trace-depth-${Date.now()}`);
2602
+ fs.mkdirSync(tmpDir, { recursive: true });
2603
+
2604
+ try {
2605
+ fs.writeFileSync(path.join(tmpDir, 'util.js'), `
2606
+ function helper() {
2607
+ return 1;
2608
+ }
2609
+
2610
+ function main() {
2611
+ return helper();
2612
+ }
2613
+
2614
+ module.exports = { helper, main };
2615
+ `);
2616
+
2617
+ const index = new ProjectIndex(tmpDir);
2618
+ index.build('**/*.js', { quiet: true });
2619
+
2620
+ const trace0 = index.trace('main', { depth: 0 });
2621
+ assert.ok(trace0, 'Should return trace result');
2622
+ assert.strictEqual(trace0.maxDepth, 0, 'maxDepth should be 0');
2623
+ assert.strictEqual(trace0.tree.children.length, 0, 'Should have no children with depth=0');
2624
+
2625
+ const trace1 = index.trace('main', { depth: 1 });
2626
+ assert.strictEqual(trace1.maxDepth, 1, 'maxDepth should be 1');
2627
+ assert.ok(trace1.tree.children.length > 0, 'Should have children with depth=1');
2628
+ } finally {
2629
+ fs.rmSync(tmpDir, { recursive: true, force: true });
2630
+ }
2631
+ });
2632
+ });
2633
+
2634
+ describe('Regression: indent should be stored for --top-level filtering', () => {
2635
+ it('should store indent field in project index', () => {
2636
+ const tmpDir = path.join(require('os').tmpdir(), `ucn-test-indent-${Date.now()}`);
2637
+ fs.mkdirSync(tmpDir, { recursive: true });
2638
+
2639
+ try {
2640
+ fs.writeFileSync(path.join(tmpDir, 'code.js'), `
2641
+ function outer() {
2642
+ function inner() {
2643
+ return 1;
2644
+ }
2645
+ return inner();
2646
+ }
2647
+
2648
+ module.exports = { outer };
2649
+ `);
2650
+
2651
+ const index = new ProjectIndex(tmpDir);
2652
+ index.build('**/*.js', { quiet: true });
2653
+
2654
+ const outerDef = index.symbols.get('outer')?.[0];
2655
+ const innerDef = index.symbols.get('inner')?.[0];
2656
+
2657
+ assert.ok(outerDef, 'Should find outer function');
2658
+ assert.ok(innerDef, 'Should find inner function');
2659
+
2660
+ // outer should have indent 0, inner should have indent > 0
2661
+ assert.strictEqual(outerDef.indent, 0, 'outer should have indent 0');
2662
+ assert.ok(innerDef.indent > 0, 'inner should have indent > 0');
2663
+ } finally {
2664
+ fs.rmSync(tmpDir, { recursive: true, force: true });
2665
+ }
2666
+ });
2667
+ });
2668
+
2669
+ // ============================================================================
2670
+ // Multi-language Fixture Tests
2671
+ // Tests parsing of realistic code fixtures for all supported languages
2672
+ // ============================================================================
2673
+
2674
+ describe('Multi-language Fixtures: Python', () => {
2675
+ const fixturesPath = path.join(__dirname, 'fixtures', 'python');
2676
+
2677
+ it('should parse Python functions with type hints', () => {
2678
+ if (!fs.existsSync(path.join(fixturesPath, 'main.py'))) {
2679
+ // Skip if fixtures not created yet
2680
+ return;
2681
+ }
2682
+
2683
+ const index = new ProjectIndex(fixturesPath);
2684
+ index.build('**/*.py', { quiet: true });
2685
+
2686
+ // Should find functions
2687
+ assert.ok(index.symbols.has('create_task'), 'Should find create_task function');
2688
+ assert.ok(index.symbols.has('filter_by_status'), 'Should find filter_by_status function');
2689
+
2690
+ // Should find classes
2691
+ assert.ok(index.symbols.has('TaskManager'), 'Should find TaskManager class');
2692
+ assert.ok(index.symbols.has('DataService'), 'Should find DataService class');
2693
+ });
2694
+
2695
+ it('should extract Python functions correctly', () => {
2696
+ if (!fs.existsSync(path.join(fixturesPath, 'main.py'))) {
2697
+ return;
2698
+ }
2699
+
2700
+ const index = new ProjectIndex(fixturesPath);
2701
+ index.build('**/*.py', { quiet: true });
2702
+
2703
+ const fnDefs = index.symbols.get('create_task');
2704
+ assert.ok(fnDefs && fnDefs.length > 0, 'Should find create_task');
2705
+
2706
+ // Use extractCode which takes a symbol definition
2707
+ const code = index.extractCode(fnDefs[0]);
2708
+ assert.ok(code, 'Should extract function code');
2709
+ assert.ok(code.includes('def create_task'), 'Code should contain function definition');
2710
+ });
2711
+ });
2712
+
2713
+ describe('Multi-language Fixtures: Go', () => {
2714
+ const fixturesPath = path.join(__dirname, 'fixtures', 'go');
2715
+
2716
+ it('should parse Go functions and structs', () => {
2717
+ if (!fs.existsSync(path.join(fixturesPath, 'main.go'))) {
2718
+ return;
2719
+ }
2720
+
2721
+ const index = new ProjectIndex(fixturesPath);
2722
+ index.build('**/*.go', { quiet: true });
2723
+
2724
+ // Should find functions
2725
+ assert.ok(index.symbols.has('NewTaskManager'), 'Should find NewTaskManager function');
2726
+ assert.ok(index.symbols.has('ValidateTask'), 'Should find ValidateTask function');
2727
+
2728
+ // Should find structs
2729
+ assert.ok(index.symbols.has('Task'), 'Should find Task struct');
2730
+ assert.ok(index.symbols.has('TaskManager'), 'Should find TaskManager struct');
2731
+ });
2732
+
2733
+ it('should parse Go methods on structs', () => {
2734
+ if (!fs.existsSync(path.join(fixturesPath, 'main.go'))) {
2735
+ return;
2736
+ }
2737
+
2738
+ const index = new ProjectIndex(fixturesPath);
2739
+ index.build('**/*.go', { quiet: true });
2740
+
2741
+ // Should find struct methods
2742
+ assert.ok(index.symbols.has('AddTask'), 'Should find AddTask method');
2743
+ assert.ok(index.symbols.has('GetTask'), 'Should find GetTask method');
2744
+ });
2745
+ });
2746
+
2747
+ describe('Multi-language Fixtures: Rust', () => {
2748
+ const fixturesPath = path.join(__dirname, 'fixtures', 'rust');
2749
+
2750
+ it('should parse Rust functions and structs', () => {
2751
+ if (!fs.existsSync(path.join(fixturesPath, 'main.rs'))) {
2752
+ return;
2753
+ }
2754
+
2755
+ const index = new ProjectIndex(fixturesPath);
2756
+ index.build('**/*.rs', { quiet: true });
2757
+
2758
+ // Should find functions
2759
+ assert.ok(index.symbols.has('validate_task'), 'Should find validate_task function');
2760
+ assert.ok(index.symbols.has('create_task'), 'Should find create_task function');
2761
+
2762
+ // Should find structs
2763
+ assert.ok(index.symbols.has('Task'), 'Should find Task struct');
2764
+ assert.ok(index.symbols.has('TaskManager'), 'Should find TaskManager struct');
2765
+ });
2766
+
2767
+ it('should parse Rust impl blocks', () => {
2768
+ if (!fs.existsSync(path.join(fixturesPath, 'main.rs'))) {
2769
+ return;
2770
+ }
2771
+
2772
+ const index = new ProjectIndex(fixturesPath);
2773
+ index.build('**/*.rs', { quiet: true });
2774
+
2775
+ // Should find methods from impl blocks
2776
+ assert.ok(index.symbols.has('add_task'), 'Should find add_task method');
2777
+ assert.ok(index.symbols.has('get_task'), 'Should find get_task method');
2778
+ });
2779
+ });
2780
+
2781
+ describe('Multi-language Fixtures: Java', () => {
2782
+ const fixturesPath = path.join(__dirname, 'fixtures', 'java');
2783
+
2784
+ it('should parse Java classes and methods', () => {
2785
+ if (!fs.existsSync(path.join(fixturesPath, 'Main.java'))) {
2786
+ return;
2787
+ }
2788
+
2789
+ const index = new ProjectIndex(fixturesPath);
2790
+ index.build('**/*.java', { quiet: true });
2791
+
2792
+ // Should find classes
2793
+ assert.ok(index.symbols.has('Main'), 'Should find Main class');
2794
+ assert.ok(index.symbols.has('DataService'), 'Should find DataService class');
2795
+
2796
+ // Should find methods
2797
+ assert.ok(index.symbols.has('createTask'), 'Should find createTask method');
2798
+ assert.ok(index.symbols.has('validateTask'), 'Should find validateTask method');
2799
+ });
2800
+
2801
+ it('should parse Java inner classes', () => {
2802
+ if (!fs.existsSync(path.join(fixturesPath, 'Main.java'))) {
2803
+ return;
2804
+ }
2805
+
2806
+ const index = new ProjectIndex(fixturesPath);
2807
+ index.build('**/*.java', { quiet: true });
2808
+
2809
+ // Should find inner classes/enums
2810
+ assert.ok(index.symbols.has('Task'), 'Should find Task inner class');
2811
+ assert.ok(index.symbols.has('TaskManager'), 'Should find TaskManager inner class');
2812
+ });
2813
+ });
2814
+
2815
+ describe('Regression: Project detection with language markers', () => {
2816
+ it('should detect Python project with pyproject.toml', () => {
2817
+ const tmpDir = path.join(require('os').tmpdir(), `ucn-test-pyproject-${Date.now()}`);
2818
+ fs.mkdirSync(tmpDir, { recursive: true });
2819
+
2820
+ try {
2821
+ fs.writeFileSync(path.join(tmpDir, 'pyproject.toml'), '[project]\nname = "test"');
2822
+ fs.writeFileSync(path.join(tmpDir, 'main.py'), 'def hello():\n pass');
2823
+
2824
+ const { detectProjectPattern } = require('../core/discovery');
2825
+ const pattern = detectProjectPattern(tmpDir);
2826
+
2827
+ // Pattern format is **/*.{py} or includes py in extensions
2828
+ assert.ok(pattern.includes('py'), 'Should detect Python files');
2829
+ } finally {
2830
+ fs.rmSync(tmpDir, { recursive: true, force: true });
2831
+ }
2832
+ });
2833
+
2834
+ it('should detect Go project with go.mod', () => {
2835
+ const tmpDir = path.join(require('os').tmpdir(), `ucn-test-gomod-${Date.now()}`);
2836
+ fs.mkdirSync(tmpDir, { recursive: true });
2837
+
2838
+ try {
2839
+ fs.writeFileSync(path.join(tmpDir, 'go.mod'), 'module test\ngo 1.21');
2840
+ fs.writeFileSync(path.join(tmpDir, 'main.go'), 'package main\nfunc main() {}');
2841
+
2842
+ const { detectProjectPattern } = require('../core/discovery');
2843
+ const pattern = detectProjectPattern(tmpDir);
2844
+
2845
+ assert.ok(pattern.includes('go'), 'Should detect Go files');
2846
+ } finally {
2847
+ fs.rmSync(tmpDir, { recursive: true, force: true });
2848
+ }
2849
+ });
2850
+
2851
+ it('should detect Rust project with Cargo.toml', () => {
2852
+ const tmpDir = path.join(require('os').tmpdir(), `ucn-test-cargo-${Date.now()}`);
2853
+ fs.mkdirSync(tmpDir, { recursive: true });
2854
+
2855
+ try {
2856
+ fs.writeFileSync(path.join(tmpDir, 'Cargo.toml'), '[package]\nname = "test"');
2857
+ fs.writeFileSync(path.join(tmpDir, 'main.rs'), 'fn main() {}');
2858
+
2859
+ const { detectProjectPattern } = require('../core/discovery');
2860
+ const pattern = detectProjectPattern(tmpDir);
2861
+
2862
+ assert.ok(pattern.includes('rs'), 'Should detect Rust files');
2863
+ } finally {
2864
+ fs.rmSync(tmpDir, { recursive: true, force: true });
2865
+ }
2866
+ });
2867
+
2868
+ it('should detect Java project with pom.xml', () => {
2869
+ const tmpDir = path.join(require('os').tmpdir(), `ucn-test-pom-${Date.now()}`);
2870
+ fs.mkdirSync(tmpDir, { recursive: true });
2871
+
2872
+ try {
2873
+ fs.writeFileSync(path.join(tmpDir, 'pom.xml'), '<project></project>');
2874
+ fs.writeFileSync(path.join(tmpDir, 'Main.java'), 'public class Main {}');
2875
+
2876
+ const { detectProjectPattern } = require('../core/discovery');
2877
+ const pattern = detectProjectPattern(tmpDir);
2878
+
2879
+ assert.ok(pattern.includes('java'), 'Should detect Java files');
2880
+ } finally {
2881
+ fs.rmSync(tmpDir, { recursive: true, force: true });
2882
+ }
2883
+ });
2884
+ });
2885
+
2886
+ // ============================================================================
2887
+ // REGRESSION: lines command should validate input
2888
+ // ============================================================================
2889
+
2890
+ describe('Regression: lines command should validate input', () => {
2891
+ it('should error on out-of-bounds line range', () => {
2892
+ const fixtureFile = path.join(__dirname, 'fixtures', 'javascript', 'main.js');
2893
+ const content = fs.readFileSync(fixtureFile, 'utf-8');
2894
+ const lineCount = content.split('\n').length;
2895
+
2896
+ // Run UCN with lines command that exceeds file length
2897
+ const { execSync } = require('child_process');
2898
+ const ucnPath = path.join(__dirname, '..', 'ucn.js');
2899
+
2900
+ try {
2901
+ execSync(`node ${ucnPath} ${fixtureFile} lines ${lineCount + 100}-${lineCount + 200}`, {
2902
+ encoding: 'utf8',
2903
+ stdio: ['pipe', 'pipe', 'pipe']
2904
+ });
2905
+ assert.fail('Should have thrown an error for out-of-bounds range');
2906
+ } catch (e) {
2907
+ assert.ok(e.stderr.includes('out of bounds'), 'Should report out of bounds error');
2908
+ }
2909
+ });
2910
+
2911
+ it('should handle reversed line range by swapping', () => {
2912
+ const fixtureFile = path.join(__dirname, 'fixtures', 'javascript', 'main.js');
2913
+ const { execSync } = require('child_process');
2914
+ const ucnPath = path.join(__dirname, '..', 'ucn.js');
2915
+
2916
+ // Reversed range should work (10-5 should become 5-10)
2917
+ const output = execSync(`node ${ucnPath} ${fixtureFile} lines 10-5`, {
2918
+ encoding: 'utf8'
2919
+ });
2920
+
2921
+ assert.ok(output.includes('5 │'), 'Should include line 5');
2922
+ assert.ok(output.includes('10 │'), 'Should include line 10');
2923
+ });
2924
+
2925
+ it('should error on non-numeric line range', () => {
2926
+ const fixtureFile = path.join(__dirname, 'fixtures', 'javascript', 'main.js');
2927
+ const { execSync } = require('child_process');
2928
+ const ucnPath = path.join(__dirname, '..', 'ucn.js');
2929
+
2930
+ try {
2931
+ execSync(`node ${ucnPath} ${fixtureFile} lines abc-def`, {
2932
+ encoding: 'utf8',
2933
+ stdio: ['pipe', 'pipe', 'pipe']
2934
+ });
2935
+ assert.fail('Should have thrown an error for non-numeric range');
2936
+ } catch (e) {
2937
+ assert.ok(e.stderr.includes('Invalid line range'), 'Should report invalid range error');
2938
+ }
2939
+ });
2940
+ });
2941
+
2942
+ // ============================================================================
2943
+ // REGRESSION: findCallees should not include function declaration
2944
+ // ============================================================================
2945
+
2946
+ describe('Regression: findCallees should not include function declaration', () => {
2947
+ it('should not list function as its own callee when not recursive', () => {
2948
+ const tmpDir = path.join(require('os').tmpdir(), `ucn-test-callee-decl-${Date.now()}`);
2949
+ fs.mkdirSync(tmpDir, { recursive: true });
2950
+
2951
+ try {
2952
+ fs.writeFileSync(path.join(tmpDir, 'package.json'), '{"name": "test"}');
2953
+ fs.writeFileSync(path.join(tmpDir, 'lib.js'), `
2954
+ function nonRecursive(x) {
2955
+ return helper(x) + other(x);
2956
+ }
2957
+ function helper(x) { return x * 2; }
2958
+ function other(x) { return x + 1; }
2959
+ module.exports = { nonRecursive, helper, other };
2960
+ `);
2961
+
2962
+ const index = new ProjectIndex(tmpDir);
2963
+ index.build('**/*.js', { quiet: true });
2964
+
2965
+ const ctx = index.context('nonRecursive');
2966
+
2967
+ // Function should not appear as its own callee
2968
+ const selfCallee = ctx.callees.find(c => c.name === 'nonRecursive');
2969
+ assert.ok(!selfCallee, 'Non-recursive function should not list itself as callee');
2970
+
2971
+ // But should still list actual callees
2972
+ assert.ok(ctx.callees.some(c => c.name === 'helper'), 'Should list helper as callee');
2973
+ assert.ok(ctx.callees.some(c => c.name === 'other'), 'Should list other as callee');
2974
+ } finally {
2975
+ fs.rmSync(tmpDir, { recursive: true, force: true });
2976
+ }
2977
+ });
2978
+
2979
+ it('should detect callees in single-line functions', () => {
2980
+ const tmpDir = path.join(require('os').tmpdir(), `ucn-test-single-line-${Date.now()}`);
2981
+ fs.mkdirSync(tmpDir, { recursive: true });
2982
+
2983
+ try {
2984
+ fs.writeFileSync(path.join(tmpDir, 'package.json'), '{"name": "test"}');
2985
+ fs.writeFileSync(path.join(tmpDir, 'lib.js'), `
2986
+ function singleLine() { return helper() + other(); }
2987
+ function helper() { return 1; }
2988
+ function other() { return 2; }
2989
+ module.exports = { singleLine, helper, other };
2990
+ `);
2991
+
2992
+ const index = new ProjectIndex(tmpDir);
2993
+ index.build('**/*.js', { quiet: true });
2994
+
2995
+ const ctx = index.context('singleLine');
2996
+
2997
+ // Single-line function should detect its callees
2998
+ assert.ok(ctx.callees.some(c => c.name === 'helper'), 'Should detect helper callee in single-line function');
2999
+ assert.ok(ctx.callees.some(c => c.name === 'other'), 'Should detect other callee in single-line function');
3000
+ // But should not include itself
3001
+ assert.ok(!ctx.callees.some(c => c.name === 'singleLine'), 'Should not include itself as callee');
3002
+ } finally {
3003
+ fs.rmSync(tmpDir, { recursive: true, force: true });
3004
+ }
3005
+ });
3006
+
3007
+ it('should list function as callee when actually recursive', () => {
3008
+ const tmpDir = path.join(require('os').tmpdir(), `ucn-test-recursive-${Date.now()}`);
3009
+ fs.mkdirSync(tmpDir, { recursive: true });
3010
+
3011
+ try {
3012
+ fs.writeFileSync(path.join(tmpDir, 'package.json'), '{"name": "test"}');
3013
+ fs.writeFileSync(path.join(tmpDir, 'lib.js'), `
3014
+ function factorial(n) {
3015
+ if (n <= 1) return 1;
3016
+ return n * factorial(n - 1);
3017
+ }
3018
+ module.exports = { factorial };
3019
+ `);
3020
+
3021
+ const index = new ProjectIndex(tmpDir);
3022
+ index.build('**/*.js', { quiet: true });
3023
+
3024
+ const ctx = index.context('factorial');
3025
+
3026
+ // Recursive function SHOULD appear as its own callee
3027
+ const selfCallee = ctx.callees.find(c => c.name === 'factorial');
3028
+ assert.ok(selfCallee, 'Recursive function should list itself as callee');
3029
+ } finally {
3030
+ fs.rmSync(tmpDir, { recursive: true, force: true });
3031
+ }
3032
+ });
3033
+ });
3034
+
3035
+ // ============================================================================
3036
+ // REGRESSION: Negative depth values should be clamped to 0
3037
+ // ============================================================================
3038
+
3039
+ describe('Regression: negative depth should be clamped to 0', () => {
3040
+ it('trace should work with negative depth (clamped to 0)', () => {
3041
+ const tmpDir = path.join(require('os').tmpdir(), `ucn-test-neg-depth-${Date.now()}`);
3042
+ fs.mkdirSync(tmpDir, { recursive: true });
3043
+
3044
+ try {
3045
+ fs.writeFileSync(path.join(tmpDir, 'package.json'), '{"name": "test"}');
3046
+ fs.writeFileSync(path.join(tmpDir, 'lib.js'), `
3047
+ function main() {
3048
+ return helper();
3049
+ }
3050
+ function helper() { return 42; }
3051
+ module.exports = { main, helper };
3052
+ `);
3053
+
3054
+ const index = new ProjectIndex(tmpDir);
3055
+ index.build('**/*.js', { quiet: true });
3056
+
3057
+ // Negative depth should be clamped to 0
3058
+ const trace = index.trace('main', { depth: -5 });
3059
+
3060
+ assert.ok(trace, 'Trace should return a result');
3061
+ assert.strictEqual(trace.maxDepth, 0, 'maxDepth should be clamped to 0');
3062
+ assert.strictEqual(trace.root, 'main', 'Root should be main');
3063
+ } finally {
3064
+ fs.rmSync(tmpDir, { recursive: true, force: true });
3065
+ }
3066
+ });
3067
+
3068
+ it('graph should work with negative depth (clamped to 0)', () => {
3069
+ const tmpDir = path.join(require('os').tmpdir(), `ucn-test-neg-graph-${Date.now()}`);
3070
+ fs.mkdirSync(tmpDir, { recursive: true });
3071
+
3072
+ try {
3073
+ fs.writeFileSync(path.join(tmpDir, 'package.json'), '{"name": "test"}');
3074
+ fs.writeFileSync(path.join(tmpDir, 'main.js'), `
3075
+ const { helper } = require('./helper');
3076
+ console.log(helper());
3077
+ `);
3078
+ fs.writeFileSync(path.join(tmpDir, 'helper.js'), `
3079
+ module.exports = { helper: () => 42 };
3080
+ `);
3081
+
3082
+ const index = new ProjectIndex(tmpDir);
3083
+ index.build('**/*.js', { quiet: true });
3084
+
3085
+ // Negative depth should be clamped to 0
3086
+ const graph = index.graph('main.js', { maxDepth: -10 });
3087
+
3088
+ assert.ok(graph.nodes.length > 0, 'Graph should have nodes');
3089
+ // With depth 0, should only show root and its direct imports (depth 0)
3090
+ assert.ok(graph.nodes.some(n => n.relativePath === 'main.js'), 'Should include main.js');
3091
+ } finally {
3092
+ fs.rmSync(tmpDir, { recursive: true, force: true });
3093
+ }
3094
+ });
3095
+ });
3096
+
3097
+ // ============================================================================
3098
+ // REGRESSION: Double dash separator should end flag processing
3099
+ // ============================================================================
3100
+
3101
+ describe('Regression: double dash separator for arguments', () => {
3102
+ it('should allow searching for flag-like strings after --', () => {
3103
+ const fixtureDir = path.join(__dirname, 'fixtures', 'javascript');
3104
+ const { execSync } = require('child_process');
3105
+ const ucnPath = path.join(__dirname, '..', 'ucn.js');
3106
+
3107
+ // This should NOT error with "Unknown flag"
3108
+ const output = execSync(`node ${ucnPath} ${fixtureDir} find -- --test`, {
3109
+ encoding: 'utf8'
3110
+ });
3111
+
3112
+ // Should show "no symbols found" rather than "unknown flag"
3113
+ assert.ok(!output.includes('Unknown flag'), 'Should not treat --test as flag after --');
3114
+ });
3115
+
3116
+ it('should process flags before -- normally', () => {
3117
+ const fixtureDir = path.join(__dirname, 'fixtures', 'javascript');
3118
+ const { execSync } = require('child_process');
3119
+ const ucnPath = path.join(__dirname, '..', 'ucn.js');
3120
+
3121
+ // Flags before -- should work
3122
+ const output = execSync(`node ${ucnPath} ${fixtureDir} find processData --json --`, {
3123
+ encoding: 'utf8'
3124
+ });
3125
+
3126
+ // Should be valid JSON
3127
+ assert.ok(output.startsWith('{'), 'Should output JSON when --json flag is before --');
3128
+ JSON.parse(output); // Should not throw
3129
+ });
3130
+ });
3131
+
3132
+ // ============================================================================
3133
+ // RELIABILITY IMPROVEMENTS: AST-based search filtering
3134
+ // ============================================================================
3135
+
3136
+ describe('Reliability: AST-based search filtering', () => {
3137
+ it('should filter out matches in comments with --code-only', () => {
3138
+ const tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), 'ucn-search-'));
3139
+ try {
3140
+ fs.writeFileSync(path.join(tmpDir, 'test.js'), `
3141
+ // This comment mentions fetchData
3142
+ const x = 'fetchData in string';
3143
+ const result = fetchData(); // trailing comment fetchData
3144
+ /* block comment
3145
+ fetchData here too */
3146
+ `);
3147
+ const index = new ProjectIndex(tmpDir);
3148
+ index.build('**/*.js', { quiet: true });
3149
+
3150
+ const results = index.search('fetchData', { codeOnly: true });
3151
+ const allMatches = results.flatMap(r => r.matches);
3152
+
3153
+ // Should only find the actual code call on line 4
3154
+ assert.strictEqual(allMatches.length, 1, 'Should find only 1 code match');
3155
+ assert.ok(allMatches[0].content.includes('const result = fetchData()'), 'Should find the actual call');
3156
+ } finally {
3157
+ fs.rmSync(tmpDir, { recursive: true, force: true });
3158
+ }
3159
+ });
3160
+
3161
+ it('should include template literal expressions as code', () => {
3162
+ const tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), 'ucn-search-template-'));
3163
+ try {
3164
+ fs.writeFileSync(path.join(tmpDir, 'test.js'), `
3165
+ const str = \`fetchData in template\`;
3166
+ const dynamic = \`Result: \${fetchData()}\`;
3167
+ `);
3168
+ const index = new ProjectIndex(tmpDir);
3169
+ index.build('**/*.js', { quiet: true });
3170
+
3171
+ const results = index.search('fetchData', { codeOnly: true });
3172
+ const allMatches = results.flatMap(r => r.matches);
3173
+
3174
+ // Should find the expression inside ${}, not the string literal
3175
+ assert.strictEqual(allMatches.length, 1, 'Should find 1 match in template expression');
3176
+ assert.ok(allMatches[0].content.includes('${fetchData()}'), 'Should find the template expression');
3177
+ } finally {
3178
+ fs.rmSync(tmpDir, { recursive: true, force: true });
3179
+ }
3180
+ });
3181
+ });
3182
+
3183
+ // ============================================================================
3184
+ // RELIABILITY IMPROVEMENTS: Stacktrace file matching
3185
+ // ============================================================================
3186
+
3187
+ describe('Reliability: Stacktrace file matching', () => {
3188
+ it('should parse various stack trace formats', () => {
3189
+ const tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), 'ucn-stack-'));
3190
+ try {
3191
+ fs.mkdirSync(path.join(tmpDir, 'src'));
3192
+ fs.writeFileSync(path.join(tmpDir, 'src', 'app.js'), `
3193
+ function processData(data) {
3194
+ throw new Error('test');
3195
+ }
3196
+ `);
3197
+ const index = new ProjectIndex(tmpDir);
3198
+ index.build('**/*.js', { quiet: true });
3199
+
3200
+ // Test Node.js format
3201
+ const nodeStack = index.parseStackTrace('at processData (src/app.js:3:11)');
3202
+ assert.strictEqual(nodeStack.frames.length, 1);
3203
+ assert.ok(nodeStack.frames[0].found);
3204
+
3205
+ // Test Firefox format
3206
+ const ffStack = index.parseStackTrace('processData@src/app.js:3:11');
3207
+ assert.strictEqual(ffStack.frames.length, 1);
3208
+ assert.ok(ffStack.frames[0].found);
3209
+
3210
+ // Test async format
3211
+ const asyncStack = index.parseStackTrace('at async processData (src/app.js:3:11)');
3212
+ assert.strictEqual(asyncStack.frames.length, 1);
3213
+ assert.ok(asyncStack.frames[0].found);
3214
+ } finally {
3215
+ fs.rmSync(tmpDir, { recursive: true, force: true });
3216
+ }
3217
+ });
3218
+
3219
+ it('should score path similarity and choose best match', () => {
3220
+ const tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), 'ucn-stack-sim-'));
3221
+ try {
3222
+ // Create files with similar names in different directories
3223
+ fs.mkdirSync(path.join(tmpDir, 'src', 'utils'), { recursive: true });
3224
+ fs.mkdirSync(path.join(tmpDir, 'lib', 'utils'), { recursive: true });
3225
+
3226
+ fs.writeFileSync(path.join(tmpDir, 'src', 'utils', 'helper.js'), `
3227
+ function helper() { console.log('src'); }
3228
+ `);
3229
+ fs.writeFileSync(path.join(tmpDir, 'lib', 'utils', 'helper.js'), `
3230
+ function helper() { console.log('lib'); }
3231
+ `);
3232
+
3233
+ const index = new ProjectIndex(tmpDir);
3234
+ index.build('**/*.js', { quiet: true });
3235
+
3236
+ // Should prefer more specific path
3237
+ const stack = index.parseStackTrace('at helper (src/utils/helper.js:2:10)');
3238
+ assert.strictEqual(stack.frames.length, 1);
3239
+ assert.ok(stack.frames[0].found);
3240
+ assert.ok(stack.frames[0].resolvedFile.includes('src/utils'), 'Should match src path');
3241
+ } finally {
3242
+ fs.rmSync(tmpDir, { recursive: true, force: true });
3243
+ }
3244
+ });
3245
+ });
3246
+
3247
+ // ============================================================================
3248
+ // RELIABILITY IMPROVEMENTS: Callback detection for deadcode
3249
+ // ============================================================================
3250
+
3251
+ describe('Reliability: Callback detection for deadcode', () => {
3252
+ it('should not report functions used as callbacks as dead', () => {
3253
+ const tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), 'ucn-deadcode-'));
3254
+ try {
3255
+ fs.writeFileSync(path.join(tmpDir, 'handlers.js'), `
3256
+ function handleClick(e) { console.log(e); }
3257
+ function mapItem(item) { return item.toUpperCase(); }
3258
+ function unusedFn() { return 'dead'; }
3259
+
3260
+ document.addEventListener('click', handleClick);
3261
+ const items = ['a', 'b'].map(mapItem);
3262
+ `);
3263
+ const index = new ProjectIndex(tmpDir);
3264
+ index.build('**/*.js', { quiet: true });
3265
+
3266
+ const dead = index.deadcode({ includeExported: true });
3267
+ const deadNames = dead.map(d => d.name);
3268
+
3269
+ assert.ok(!deadNames.includes('handleClick'), 'handleClick should not be dead (event handler)');
3270
+ assert.ok(!deadNames.includes('mapItem'), 'mapItem should not be dead (array callback)');
3271
+ assert.ok(deadNames.includes('unusedFn'), 'unusedFn should be dead');
3272
+ } finally {
3273
+ fs.rmSync(tmpDir, { recursive: true, force: true });
3274
+ }
3275
+ });
3276
+
3277
+ it('should not report re-exported functions as dead', () => {
3278
+ const tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), 'ucn-reexport-'));
3279
+ try {
3280
+ fs.writeFileSync(path.join(tmpDir, 'utils.js'), `
3281
+ export function formatDate(d) { return d.toString(); }
3282
+ export function unusedUtil() { return null; }
3283
+ `);
3284
+ fs.writeFileSync(path.join(tmpDir, 'index.js'), `
3285
+ export { formatDate } from './utils';
3286
+ `);
3287
+ const index = new ProjectIndex(tmpDir);
3288
+ index.build('**/*.js', { quiet: true });
3289
+
3290
+ const dead = index.deadcode({ includeExported: true });
3291
+ const deadNames = dead.map(d => d.name);
3292
+
3293
+ assert.ok(!deadNames.includes('formatDate'), 'formatDate should not be dead (re-exported)');
3294
+ // Note: unusedUtil might or might not be dead depending on export tracking
3295
+ } finally {
3296
+ fs.rmSync(tmpDir, { recursive: true, force: true });
3297
+ }
3298
+ });
3299
+ });
3300
+
3301
+ // ============================================================================
3302
+ // AST-BASED COMMENT/STRING DETECTION
3303
+ // ============================================================================
3304
+
3305
+ describe('AST-based Comment/String Detection', () => {
3306
+ it('detects inline comments correctly', () => {
3307
+ const tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), 'ucn-ast-test-'));
3308
+ try {
3309
+ fs.writeFileSync(path.join(tmpDir, 'test.js'), `
3310
+ const x = 5; // comment mentioning myFunc
3311
+ myFunc(); // this is a call
3312
+ // myFunc is mentioned here
3313
+ `);
3314
+ const index = new ProjectIndex(tmpDir);
3315
+ index.build('**/*.js', { quiet: true });
3316
+
3317
+ // isCommentOrStringAtPosition should detect the comment
3318
+ const content = fs.readFileSync(path.join(tmpDir, 'test.js'), 'utf-8');
3319
+ const filePath = path.join(tmpDir, 'test.js');
3320
+
3321
+ // Line 2: "const x = 5; // comment mentioning myFunc"
3322
+ // Column 0 should be code, column after // should be comment
3323
+ assert.strictEqual(
3324
+ index.isCommentOrStringAtPosition(content, 2, 0, filePath),
3325
+ false,
3326
+ 'Start of line 2 should be code'
3327
+ );
3328
+ assert.strictEqual(
3329
+ index.isCommentOrStringAtPosition(content, 2, 14, filePath),
3330
+ true,
3331
+ 'Inside comment on line 2 should be comment'
3332
+ );
3333
+
3334
+ // Line 4: "// myFunc is mentioned here" - entire line is comment
3335
+ assert.strictEqual(
3336
+ index.isCommentOrStringAtPosition(content, 4, 0, filePath),
3337
+ true,
3338
+ 'Comment-only line should be comment'
3339
+ );
3340
+ } finally {
3341
+ fs.rmSync(tmpDir, { recursive: true, force: true });
3342
+ }
3343
+ });
3344
+
3345
+ it('detects string literals correctly', () => {
3346
+ const tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), 'ucn-ast-test-'));
3347
+ try {
3348
+ fs.writeFileSync(path.join(tmpDir, 'test.js'), `
3349
+ const msg = "function call()";
3350
+ const real = call();
3351
+ `);
3352
+ const index = new ProjectIndex(tmpDir);
3353
+ index.build('**/*.js', { quiet: true });
3354
+
3355
+ const content = fs.readFileSync(path.join(tmpDir, 'test.js'), 'utf-8');
3356
+ const filePath = path.join(tmpDir, 'test.js');
3357
+
3358
+ // Line 2: const msg = "function call()";
3359
+ // "function" inside the string should be detected as string
3360
+ assert.strictEqual(
3361
+ index.isCommentOrStringAtPosition(content, 2, 13, filePath),
3362
+ true,
3363
+ 'Inside string literal should be string'
3364
+ );
3365
+
3366
+ // Line 3: const real = call();
3367
+ // "call" should be code
3368
+ assert.strictEqual(
3369
+ index.isCommentOrStringAtPosition(content, 3, 13, filePath),
3370
+ false,
3371
+ 'Function call should be code'
3372
+ );
3373
+ } finally {
3374
+ fs.rmSync(tmpDir, { recursive: true, force: true });
3375
+ }
3376
+ });
3377
+
3378
+ it('handles template literals with expressions', () => {
3379
+ const tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), 'ucn-ast-test-'));
3380
+ try {
3381
+ fs.writeFileSync(path.join(tmpDir, 'test.js'), 'const x = `value is ${fn()} here`;');
3382
+ const index = new ProjectIndex(tmpDir);
3383
+ index.build('**/*.js', { quiet: true });
3384
+
3385
+ const content = fs.readFileSync(path.join(tmpDir, 'test.js'), 'utf-8');
3386
+ const filePath = path.join(tmpDir, 'test.js');
3387
+
3388
+ // Inside template expression ${fn()} - "fn" should be code
3389
+ assert.strictEqual(
3390
+ index.isCommentOrStringAtPosition(content, 1, 22, filePath),
3391
+ false,
3392
+ 'Inside template expression should be code'
3393
+ );
3394
+
3395
+ // Inside template string but outside expression - should be string
3396
+ assert.strictEqual(
3397
+ index.isCommentOrStringAtPosition(content, 1, 12, filePath),
3398
+ true,
3399
+ 'Inside template string should be string'
3400
+ );
3401
+ } finally {
3402
+ fs.rmSync(tmpDir, { recursive: true, force: true });
3403
+ }
3404
+ });
3405
+
3406
+ it('isInsideStringAST correctly identifies names in strings vs code', () => {
3407
+ const tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), 'ucn-ast-test-'));
3408
+ try {
3409
+ fs.writeFileSync(path.join(tmpDir, 'test.js'), `
3410
+ const msg = "call myFunc here";
3411
+ myFunc();
3412
+ `);
3413
+ const index = new ProjectIndex(tmpDir);
3414
+ index.build('**/*.js', { quiet: true });
3415
+
3416
+ const content = fs.readFileSync(path.join(tmpDir, 'test.js'), 'utf-8');
3417
+ const filePath = path.join(tmpDir, 'test.js');
3418
+ const lines = content.split('\n');
3419
+
3420
+ // Line 2: myFunc appears inside string - should return true
3421
+ assert.strictEqual(
3422
+ index.isInsideStringAST(content, 2, lines[1], 'myFunc', filePath),
3423
+ true,
3424
+ 'myFunc on line 2 is inside string'
3425
+ );
3426
+
3427
+ // Line 3: myFunc appears as code - should return false
3428
+ assert.strictEqual(
3429
+ index.isInsideStringAST(content, 3, lines[2], 'myFunc', filePath),
3430
+ false,
3431
+ 'myFunc on line 3 is code'
3432
+ );
3433
+ } finally {
3434
+ fs.rmSync(tmpDir, { recursive: true, force: true });
3435
+ }
3436
+ });
3437
+
3438
+ it('classifyUsageAST correctly classifies calls and definitions', () => {
3439
+ const tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), 'ucn-ast-test-'));
3440
+ try {
3441
+ fs.writeFileSync(path.join(tmpDir, 'test.js'), `
3442
+ function myFunc() {}
3443
+ myFunc();
3444
+ import { other } from './other';
3445
+ `);
3446
+ const index = new ProjectIndex(tmpDir);
3447
+ index.build('**/*.js', { quiet: true });
3448
+
3449
+ const content = fs.readFileSync(path.join(tmpDir, 'test.js'), 'utf-8');
3450
+ const filePath = path.join(tmpDir, 'test.js');
3451
+
3452
+ // Line 2: function definition
3453
+ assert.strictEqual(
3454
+ index.classifyUsageAST(content, 2, 'myFunc', filePath),
3455
+ 'definition',
3456
+ 'Function declaration should be classified as definition'
3457
+ );
3458
+
3459
+ // Line 3: function call
3460
+ assert.strictEqual(
3461
+ index.classifyUsageAST(content, 3, 'myFunc', filePath),
3462
+ 'call',
3463
+ 'Function call should be classified as call'
3464
+ );
3465
+
3466
+ // Line 4: import
3467
+ assert.strictEqual(
3468
+ index.classifyUsageAST(content, 4, 'other', filePath),
3469
+ 'import',
3470
+ 'Import should be classified as import'
3471
+ );
3472
+ } finally {
3473
+ fs.rmSync(tmpDir, { recursive: true, force: true });
3474
+ }
3475
+ });
3476
+ });
3477
+
3478
+ describe('Cache Performance Optimizations', () => {
3479
+ it('getCachedCalls uses mtime for fast cache validation', () => {
3480
+ const tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), 'ucn-cache-perf-'));
3481
+ try {
3482
+ fs.writeFileSync(path.join(tmpDir, 'test.js'), `
3483
+ function foo() { bar(); }
3484
+ function bar() { return 1; }
3485
+ `);
3486
+ const index = new ProjectIndex(tmpDir);
3487
+ index.build('**/*.js', { quiet: true });
3488
+
3489
+ const filePath = path.join(tmpDir, 'test.js');
3490
+
3491
+ // First call - should parse
3492
+ const calls1 = index.getCachedCalls(filePath);
3493
+ assert.ok(calls1, 'First call should return calls');
3494
+ assert.ok(calls1.length > 0, 'Should find calls');
3495
+
3496
+ // Check cache entry has mtime
3497
+ const cached = index.callsCache.get(filePath);
3498
+ assert.ok(cached, 'Cache entry should exist');
3499
+ assert.ok(cached.mtime, 'Cache should have mtime');
3500
+ assert.ok(cached.hash, 'Cache should have hash');
3501
+
3502
+ // Second call - should use mtime cache (no reparse)
3503
+ const calls2 = index.getCachedCalls(filePath);
3504
+ assert.deepStrictEqual(calls2, calls1, 'Second call should return same result');
3505
+ } finally {
3506
+ fs.rmSync(tmpDir, { recursive: true, force: true });
3507
+ }
3508
+ });
3509
+
3510
+ it('getCachedCalls with includeContent avoids double file read', () => {
3511
+ const tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), 'ucn-cache-perf-'));
3512
+ try {
3513
+ fs.writeFileSync(path.join(tmpDir, 'test.js'), `
3514
+ function foo() { bar(); }
3515
+ function bar() { return 1; }
3516
+ `);
3517
+ const index = new ProjectIndex(tmpDir);
3518
+ index.build('**/*.js', { quiet: true });
3519
+
3520
+ const filePath = path.join(tmpDir, 'test.js');
3521
+
3522
+ // Call with includeContent
3523
+ const result = index.getCachedCalls(filePath, { includeContent: true });
3524
+ assert.ok(result, 'Should return result');
3525
+ assert.ok(result.calls, 'Should have calls');
3526
+ assert.ok(result.content, 'Should have content');
3527
+ assert.ok(result.content.includes('function foo'), 'Content should be the file');
3528
+ } finally {
3529
+ fs.rmSync(tmpDir, { recursive: true, force: true });
3530
+ }
3531
+ });
3532
+
3533
+ it('callsCache is persisted to disk and restored', () => {
3534
+ const tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), 'ucn-cache-persist-'));
3535
+ try {
3536
+ fs.writeFileSync(path.join(tmpDir, 'test.js'), `
3537
+ function processData() {
3538
+ helper();
3539
+ console.log('done');
3540
+ }
3541
+ function helper() { return 42; }
3542
+ `);
3543
+ // Build and populate cache
3544
+ const index1 = new ProjectIndex(tmpDir);
3545
+ index1.build('**/*.js', { quiet: true });
3546
+
3547
+ // Trigger callsCache population
3548
+ const filePath = path.join(tmpDir, 'test.js');
3549
+ index1.getCachedCalls(filePath);
3550
+
3551
+ // Verify callsCache is populated
3552
+ assert.ok(index1.callsCache.size > 0, 'callsCache should be populated');
3553
+
3554
+ // Save cache
3555
+ const cachePath = path.join(tmpDir, 'test-cache.json');
3556
+ index1.saveCache(cachePath);
3557
+
3558
+ // Verify cache file has callsCache
3559
+ const cacheData = JSON.parse(fs.readFileSync(cachePath, 'utf-8'));
3560
+ assert.strictEqual(cacheData.version, 2, 'Cache version should be 2');
3561
+ assert.ok(Array.isArray(cacheData.callsCache), 'Cache should have callsCache array');
3562
+ assert.ok(cacheData.callsCache.length > 0, 'callsCache should have entries');
3563
+
3564
+ // Load in new instance
3565
+ const index2 = new ProjectIndex(tmpDir);
3566
+ const loaded = index2.loadCache(cachePath);
3567
+ assert.ok(loaded, 'Cache should load successfully');
3568
+
3569
+ // Verify callsCache is restored
3570
+ assert.ok(index2.callsCache.size > 0, 'callsCache should be restored');
3571
+
3572
+ // Verify calls are usable without reparsing
3573
+ const calls = index2.getCachedCalls(filePath);
3574
+ assert.ok(calls, 'Should get calls from restored cache');
3575
+ assert.ok(calls.some(c => c.name === 'helper'), 'Should find helper call');
3576
+ } finally {
3577
+ fs.rmSync(tmpDir, { recursive: true, force: true });
3578
+ }
3579
+ });
3580
+
3581
+ it('findCallers is fast after cache load (no reparse)', () => {
3582
+ const tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), 'ucn-cache-perf-'));
3583
+ try {
3584
+ // Create multiple files
3585
+ for (let i = 0; i < 10; i++) {
3586
+ fs.writeFileSync(path.join(tmpDir, `file${i}.js`), `
3587
+ function caller${i}() { helper(); }
3588
+ `);
3589
+ }
3590
+ fs.writeFileSync(path.join(tmpDir, 'helper.js'), `
3591
+ function helper() { return 42; }
3592
+ `);
3593
+
3594
+ // Build and warm up cache
3595
+ const index1 = new ProjectIndex(tmpDir);
3596
+ index1.build('**/*.js', { quiet: true });
3597
+
3598
+ // Time first findCallers (populates callsCache)
3599
+ const start1 = Date.now();
3600
+ const callers1 = index1.findCallers('helper');
3601
+ const time1 = Date.now() - start1;
3602
+
3603
+ // Save cache
3604
+ const cachePath = path.join(tmpDir, 'perf-cache.json');
3605
+ index1.saveCache(cachePath);
3606
+
3607
+ // Load in new instance
3608
+ const index2 = new ProjectIndex(tmpDir);
3609
+ index2.loadCache(cachePath);
3610
+
3611
+ // Time findCallers after cache load
3612
+ const start2 = Date.now();
3613
+ const callers2 = index2.findCallers('helper');
3614
+ const time2 = Date.now() - start2;
3615
+
3616
+ // Verify results are same
3617
+ assert.strictEqual(callers1.length, callers2.length, 'Same number of callers');
3618
+ assert.strictEqual(callers1.length, 10, 'Should find 10 callers');
3619
+
3620
+ // Cache-loaded should be reasonably fast (not doing full reparse)
3621
+ // Note: First call might be faster due to mtime check, second call uses persisted data
3622
+ assert.ok(time2 < time1 * 3 || time2 < 100,
3623
+ `Cache-loaded findCallers (${time2}ms) should not be much slower than warm (${time1}ms)`);
3624
+ } finally {
3625
+ fs.rmSync(tmpDir, { recursive: true, force: true });
3626
+ }
3627
+ });
3628
+
3629
+ it('mtime change triggers reparse but hash match skips reparse', () => {
3630
+ const tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), 'ucn-cache-mtime-'));
3631
+ try {
3632
+ const filePath = path.join(tmpDir, 'test.js');
3633
+ fs.writeFileSync(filePath, `function foo() { bar(); }`);
3634
+
3635
+ const index = new ProjectIndex(tmpDir);
3636
+ index.build('**/*.js', { quiet: true });
3637
+
3638
+ // Get initial cache
3639
+ index.getCachedCalls(filePath);
3640
+ const cached1 = index.callsCache.get(filePath);
3641
+ const originalMtime = cached1.mtime;
3642
+ const originalHash = cached1.hash;
3643
+
3644
+ // Touch file (change mtime but not content)
3645
+ const now = new Date();
3646
+ fs.utimesSync(filePath, now, now);
3647
+
3648
+ // Get calls again - should update mtime but not reparse (hash matches)
3649
+ index.getCachedCalls(filePath);
3650
+ const cached2 = index.callsCache.get(filePath);
3651
+
3652
+ assert.notStrictEqual(cached2.mtime, originalMtime, 'mtime should be updated');
3653
+ assert.strictEqual(cached2.hash, originalHash, 'hash should be same (content unchanged)');
3654
+ } finally {
3655
+ fs.rmSync(tmpDir, { recursive: true, force: true });
3656
+ }
3657
+ });
3658
+ });
3659
+
3660
+ console.log('UCN v3 Test Suite');
3661
+ console.log('Run with: node --test test/parser.test.js');