ucn 3.8.12 → 3.8.14

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.
Files changed (44) hide show
  1. package/.claude/skills/ucn/SKILL.md +3 -1
  2. package/.github/workflows/ci.yml +15 -3
  3. package/.github/workflows/publish.yml +20 -8
  4. package/README.md +1 -0
  5. package/cli/index.js +165 -246
  6. package/core/analysis.js +1400 -0
  7. package/core/build-worker.js +194 -0
  8. package/core/cache.js +105 -7
  9. package/core/callers.js +194 -64
  10. package/core/deadcode.js +22 -66
  11. package/core/discovery.js +9 -54
  12. package/core/execute.js +139 -54
  13. package/core/graph.js +615 -0
  14. package/core/output/analysis-ext.js +271 -0
  15. package/core/output/analysis.js +491 -0
  16. package/core/output/extraction.js +188 -0
  17. package/core/output/find.js +355 -0
  18. package/core/output/graph.js +399 -0
  19. package/core/output/refactoring.js +293 -0
  20. package/core/output/reporting.js +331 -0
  21. package/core/output/search.js +307 -0
  22. package/core/output/shared.js +271 -0
  23. package/core/output/tracing.js +416 -0
  24. package/core/output.js +15 -3293
  25. package/core/parallel-build.js +165 -0
  26. package/core/project.js +299 -3633
  27. package/core/registry.js +59 -0
  28. package/core/reporting.js +258 -0
  29. package/core/search.js +890 -0
  30. package/core/stacktrace.js +1 -1
  31. package/core/tracing.js +631 -0
  32. package/core/verify.js +10 -13
  33. package/eslint.config.js +43 -0
  34. package/jsconfig.json +10 -0
  35. package/languages/go.js +21 -2
  36. package/languages/html.js +8 -0
  37. package/languages/index.js +102 -40
  38. package/languages/java.js +13 -0
  39. package/languages/javascript.js +17 -1
  40. package/languages/python.js +14 -0
  41. package/languages/rust.js +13 -0
  42. package/languages/utils.js +1 -1
  43. package/mcp/server.js +45 -28
  44. package/package.json +8 -3
