ucn 3.8.11 → 3.8.12

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.
@@ -26,12 +26,13 @@ function traverseTree(node, callback, options) {
26
26
  * @param {string} code - Original source code
27
27
  * @returns {{ startLine: number, endLine: number, indent: number }}
28
28
  */
29
- function nodeToLocation(node, code) {
29
+ function nodeToLocation(node, codeOrLines) {
30
30
  const startLine = node.startPosition.row + 1; // tree-sitter is 0-indexed
31
31
  const endLine = node.endPosition.row + 1;
32
32
 
33
33
  // Calculate indent from start of line
34
- const lines = code.split('\n');
34
+ // Accept pre-split lines array to avoid repeated code.split('\n')
35
+ const lines = Array.isArray(codeOrLines) ? codeOrLines : codeOrLines.split('\n');
35
36
  const firstLine = lines[node.startPosition.row] || '';
36
37
  const indentMatch = firstLine.match(/^(\s*)/);
37
38
  const indent = indentMatch ? indentMatch[1].length : 0;
@@ -235,8 +236,8 @@ function parseJavaParam(param, info) {
235
236
  * @param {number} startLine - 1-indexed line number of the function/class
236
237
  * @returns {string|null} First line of docstring or null
237
238
  */
238
- function extractJSDocstring(code, startLine) {
239
- const lines = code.split('\n');
239
+ function extractJSDocstring(codeOrLines, startLine) {
240
+ const lines = Array.isArray(codeOrLines) ? codeOrLines : codeOrLines.split('\n');
240
241
  const lineIndex = startLine - 1;
241
242
  if (lineIndex <= 0) return null;
242
243
 
@@ -280,8 +281,8 @@ function extractJSDocstring(code, startLine) {
280
281
  * @param {number} defLine - 1-indexed line number of the def/class (not decorator)
281
282
  * @returns {string|null} First line of docstring or null
282
283
  */
283
- function extractPythonDocstring(code, defLine) {
284
- const lines = code.split('\n');
284
+ function extractPythonDocstring(codeOrLines, defLine) {
285
+ const lines = Array.isArray(codeOrLines) ? codeOrLines : codeOrLines.split('\n');
285
286
  // Python docstring is INSIDE the function, on lines after the def:
286
287
  let i = defLine; // Start after the def line (defLine is 1-indexed)
287
288
  // Skip to find the first non-empty line inside the function
@@ -316,8 +317,8 @@ function extractPythonDocstring(code, defLine) {
316
317
  * @param {number} startLine - 1-indexed line number of the function
317
318
  * @returns {string|null} First line of doc comment or null
318
319
  */
319
- function extractGoDocstring(code, startLine) {
320
- const lines = code.split('\n');
320
+ function extractGoDocstring(codeOrLines, startLine) {
321
+ const lines = Array.isArray(codeOrLines) ? codeOrLines : codeOrLines.split('\n');
321
322
  const lineIndex = startLine - 1;
322
323
  if (lineIndex <= 0) return null;
323
324
 
@@ -349,8 +350,8 @@ function extractGoDocstring(code, startLine) {
349
350
  * @param {number} startLine - 1-indexed line number of the item
350
351
  * @returns {string|null} First line of doc comment or null
351
352
  */
352
- function extractRustDocstring(code, startLine) {
353
- const lines = code.split('\n');
353
+ function extractRustDocstring(codeOrLines, startLine) {
354
+ const lines = Array.isArray(codeOrLines) ? codeOrLines : codeOrLines.split('\n');
354
355
  const lineIndex = startLine - 1;
355
356
  if (lineIndex <= 0) return null;
356
357
 
@@ -515,8 +516,86 @@ function findMatchesWithASTFilter(content, term, parser, options = {}) {
515
516
  return matches;
516
517
  }
517
518
 
519
+ /**
520
+ * Single-entry cache for flat node lists.
521
+ * During indexFile(), the same tree is traversed 5+ times (findFunctions,
522
+ * findClasses, findStateObjects, findImports, findExports). Building a flat
523
+ * list once and iterating it for each pass eliminates repeated recursive
524
+ * traversal overhead (namedChild object creation, function call overhead).
525
+ */
526
+ let _cachedRootNode = null;
527
+ let _cachedNodeList = null;
528
+ let _cachedSubtreeEnds = null;
529
+
530
+ function _buildNodeList(rootNode) {
531
+ const nodes = [];
532
+ const subtreeEnds = [];
533
+ const stack = [rootNode];
534
+ // Iterative DFS with subtreeEnd tracking
535
+ // We use a post-processing step to fill subtreeEnds
536
+ function collect(node) {
537
+ const idx = nodes.length;
538
+ nodes.push(node);
539
+ subtreeEnds.push(0);
540
+ for (let i = 0; i < node.namedChildCount; i++) {
541
+ collect(node.namedChild(i));
542
+ }
543
+ subtreeEnds[idx] = nodes.length;
544
+ }
545
+ collect(rootNode);
546
+ return { nodes, subtreeEnds };
547
+ }
548
+
549
+ /**
550
+ * Get or build a cached flat node list for the given tree.
551
+ * Returns { nodes: SyntaxNode[], subtreeEnds: number[] }.
552
+ * subtreeEnds[i] is the index past the last descendant of nodes[i],
553
+ * enabling O(1) subtree skipping (for 'return false' semantics).
554
+ */
555
+ function getCachedNodeList(rootNode) {
556
+ if (rootNode === _cachedRootNode && _cachedNodeList) {
557
+ return { nodes: _cachedNodeList, subtreeEnds: _cachedSubtreeEnds };
558
+ }
559
+ const { nodes, subtreeEnds } = _buildNodeList(rootNode);
560
+ _cachedRootNode = rootNode;
561
+ _cachedNodeList = nodes;
562
+ _cachedSubtreeEnds = subtreeEnds;
563
+ return { nodes, subtreeEnds };
564
+ }
565
+
566
+ /**
567
+ * Traverse a tree-sitter AST using a cached flat node list.
568
+ * Semantically equivalent to traverseTree() but ~3x faster when the same
569
+ * tree is traversed multiple times (which happens 5+ times per file during build).
570
+ * Supports 'return false' to skip a node's entire subtree.
571
+ *
572
+ * NOTE: Does not support onLeave callbacks. Use traverseTree() for those.
573
+ */
574
+ function traverseTreeCached(rootNode, callback) {
575
+ const { nodes, subtreeEnds } = getCachedNodeList(rootNode);
576
+ for (let i = 0; i < nodes.length; ) {
577
+ if (callback(nodes[i]) === false) {
578
+ i = subtreeEnds[i];
579
+ } else {
580
+ i++;
581
+ }
582
+ }
583
+ }
584
+
585
+ /**
586
+ * Clear the cached node list (call when the tree changes).
587
+ */
588
+ function clearNodeListCache() {
589
+ _cachedRootNode = null;
590
+ _cachedNodeList = null;
591
+ _cachedSubtreeEnds = null;
592
+ }
593
+
518
594
  module.exports = {
519
595
  traverseTree,
596
+ traverseTreeCached,
597
+ getCachedNodeList,
598
+ clearNodeListCache,
520
599
  nodeToLocation,
521
600
  extractParams,
522
601
  parseStructuredParams,
package/mcp/server.js CHANGED
@@ -317,6 +317,7 @@ server.registerTool(
317
317
  // Wrap toolResult to auto-inject command + maxChars from this request
318
318
  const tr = (text) => toolResult(text, command, maxChars);
319
319
 
320
+ let index = null; // Track for post-command cache save
320
321
  try {
321
322
  switch (command) {
322
323
 
@@ -327,7 +328,7 @@ server.registerTool(
327
328
  // ── Commands using shared executor ─────────────────────────
328
329
 
329
330
  case 'about': {
330
- const index = getIndex(project_dir, ep);
331
+ index = getIndex(project_dir, ep);
331
332
  const { ok, result, error } = execute(index, 'about', ep);
332
333
  if (!ok) return tr(error); // soft error — won't kill sibling calls
333
334
  return tr(output.formatAbout(result, {
@@ -338,7 +339,7 @@ server.registerTool(
338
339
  }
339
340
 
340
341
  case 'context': {
341
- const index = getIndex(project_dir, ep);
342
+ index = getIndex(project_dir, ep);
342
343
  const { ok, result: ctx, error } = execute(index, 'context', ep);
343
344
  if (!ok) return tr(error); // context uses soft error (not toolError)
344
345
  const { text, expandable } = output.formatContext(ctx, {
@@ -350,14 +351,14 @@ server.registerTool(
350
351
  }
351
352
 
352
353
  case 'impact': {
353
- const index = getIndex(project_dir, ep);
354
+ index = getIndex(project_dir, ep);
354
355
  const { ok, result, error } = execute(index, 'impact', ep);
355
356
  if (!ok) return tr(error); // soft error
356
357
  return tr(output.formatImpact(result));
357
358
  }
358
359
 
359
360
  case 'blast': {
360
- const index = getIndex(project_dir, ep);
361
+ index = getIndex(project_dir, ep);
361
362
  const { ok, result, error } = execute(index, 'blast', ep);
362
363
  if (!ok) return tr(error); // soft error
363
364
  return tr(output.formatBlast(result, {
@@ -366,14 +367,14 @@ server.registerTool(
366
367
  }
367
368
 
368
369
  case 'smart': {
369
- const index = getIndex(project_dir, ep);
370
+ index = getIndex(project_dir, ep);
370
371
  const { ok, result, error } = execute(index, 'smart', ep);
371
372
  if (!ok) return tr(error); // soft error
372
373
  return tr(output.formatSmart(result));
373
374
  }
374
375
 
375
376
  case 'trace': {
376
- const index = getIndex(project_dir, ep);
377
+ index = getIndex(project_dir, ep);
377
378
  const { ok, result, error } = execute(index, 'trace', ep);
378
379
  if (!ok) return tr(error); // soft error
379
380
  return tr(output.formatTrace(result, {
@@ -383,7 +384,7 @@ server.registerTool(
383
384
  }
384
385
 
385
386
  case 'reverse_trace': {
386
- const index = getIndex(project_dir, ep);
387
+ index = getIndex(project_dir, ep);
387
388
  const { ok, result, error } = execute(index, 'reverseTrace', ep);
388
389
  if (!ok) return tr(error);
389
390
  return tr(output.formatReverseTrace(result, {
@@ -392,7 +393,7 @@ server.registerTool(
392
393
  }
393
394
 
394
395
  case 'example': {
395
- const index = getIndex(project_dir, ep);
396
+ index = getIndex(project_dir, ep);
396
397
  const { ok, result, error } = execute(index, 'example', ep);
397
398
  if (!ok) return tr(error);
398
399
  if (!result) return tr(`No usage examples found for "${ep.name}".`);
@@ -400,7 +401,7 @@ server.registerTool(
400
401
  }
401
402
 
402
403
  case 'related': {
403
- const index = getIndex(project_dir, ep);
404
+ index = getIndex(project_dir, ep);
404
405
  const { ok, result, error } = execute(index, 'related', ep);
405
406
  if (!ok) return tr(error);
406
407
  if (!result) return tr(`Symbol "${ep.name}" not found.`);
@@ -413,7 +414,7 @@ server.registerTool(
413
414
  // ── Finding Code ────────────────────────────────────────────
414
415
 
415
416
  case 'find': {
416
- const index = getIndex(project_dir, ep);
417
+ index = getIndex(project_dir, ep);
417
418
  const { ok, result, error, note } = execute(index, 'find', ep);
418
419
  if (!ok) return tr(error); // soft error
419
420
  let text = output.formatFind(result, ep.name, ep.top);
@@ -422,7 +423,7 @@ server.registerTool(
422
423
  }
423
424
 
424
425
  case 'usages': {
425
- const index = getIndex(project_dir, ep);
426
+ index = getIndex(project_dir, ep);
426
427
  const { ok, result, error, note } = execute(index, 'usages', ep);
427
428
  if (!ok) return tr(error); // soft error
428
429
  let text = output.formatUsages(result, ep.name);
@@ -431,7 +432,7 @@ server.registerTool(
431
432
  }
432
433
 
433
434
  case 'toc': {
434
- const index = getIndex(project_dir, ep);
435
+ index = getIndex(project_dir, ep);
435
436
  const { ok, result, error, note } = execute(index, 'toc', ep);
436
437
  if (!ok) return tr(error); // soft error
437
438
  let text = output.formatToc(result, {
@@ -442,7 +443,7 @@ server.registerTool(
442
443
  }
443
444
 
444
445
  case 'search': {
445
- const index = getIndex(project_dir, ep);
446
+ index = getIndex(project_dir, ep);
446
447
  const { ok, result, error, structural } = execute(index, 'search', ep);
447
448
  if (!ok) return tr(error); // soft error
448
449
  if (structural) {
@@ -452,21 +453,21 @@ server.registerTool(
452
453
  }
453
454
 
454
455
  case 'tests': {
455
- const index = getIndex(project_dir, ep);
456
+ index = getIndex(project_dir, ep);
456
457
  const { ok, result, error } = execute(index, 'tests', ep);
457
458
  if (!ok) return tr(error); // soft error
458
459
  return tr(output.formatTests(result, ep.name));
459
460
  }
460
461
 
461
462
  case 'affected_tests': {
462
- const index = getIndex(project_dir, ep);
463
+ index = getIndex(project_dir, ep);
463
464
  const { ok, result, error } = execute(index, 'affectedTests', ep);
464
465
  if (!ok) return tr(error);
465
466
  return tr(output.formatAffectedTests(result, { all: ep.all }));
466
467
  }
467
468
 
468
469
  case 'deadcode': {
469
- const index = getIndex(project_dir, ep);
470
+ index = getIndex(project_dir, ep);
470
471
  const { ok, result, error, note } = execute(index, 'deadcode', ep);
471
472
  if (!ok) return tr(error); // soft error
472
473
  const dcNote = note;
@@ -480,7 +481,7 @@ server.registerTool(
480
481
  }
481
482
 
482
483
  case 'entrypoints': {
483
- const index = getIndex(project_dir, ep);
484
+ index = getIndex(project_dir, ep);
484
485
  const { ok, result, error } = execute(index, 'entrypoints', ep);
485
486
  if (!ok) return tr(error);
486
487
  return tr(output.formatEntrypoints(result));
@@ -489,28 +490,28 @@ server.registerTool(
489
490
  // ── File Dependencies ───────────────────────────────────────
490
491
 
491
492
  case 'imports': {
492
- const index = getIndex(project_dir, ep);
493
+ index = getIndex(project_dir, ep);
493
494
  const { ok, result, error } = execute(index, 'imports', ep);
494
495
  if (!ok) return tr(error); // soft error
495
496
  return tr(output.formatImports(result, ep.file));
496
497
  }
497
498
 
498
499
  case 'exporters': {
499
- const index = getIndex(project_dir, ep);
500
+ index = getIndex(project_dir, ep);
500
501
  const { ok, result, error } = execute(index, 'exporters', ep);
501
502
  if (!ok) return tr(error); // soft error
502
503
  return tr(output.formatExporters(result, ep.file));
503
504
  }
504
505
 
505
506
  case 'file_exports': {
506
- const index = getIndex(project_dir, ep);
507
+ index = getIndex(project_dir, ep);
507
508
  const { ok, result, error } = execute(index, 'fileExports', ep);
508
509
  if (!ok) return tr(error); // soft error
509
510
  return tr(output.formatFileExports(result, ep.file));
510
511
  }
511
512
 
512
513
  case 'graph': {
513
- const index = getIndex(project_dir, ep);
514
+ index = getIndex(project_dir, ep);
514
515
  const { ok, result, error } = execute(index, 'graph', ep);
515
516
  if (!ok) return tr(error); // soft error
516
517
  return tr(output.formatGraph(result, {
@@ -522,7 +523,7 @@ server.registerTool(
522
523
  }
523
524
 
524
525
  case 'circular_deps': {
525
- const index = getIndex(project_dir, ep);
526
+ index = getIndex(project_dir, ep);
526
527
  const { ok, result, error } = execute(index, 'circularDeps', ep);
527
528
  if (!ok) return tr(error);
528
529
  return tr(output.formatCircularDeps(result));
@@ -531,21 +532,21 @@ server.registerTool(
531
532
  // ── Refactoring ─────────────────────────────────────────────
532
533
 
533
534
  case 'verify': {
534
- const index = getIndex(project_dir, ep);
535
+ index = getIndex(project_dir, ep);
535
536
  const { ok, result, error } = execute(index, 'verify', ep);
536
537
  if (!ok) return tr(error); // soft error
537
538
  return tr(output.formatVerify(result));
538
539
  }
539
540
 
540
541
  case 'plan': {
541
- const index = getIndex(project_dir, ep);
542
+ index = getIndex(project_dir, ep);
542
543
  const { ok, result, error } = execute(index, 'plan', ep);
543
544
  if (!ok) return tr(error); // soft error
544
545
  return tr(output.formatPlan(result));
545
546
  }
546
547
 
547
548
  case 'diff_impact': {
548
- const index = getIndex(project_dir, ep);
549
+ index = getIndex(project_dir, ep);
549
550
  const { ok, result, error } = execute(index, 'diffImpact', ep);
550
551
  if (!ok) return tr(error); // soft error — e.g. "not a git repo"
551
552
  return tr(output.formatDiffImpact(result, { all: ep.all }));
@@ -554,21 +555,21 @@ server.registerTool(
554
555
  // ── Other ───────────────────────────────────────────────────
555
556
 
556
557
  case 'typedef': {
557
- const index = getIndex(project_dir, ep);
558
+ index = getIndex(project_dir, ep);
558
559
  const { ok, result, error } = execute(index, 'typedef', ep);
559
560
  if (!ok) return tr(error); // soft error
560
561
  return tr(output.formatTypedef(result, ep.name));
561
562
  }
562
563
 
563
564
  case 'stacktrace': {
564
- const index = getIndex(project_dir, ep);
565
+ index = getIndex(project_dir, ep);
565
566
  const { ok, result, error } = execute(index, 'stacktrace', ep);
566
567
  if (!ok) return tr(error); // soft error
567
568
  return tr(output.formatStackTrace(result));
568
569
  }
569
570
 
570
571
  case 'api': {
571
- const index = getIndex(project_dir, ep);
572
+ index = getIndex(project_dir, ep);
572
573
  const { ok, result, error, note } = execute(index, 'api', ep);
573
574
  if (!ok) return tr(error); // soft error
574
575
  let apiText = output.formatApi(result, ep.file || '.');
@@ -577,7 +578,7 @@ server.registerTool(
577
578
  }
578
579
 
579
580
  case 'stats': {
580
- const index = getIndex(project_dir, ep);
581
+ index = getIndex(project_dir, ep);
581
582
  const { ok, result, error } = execute(index, 'stats', ep);
582
583
  if (!ok) return tr(error); // soft error
583
584
  return tr(output.formatStats(result, { top: ep.top || 0 }));
@@ -588,7 +589,7 @@ server.registerTool(
588
589
  case 'fn': {
589
590
  const err = requireName(ep.name);
590
591
  if (err) return err;
591
- const index = getIndex(project_dir, ep);
592
+ index = getIndex(project_dir, ep);
592
593
  const { ok, result, error } = execute(index, 'fn', ep);
593
594
  if (!ok) return tr(error); // soft error
594
595
  // MCP path security: validate all result files are within project root
@@ -606,7 +607,7 @@ server.registerTool(
606
607
  if (ep.maxLines !== undefined && (!Number.isInteger(ep.maxLines) || ep.maxLines < 1)) {
607
608
  return toolError(`Invalid max_lines: ${ep.maxLines}. Must be a positive integer.`);
608
609
  }
609
- const index = getIndex(project_dir, ep);
610
+ index = getIndex(project_dir, ep);
610
611
  const { ok, result, error } = execute(index, 'class', ep);
611
612
  if (!ok) return tr(error); // soft error (class not found)
612
613
  // MCP path security: validate all result files are within project root
@@ -619,7 +620,7 @@ server.registerTool(
619
620
  }
620
621
 
621
622
  case 'lines': {
622
- const index = getIndex(project_dir, ep);
623
+ index = getIndex(project_dir, ep);
623
624
  const { ok, result, error } = execute(index, 'lines', ep);
624
625
  if (!ok) return tr(error); // soft error
625
626
  // MCP path security: validate file is within project root
@@ -632,7 +633,7 @@ server.registerTool(
632
633
  if (ep.item === undefined || ep.item === null) {
633
634
  return toolError('Item number is required (e.g. item=1).');
634
635
  }
635
- const index = getIndex(project_dir, ep);
636
+ index = getIndex(project_dir, ep);
636
637
  const lookup = expandCacheInstance.lookup(index.root, ep.item);
637
638
  const { ok, result, error } = execute(index, 'expand', {
638
639
  match: lookup.match, itemNum: ep.item,
@@ -648,6 +649,15 @@ server.registerTool(
648
649
  }
649
650
  } catch (e) {
650
651
  return toolError(e.message);
652
+ } finally {
653
+ // Persist calls cache after command execution.
654
+ // getIndex() only saves after build (when callsCache is empty).
655
+ // Commands like context/about/impact populate callsCache lazily,
656
+ // so we save here to avoid re-parsing all files on every MCP session.
657
+ if (index && index.callsCacheDirty) {
658
+ try { index.saveCache(); } catch (_) { /* best-effort */ }
659
+ index.callsCacheDirty = false;
660
+ }
651
661
  }
652
662
  }
653
663
  );
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "ucn",
3
- "version": "3.8.11",
3
+ "version": "3.8.12",
4
4
  "mcpName": "io.github.mleoca/ucn",
5
5
  "description": "Code intelligence toolkit for AI agents — extract functions, trace call chains, find callers, detect dead code without reading entire files. Works as MCP server, CLI, or agent skill. Supports JS/TS, Python, Go, Rust, Java.",
6
6
  "main": "index.js",
@@ -1 +0,0 @@
1
- {"sessionId":"5e2a28ce-7f65-4369-a922-8d1d902f9d8f","pid":18086,"acquiredAt":1773363188494}