package/mcp/server.js CHANGED
@@ -33,7 +33,7 @@ try {
33
33
  const { ProjectIndex } = require('../core/project');
34
34
  const { findProjectRoot } = require('../core/discovery');
35
35
  const output = require('../core/output');
36
- const { getMcpCommandEnum, normalizeParams } = require('../core/registry');
36
+ const { getMcpCommandEnum, normalizeParams, BROAD_COMMANDS: BROAD_CANONICAL, toMcpName } = require('../core/registry');
37
37
  const { execute } = require('../core/execute');
38
38
  const { ExpandCache } = require('../core/expand-cache');
39
39
 
@@ -47,6 +47,7 @@ const expandCacheInstance = new ExpandCache();
47
47
 
48
48
  function getIndex(projectDir, options) {
49
49
  const maxFiles = options && options.maxFiles;
50
+ const followSymlinks = options && options.followSymlinks;
50
51
  const absDir = path.resolve(projectDir);
51
52
  if (!fs.existsSync(absDir) || !fs.statSync(absDir).isDirectory()) {
52
53
  throw new Error(`Project directory not found: ${absDir}`);
@@ -67,6 +68,7 @@ function getIndex(projectDir, options) {
67
68
  const index = new ProjectIndex(root);
68
69
  const buildOpts = { quiet: true, forceRebuild: false };
69
70
  if (maxFiles) buildOpts.maxFiles = maxFiles;
71
+ if (followSymlinks === false) buildOpts.followSymlinks = false;
70
72
  const loaded = index.loadCache();
71
73
  if (loaded && !maxFiles && !index.isCacheStale()) {
72
74
  // Disk cache is fresh (skip when maxFiles is set — cached index may have different file count)
@@ -118,8 +120,8 @@ const DEFAULT_OUTPUT_CHARS = 10000; // ~2.5K tokens — targeted commands (abou
118
120
  const BROAD_OUTPUT_CHARS = 3000; // ~750 tokens — broad commands where truncated listings are useless
119
121
  const MAX_OUTPUT_CHARS = 100000; // hard ceiling even with max_chars override
120
122
 
121
- // Broad commands: output is project-wide, truncation means you need a filter, not more text
122
- const BROAD_COMMANDS = new Set(['toc', 'entrypoints', 'diff_impact', 'affected_tests', 'deadcode', 'usages']);
123
+ // Broad commands (derived from registry): output is project-wide, truncation means you need a filter
124
+ const BROAD_COMMANDS = new Set([...BROAD_CANONICAL].map(toMcpName));
123
125
 
124
126
  function toolResult(text, command, maxChars) {
125
127
  if (!text) return { content: [{ type: 'text', text: '(no output)' }] };
@@ -300,7 +302,8 @@ server.registerTool(
300
302
  decorator: z.string().optional().describe('Filter by decorator/annotation (structural search). E.g. "Route", "Test".'),
301
303
  exported: z.boolean().optional().describe('Only exported/public symbols (structural search).'),
302
304
  unused: z.boolean().optional().describe('Only symbols with zero callers (structural search).'),
303
- framework: z.string().optional().describe('Filter entrypoints by framework (e.g. "express", "spring", "flask"). Comma-separated for multiple.')
305
+ framework: z.string().optional().describe('Filter entrypoints by framework (e.g. "express", "spring", "flask"). Comma-separated for multiple.'),
306
+ follow_symlinks: z.boolean().optional().describe('Follow symlinks during file discovery (default: true)')
304
307
 
305
308
  })
306
309
  },
@@ -340,30 +343,36 @@ server.registerTool(
340
343
 
341
344
  case 'context': {
342
345
  index = getIndex(project_dir, ep);
343
- const { ok, result: ctx, error } = execute(index, 'context', ep);
346
+ const { ok, result: ctx, error, note } = execute(index, 'context', ep);
344
347
  if (!ok) return tr(error); // context uses soft error (not toolError)
345
348
  const { text, expandable } = output.formatContext(ctx, {
346
349
  expandHint: 'Use expand command with item number to see code for any item.',
347
350
  showConfidence: ep.showConfidence !== false,
348
351
  });
349
352
  expandCacheInstance.save(index.root, ep.name, ep.file, expandable);
350
- return tr(text);
353
+ let ctxText = text;
354
+ if (note) ctxText += '\n\n' + note;
355
+ return tr(ctxText);
351
356
  }
352
357
 
353
358
  case 'impact': {
354
359
  index = getIndex(project_dir, ep);
355
- const { ok, result, error } = execute(index, 'impact', ep);
360
+ const { ok, result, error, note } = execute(index, 'impact', ep);
356
361
  if (!ok) return tr(error); // soft error
357
- return tr(output.formatImpact(result));
362
+ let impactText = output.formatImpact(result);
363
+ if (note) impactText += '\n\n' + note;
364
+ return tr(impactText);
358
365
  }
359
366
 
360
367
  case 'blast': {
361
368
  index = getIndex(project_dir, ep);
362
- const { ok, result, error } = execute(index, 'blast', ep);
369
+ const { ok, result, error, note } = execute(index, 'blast', ep);
363
370
  if (!ok) return tr(error); // soft error
364
- return tr(output.formatBlast(result, {
371
+ let blastText = output.formatBlast(result, {
365
372
  allHint: 'Set depth to expand all children.',
366
- }));
373
+ });
374
+ if (note) blastText += '\n\n' + note;
375
+ return tr(blastText);
367
376
  }
368
377
 
369
378
  case 'smart': {
@@ -375,21 +384,25 @@ server.registerTool(
375
384
 
376
385
  case 'trace': {
377
386
  index = getIndex(project_dir, ep);
378
- const { ok, result, error } = execute(index, 'trace', ep);
387
+ const { ok, result, error, note } = execute(index, 'trace', ep);
379
388
  if (!ok) return tr(error); // soft error
380
- return tr(output.formatTrace(result, {
389
+ let traceText = output.formatTrace(result, {
381
390
  allHint: 'Set depth to expand all children.',
382
391
  methodsHint: 'Note: obj.method() calls excluded. Use include_methods=true to include them.'
383
- }));
392
+ });
393
+ if (note) traceText += '\n\n' + note;
394
+ return tr(traceText);
384
395
  }
385
396
 
386
397
  case 'reverse_trace': {
387
398
  index = getIndex(project_dir, ep);
388
- const { ok, result, error } = execute(index, 'reverseTrace', ep);
399
+ const { ok, result, error, note } = execute(index, 'reverseTrace', ep);
389
400
  if (!ok) return tr(error);
390
- return tr(output.formatReverseTrace(result, {
401
+ let rtText = output.formatReverseTrace(result, {
391
402
  allHint: 'Set depth to expand all children.',
392
- }));
403
+ });
404
+ if (note) rtText += '\n\n' + note;
405
+ return tr(rtText);
393
406
  }
394
407
 
395
408
  case 'example': {
@@ -402,13 +415,15 @@ server.registerTool(
402
415
 
403
416
  case 'related': {
404
417
  index = getIndex(project_dir, ep);
405
- const { ok, result, error } = execute(index, 'related', ep);
418
+ const { ok, result, error, note } = execute(index, 'related', ep);
406
419
  if (!ok) return tr(error);
407
420
  if (!result) return tr(`Symbol "${ep.name}" not found.`);
408
- return tr(output.formatRelated(result, {
421
+ let relText = output.formatRelated(result, {
409
422
  all: ep.all || false, top: ep.top,
410
423
  allHint: 'Repeat with all=true to show all.'
411
- }));
424
+ });
425
+ if (note) relText += '\n\n' + note;
426
+ return tr(relText);
412
427
  }
413
428
 
414
429
  // ── Finding Code ────────────────────────────────────────────
@@ -461,9 +476,11 @@ server.registerTool(
461
476
 
462
477
  case 'affected_tests': {
463
478
  index = getIndex(project_dir, ep);
464
- const { ok, result, error } = execute(index, 'affectedTests', ep);
479
+ const { ok, result, error, note } = execute(index, 'affectedTests', ep);
465
480
  if (!ok) return tr(error);
466
- return tr(output.formatAffectedTests(result, { all: ep.all }));
481
+ let atText = output.formatAffectedTests(result, { all: ep.all });
482
+ if (note) atText += '\n\n' + note;
483
+ return tr(atText);
467
484
  }
468
485
 
469
486
  case 'deadcode': {
@@ -590,15 +607,15 @@ server.registerTool(
590
607
  const err = requireName(ep.name);
591
608
  if (err) return err;
592
609
  index = getIndex(project_dir, ep);
593
- const { ok, result, error } = execute(index, 'fn', ep);
610
+ const { ok, result, error, note } = execute(index, 'fn', ep);
594
611
  if (!ok) return tr(error); // soft error
595
612
  // MCP path security: validate all result files are within project root
596
613
  for (const entry of result.entries) {
597
614
  const check = resolveAndValidatePath(index, entry.match.relativePath || path.relative(index.root, entry.match.file));
598
615
  if (typeof check !== 'string') return check;
599
616
  }
600
- const notes = result.notes.length ? result.notes.map(n => 'Note: ' + n).join('\n') + '\n\n' : '';
601
- return tr(notes + output.formatFnResult(result));
617
+ const fnText = (note ? note + '\n\n' : '') + output.formatFnResult(result);
618
+ return tr(fnText);
602
619
  }
603
620
 
604
621
  case 'class': {
@@ -608,15 +625,15 @@ server.registerTool(
608
625
  return toolError(`Invalid max_lines: ${ep.maxLines}. Must be a positive integer.`);
609
626
  }
610
627
  index = getIndex(project_dir, ep);
611
- const { ok, result, error } = execute(index, 'class', ep);
628
+ const { ok, result, error, note } = execute(index, 'class', ep);
612
629
  if (!ok) return tr(error); // soft error (class not found)
613
630
  // MCP path security: validate all result files are within project root
614
631
  for (const entry of result.entries) {
615
632
  const check = resolveAndValidatePath(index, entry.match.relativePath || path.relative(index.root, entry.match.file));
616
633
  if (typeof check !== 'string') return check;
617
634
  }
618
- const notes = result.notes.length ? result.notes.map(n => 'Note: ' + n).join('\n') + '\n\n' : '';
619
- return tr(notes + output.formatClassResult(result));
635
+ const classText = (note ? note + '\n\n' : '') + output.formatClassResult(result);
636
+ return tr(classText);
620
637
  }
621
638
 
622
639
  case 'lines': {
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "ucn",
3
- "version": "3.8.12",
3
+ "version": "3.8.14",
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",
@@ -9,8 +9,9 @@
9
9
  "ucn-mcp": "mcp/server.js"
10
10
  },
11
11
  "scripts": {
12
- "test": "node --test test/parser-unit.test.js test/integration.test.js test/cache.test.js test/formatter.test.js test/interactive.test.js test/feature.test.js test/regression-js.test.js test/regression-py.test.js test/regression-go.test.js test/regression-java.test.js test/regression-rust.test.js test/regression-cross.test.js test/accuracy.test.js test/systematic-test.js test/mcp-edge-cases.js test/parity-test.js",
13
- "benchmark:agent": "node test/agent-understanding-benchmark.js"
12
+ "test": "node --test test/parser-unit.test.js test/integration.test.js test/cache.test.js test/formatter.test.js test/interactive.test.js test/feature.test.js test/regression-js.test.js test/regression-py.test.js test/regression-go.test.js test/regression-java.test.js test/regression-rust.test.js test/regression-cross.test.js test/regression-mcp.test.js test/regression-parser.test.js test/regression-commands.test.js test/regression-fixes.test.js test/regression-bugfixes.test.js test/cross-language.test.js test/accuracy.test.js test/command-coverage.test.js test/systematic-test.js test/mcp-edge-cases.js test/parity-test.js",
13
+ "benchmark:agent": "node test/agent-understanding-benchmark.js",
14
+ "lint": "eslint core/ cli/ mcp/ languages/"
14
15
  },
15
16
  "keywords": [
16
17
  "mcp",
@@ -60,5 +61,9 @@
60
61
  "optionalDependencies": {
61
62
  "@modelcontextprotocol/sdk": "^1.0.0",
62
63
  "zod": "^3.25.0"
64
+ },
65
+ "devDependencies": {
66
+ "@eslint/js": "^10.0.1",
67
+ "eslint": "^10.0.3"
63
68
  }
64
69
  }