ucn 3.8.23 → 3.8.26

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 +127 -12
  2. package/README.md +152 -156
  3. package/cli/index.js +363 -37
  4. package/core/analysis.js +936 -32
  5. package/core/bridge.js +1095 -0
  6. package/core/brief.js +408 -0
  7. package/core/cache.js +105 -5
  8. package/core/callers.js +72 -18
  9. package/core/check.js +200 -0
  10. package/core/discovery.js +57 -34
  11. package/core/entrypoints.js +638 -4
  12. package/core/execute.js +304 -5
  13. package/core/git-enrich.js +130 -0
  14. package/core/graph.js +24 -2
  15. package/core/output/analysis.js +157 -25
  16. package/core/output/brief.js +100 -0
  17. package/core/output/check.js +79 -0
  18. package/core/output/doctor.js +85 -0
  19. package/core/output/endpoints.js +239 -0
  20. package/core/output/extraction.js +2 -0
  21. package/core/output/find.js +126 -39
  22. package/core/output/graph.js +48 -15
  23. package/core/output/refactoring.js +103 -5
  24. package/core/output/reporting.js +63 -23
  25. package/core/output/search.js +110 -17
  26. package/core/output/shared.js +56 -2
  27. package/core/output.js +4 -0
  28. package/core/parser.js +8 -2
  29. package/core/project.js +39 -3
  30. package/core/registry.js +30 -14
  31. package/core/reporting.js +465 -2
  32. package/core/search.js +130 -52
  33. package/core/shared.js +101 -5
  34. package/core/tracing.js +16 -6
  35. package/core/verify.js +982 -95
  36. package/languages/go.js +91 -6
  37. package/languages/html.js +10 -0
  38. package/languages/java.js +151 -35
  39. package/languages/javascript.js +290 -33
  40. package/languages/python.js +78 -11
  41. package/languages/rust.js +267 -12
  42. package/languages/utils.js +315 -3
  43. package/mcp/server.js +91 -16
  44. package/package.json +9 -1
@@ -46,10 +46,14 @@ function nodeToLocation(node, codeOrLines) {
46
46
  * @returns {string}
47
47
  */
48
48
  function extractParams(paramsNode) {
49
+ // Distinguish "we have no node" (genuinely unknown) from "node is empty".
50
+ // Returning '...' for empty parens caused signatures like `main(...)` for
51
+ // functions that actually take zero arguments. Empty → '' so callers can
52
+ // render `main()` cleanly.
49
53
  if (!paramsNode) return '...';
50
54
  const text = paramsNode.text;
51
- // Remove outer parens and trim
52
- return text.replace(/^\(|\)$/g, '').trim() || '...';
55
+ const stripped = text.replace(/^\(|\)$/g, '').trim();
56
+ return stripped; // '' for empty params, '...' only when paramsNode missing
53
57
  }
54
58
 
55
59
  /**
@@ -159,8 +163,10 @@ function parsePythonParam(param, info) {
159
163
  } else if (param.type === 'default_parameter' || param.type === 'typed_default_parameter') {
160
164
  const nameNode = param.childForFieldName('name');
161
165
  const valueNode = param.childForFieldName('value');
166
+ const typeNode = param.childForFieldName('type');
162
167
  if (nameNode) info.name = nameNode.text;
163
168
  if (valueNode) info.default = valueNode.text;
169
+ if (typeNode) info.type = typeNode.text;
164
170
  info.optional = true;
165
171
  } else if (param.type === 'list_splat_pattern' || param.type === 'dictionary_splat_pattern') {
166
172
  info.name = param.text;
@@ -390,6 +396,126 @@ function extractJavaDocstring(code, startLine) {
390
396
  return extractJSDocstring(code, startLine);
391
397
  }
392
398
 
399
+ /**
400
+ * Build a paramTypes map from a structured-params array.
401
+ * Skips entries without a `type`. Preserves the structured order via plain object.
402
+ * @param {Array<{name: string, type?: string}>} paramsStructured
403
+ * @returns {Object<string,string>|null} map { paramName: typeString } or null if empty
404
+ */
405
+ function paramTypesFromStructured(paramsStructured) {
406
+ if (!Array.isArray(paramsStructured) || paramsStructured.length === 0) return null;
407
+ const map = {};
408
+ let any = false;
409
+ for (const p of paramsStructured) {
410
+ if (p && p.name && p.type) {
411
+ map[p.name] = p.type;
412
+ any = true;
413
+ }
414
+ }
415
+ return any ? map : null;
416
+ }
417
+
418
+ /**
419
+ * Parse @param and @returns/@return tags from a JSDoc block above the given line.
420
+ * Returns { paramTypes, returnType } where paramTypes is { name: type } and
421
+ * returnType is a string, both possibly omitted if not present.
422
+ *
423
+ * Tag forms supported:
424
+ * @param {Type} name - typed param
425
+ * @param {Type} name - desc - typed param with description
426
+ * @returns {Type} desc - return type (also @return)
427
+ * Untyped @param tags are ignored.
428
+ *
429
+ * @param {string|string[]} codeOrLines
430
+ * @param {number} startLine - 1-indexed line of the function/method declaration
431
+ * @returns {{ paramTypes?: Object, returnType?: string }}
432
+ */
433
+ function parseJSDocTags(codeOrLines, startLine) {
434
+ const lines = Array.isArray(codeOrLines) ? codeOrLines : codeOrLines.split('\n');
435
+ const lineIndex = startLine - 1;
436
+ if (lineIndex <= 0) return {};
437
+
438
+ // Walk up past blank lines and decorators to find a JSDoc end line `*/`
439
+ let i = lineIndex - 1;
440
+ while (i >= 0 && (lines[i].trim() === '' || lines[i].trim().startsWith('@'))) {
441
+ i--;
442
+ }
443
+ if (i < 0) return {};
444
+ if (!lines[i].trim().endsWith('*/')) return {};
445
+
446
+ const docEnd = i;
447
+ while (i >= 0 && !lines[i].includes('/**')) {
448
+ i--;
449
+ }
450
+ if (i < 0) return {};
451
+
452
+ // Collect block text lines, stripping leading `*` and surrounding whitespace
453
+ const blockLines = [];
454
+ for (let j = i; j <= docEnd; j++) {
455
+ const line = lines[j]
456
+ .replace(/^\s*\/\*\*\s?/, '')
457
+ .replace(/\s*\*\/\s*$/, '')
458
+ .replace(/^\s*\*\s?/, '');
459
+ blockLines.push(line);
460
+ }
461
+ const block = blockLines.join('\n');
462
+
463
+ // @param {Type} name — capture balanced braces (no nested braces in JSDoc types in practice)
464
+ const paramTypes = {};
465
+ let any = false;
466
+ const paramRegex = /@param\s+\{([^}]+)\}\s+([A-Za-z_$][\w$]*)/g;
467
+ let m;
468
+ while ((m = paramRegex.exec(block)) !== null) {
469
+ const type = m[1].trim();
470
+ const name = m[2];
471
+ // Strip optional brackets if author wrote @param {Type} [name]; here we already excluded that
472
+ // but also handle @param {Type} [name=default] form by scanning a separate regex
473
+ paramTypes[name] = type;
474
+ any = true;
475
+ }
476
+ // Optional-bracket form: @param {Type} [name] or [name=default]
477
+ const paramOptRegex = /@param\s+\{([^}]+)\}\s+\[([A-Za-z_$][\w$]*)(?:\s*=[^\]]*)?\]/g;
478
+ while ((m = paramOptRegex.exec(block)) !== null) {
479
+ const type = m[1].trim();
480
+ const name = m[2];
481
+ if (!paramTypes[name]) paramTypes[name] = type;
482
+ any = true;
483
+ }
484
+
485
+ // @returns {Type} or @return {Type}
486
+ const retMatch = block.match(/@returns?\s+\{([^}]+)\}/);
487
+ const result = {};
488
+ if (any) result.paramTypes = paramTypes;
489
+ if (retMatch) result.returnType = retMatch[1].trim();
490
+ return result;
491
+ }
492
+
493
+ /**
494
+ * Compute the final paramTypes/returnType for a function symbol.
495
+ * Native AST types take precedence; JSDoc fills gaps.
496
+ * @param {Array} paramsStructured - parser output
497
+ * @param {string|null} nativeReturnType - return type extracted from AST (TS/Py)
498
+ * @param {string|string[]} codeOrLines - source for JSDoc lookup
499
+ * @param {number} startLine - function start line
500
+ * @param {boolean} useJSDoc - whether to consult JSDoc (true for JS/TS, false for Py)
501
+ * @returns {{ paramTypes?: Object, returnType?: string }}
502
+ */
503
+ function buildTypeAnnotations(paramsStructured, nativeReturnType, codeOrLines, startLine, useJSDoc) {
504
+ const native = paramTypesFromStructured(paramsStructured);
505
+ let jsdoc = {};
506
+ if (useJSDoc) jsdoc = parseJSDocTags(codeOrLines, startLine);
507
+
508
+ const out = {};
509
+ // Merge: JSDoc first, native overrides
510
+ if (jsdoc.paramTypes || native) {
511
+ const merged = { ...(jsdoc.paramTypes || {}), ...(native || {}) };
512
+ if (Object.keys(merged).length > 0) out.paramTypes = merged;
513
+ }
514
+ const rt = nativeReturnType || jsdoc.returnType;
515
+ if (rt) out.returnType = rt;
516
+ return out;
517
+ }
518
+
393
519
  /**
394
520
  * Get the token type at a specific position using AST
395
521
  * @param {object} rootNode - Tree-sitter root node
@@ -591,6 +717,186 @@ function clearNodeListCache() {
591
717
  _cachedSubtreeEnds = null;
592
718
  }
593
719
 
720
+ /**
721
+ * Extract a string value from a tree-sitter argument node, returning
722
+ * `{ value, interp }` where:
723
+ * - value is the literal portion (with quotes stripped)
724
+ * - interp is true when the argument is interpolated (template literal,
725
+ * f-string, format!() macro, fmt.Sprintf), in which case `value` is the
726
+ * literal *prefix* before the first interpolation, suffixed with '*'.
727
+ *
728
+ * Returns null when the node isn't a string-like value.
729
+ *
730
+ * Used by route extraction: server `app.get('/users/:id')` → '/users/:id';
731
+ * client `fetch(\`/users/${id}\`)` → '/users/*' (interp).
732
+ *
733
+ * @param {object} node - Tree-sitter node (any language)
734
+ * @returns {{ value: string, interp: boolean }|null}
735
+ */
736
+ function extractStringArg(node) {
737
+ if (!node) return null;
738
+ const t = node.type;
739
+
740
+ // JS/TS string literals
741
+ if (t === 'string') {
742
+ const text = node.text;
743
+ return { value: stripQuotes(text), interp: false };
744
+ }
745
+
746
+ // JS/TS template literal: `/users/${id}` or plain `/users/all`
747
+ if (t === 'template_string' || t === 'template_literal') {
748
+ return parseTemplateLiteral(node);
749
+ }
750
+
751
+ // Python string
752
+ if (t === 'string') {
753
+ return { value: stripQuotes(node.text), interp: false };
754
+ }
755
+
756
+ // Python concatenated strings: 'a' 'b' → 'ab'
757
+ if (t === 'concatenated_string') {
758
+ let acc = '';
759
+ let interp = false;
760
+ for (let i = 0; i < node.namedChildCount; i++) {
761
+ const child = node.namedChild(i);
762
+ const r = extractStringArg(child);
763
+ if (r) {
764
+ acc += r.value;
765
+ if (r.interp) { interp = true; break; }
766
+ } else {
767
+ interp = true;
768
+ break;
769
+ }
770
+ }
771
+ return { value: acc, interp };
772
+ }
773
+
774
+ // Go interpreted/raw string
775
+ if (t === 'interpreted_string_literal' || t === 'raw_string_literal') {
776
+ return { value: stripQuotes(node.text), interp: false };
777
+ }
778
+
779
+ // Java string literal / text block
780
+ if (t === 'string_literal' || t === 'text_block') {
781
+ return { value: stripQuotes(node.text), interp: false };
782
+ }
783
+
784
+ // Rust string literal: "..." or raw r"..." / r#"..."#
785
+ if (t === 'string_literal') {
786
+ return { value: stripRustString(node.text), interp: false };
787
+ }
788
+ if (t === 'raw_string_literal') {
789
+ return { value: stripRustString(node.text), interp: false };
790
+ }
791
+
792
+ // Rust macro_invocation: format!("/users/{}", id), format!("/path") → extract first string arg
793
+ if (t === 'macro_invocation') {
794
+ const argsNode = node.childForFieldName('arguments');
795
+ if (!argsNode) return null;
796
+ // Find first string-like child of token_tree
797
+ const first = findFirstStringInRustMacro(argsNode);
798
+ if (first == null) return null;
799
+ // Detect interpolation by presence of `{}` or `{...}` placeholders or extra args
800
+ const hasPlaceholder = /\{[^}]*\}/.test(first);
801
+ // Truncate at first placeholder
802
+ const m = first.match(/^([^{]*)/);
803
+ return { value: (m ? m[1] : first), interp: hasPlaceholder };
804
+ }
805
+
806
+ return null;
807
+ }
808
+
809
+ /** Strip surrounding quotes (' " `) from a literal, leaving the inner content. */
810
+ function stripQuotes(text) {
811
+ if (typeof text !== 'string' || text.length < 2) return text;
812
+ const first = text[0];
813
+ const last = text[text.length - 1];
814
+ if ((first === '"' || first === "'" || first === '`') && first === last) {
815
+ return text.slice(1, -1);
816
+ }
817
+ return text;
818
+ }
819
+
820
+ /** Strip Rust string literal quoting: "..." or r"..." or r#"..."# etc. */
821
+ function stripRustString(text) {
822
+ if (typeof text !== 'string') return text;
823
+ // r#"..."# (any number of #)
824
+ const rawHash = text.match(/^r(#+)"([\s\S]*)"\1$/);
825
+ if (rawHash) return rawHash[2];
826
+ // r"..."
827
+ const raw = text.match(/^r"([\s\S]*)"$/);
828
+ if (raw) return raw[1];
829
+ // "..."
830
+ if (text.startsWith('"') && text.endsWith('"')) return text.slice(1, -1);
831
+ return text;
832
+ }
833
+
834
+ /**
835
+ * Parse a JS template literal AST node and return literal prefix + interp flag.
836
+ * `/users/all` → { value: '/users/all', interp: false }
837
+ * `/users/${id}` → { value: '/users/*', interp: true }
838
+ */
839
+ function parseTemplateLiteral(node) {
840
+ let prefix = '';
841
+ let interp = false;
842
+ for (let i = 0; i < node.namedChildCount; i++) {
843
+ const child = node.namedChild(i);
844
+ // template_substitution = ${...}
845
+ if (child.type === 'template_substitution') {
846
+ interp = true;
847
+ break;
848
+ }
849
+ // string_fragment / template_chars: literal segment
850
+ if (child.type === 'string_fragment' || child.type === 'template_chars') {
851
+ prefix += child.text;
852
+ }
853
+ }
854
+ if (interp && !prefix.endsWith('*')) prefix += '*';
855
+ return { value: prefix, interp };
856
+ }
857
+
858
+ /** Find the first quoted string inside a Rust macro_invocation token_tree. */
859
+ function findFirstStringInRustMacro(tokenTree) {
860
+ if (!tokenTree) return null;
861
+ for (let i = 0; i < tokenTree.namedChildCount; i++) {
862
+ const child = tokenTree.namedChild(i);
863
+ if (child.type === 'string_literal' || child.type === 'raw_string_literal') {
864
+ return stripRustString(child.text);
865
+ }
866
+ // Recurse into nested token_trees (rare)
867
+ if (child.namedChildCount > 0) {
868
+ const r = findFirstStringInRustMacro(child);
869
+ if (r != null) return r;
870
+ }
871
+ }
872
+ // Fallback: scan raw text for first quoted string
873
+ const m = tokenTree.text.match(/"([^"\\]|\\.)*"/);
874
+ if (m) {
875
+ const raw = m[0];
876
+ return raw.slice(1, -1);
877
+ }
878
+ return null;
879
+ }
880
+
881
+ /**
882
+ * Extract path/method from a fmt.Sprintf("...", args) call inside Go.
883
+ * Returns the literal prefix before the first %v/%s/%d etc. with '*' suffix.
884
+ */
885
+ function extractSprintfPrefix(callNode) {
886
+ // call_expression with function = selector_expression "fmt.Sprintf"
887
+ const argsNode = callNode.childForFieldName('arguments');
888
+ if (!argsNode) return null;
889
+ const first = argsNode.namedChildCount > 0 ? argsNode.namedChild(0) : null;
890
+ if (!first) return null;
891
+ const r = extractStringArg(first);
892
+ if (!r) return null;
893
+ // Find first format directive %x and truncate
894
+ const m = r.value.match(/^([^%]*)/);
895
+ const literal = m ? m[1] : r.value;
896
+ const hasFmt = /%/.test(r.value);
897
+ return { value: hasFmt ? (literal + '*') : literal, interp: hasFmt };
898
+ }
899
+
594
900
  module.exports = {
595
901
  traverseTree,
596
902
  traverseTreeCached,
@@ -604,7 +910,13 @@ module.exports = {
604
910
  extractGoDocstring,
605
911
  extractRustDocstring,
606
912
  extractJavaDocstring,
913
+ paramTypesFromStructured,
914
+ parseJSDocTags,
915
+ buildTypeAnnotations,
607
916
  getTokenTypeAtPosition,
608
917
  isMatchInCommentOrString,
609
- findMatchesWithASTFilter
918
+ findMatchesWithASTFilter,
919
+ extractStringArg,
920
+ stripQuotes,
921
+ extractSprintfPrefix,
610
922
  };
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, BROAD_COMMANDS: BROAD_CANONICAL, toMcpName, FLAG_APPLICABILITY, REVERSE_PARAM_MAP, generateMcpParamSection } = require('../core/registry');
36
+ const { getMcpCommandEnum, normalizeParams, BROAD_COMMANDS: BROAD_CANONICAL, toMcpName, FLAG_APPLICABILITY, REVERSE_PARAM_MAP, generateMcpParamSection, resolveCommand } = require('../core/registry');
37
37
  const { execute } = require('../core/execute');
38
38
  const { ExpandCache } = require('../core/expand-cache');
39
39
 
@@ -139,6 +139,7 @@ function toolResult(text, command, maxChars, suffixNote) {
139
139
  const hints = {
140
140
  toc: 'Use in= to scope to a subdirectory, or detailed=false for compact view.',
141
141
  entrypoints: 'Use framework= to filter by framework, exclude= to skip patterns.',
142
+ endpoints: 'Use prefix= to filter by URL prefix, method= to filter by HTTP method, server_only/client_only to halve output.',
142
143
  diff_impact: 'Use file= to scope to specific files/directories.',
143
144
  affected_tests: 'Use file= to scope, exclude= to skip patterns.',
144
145
  deadcode: 'Use file= to scope, exclude= to skip patterns.',
@@ -197,15 +198,16 @@ QUICK GUIDE — choosing the right command:
197
198
  Commands:
198
199
 
199
200
  UNDERSTANDING CODE:
200
- - about <name>: Definition, source, callers, callees, and tests — everything in one call. Replaces 3-4 grep+read cycles. Your first stop for any function or class.
201
+ - about <name>: Definition, source, callers, callees, and tests — everything in one call. Replaces 3-4 grep+read cycles. Your first stop for any function or class. Pass git=true for last-modified, author, and recent-changes (last 30d).
201
202
  - context <name>: Who calls it and what does it call, without source code. Results are numbered for use with expand. For classes/structs, shows all methods instead.
202
203
  - impact <name>: Every call site with actual arguments passed, grouped by file. Essential before changing a function signature — shows exactly what breaks.
203
204
  - blast <name>: Transitive blast radius — callers of callers. Shows the full chain of functions affected if you change something. Like impact but recursive. Use depth (default: 3) to control how far up the chain to walk.
204
205
  - smart <name>: Get a function's source with all called functions expanded inline (not constants/variables). Use to understand or modify a function and its dependencies in one read.
205
206
  - trace <name>: Call tree from a function downward. Use to understand "what happens when X runs" — maps which modules a pipeline touches without reading files. Set depth (default: 3); setting depth expands all children.
206
- - example <name>: Best real-world usage example. Automatically scores call sites by quality and returns the top one with context. Use to understand expected calling patterns.
207
+ - example <name>: Best real-world usage example. Automatically scores call sites by quality and returns the top one with context. Use to understand expected calling patterns. Set diverse=true to cluster call sites by argument shape and return one representative per cluster (pair with top=N, default 3).
207
208
  - reverse_trace <name>: Upward call chain to entry points — who calls this, who calls those callers, etc. Use to find all paths that lead to a function. Set depth (default: 5) to control how far up. Complement to trace (which goes downward).
208
209
  - related <name>: Sibling functions: same file, similar names, or shared callers/callees. Find companions to update together (e.g., serialize when you're changing deserialize). Name-based, not semantic.
210
+ - brief <name>: Compact summary of a function: typed signature, first sentence of docstring, side-effect classification (fs/network/process/global_mutation), complexity (branches, depth, lines). Cheaper than about; more useful than fn when you don't need the body. Pass git=true for last-modified info.
209
211
 
210
212
  FINDING CODE:
211
213
  - find <name>: Locate definitions ranked by usage count. Supports glob patterns (e.g. find "handle*" or "_update*"). Use when you know the name but not the file.
@@ -216,6 +218,7 @@ FINDING CODE:
216
218
  - affected_tests <name>: Which tests to run after changing a function. Combines blast (transitive callers) with test detection. Shows test files, coverage %, and uncovered functions. Use depth= to control depth.
217
219
  - deadcode: Find dead code: functions/classes with zero callers. Use during cleanup to identify safely deletable code. Excludes exported, decorated, and test symbols by default — use include_exported/include_decorated/include_tests to expand.
218
220
  - entrypoints: Detect framework entry points: routes, handlers, DI providers, tasks. Auto-detects Express, Flask, Spring, Gin, Actix, and more. Use framework= to filter by specific framework.
221
+ - endpoints: HTTP API surface — list server routes (Express/Fastify/Koa/NestJS/Flask/FastAPI/Spring/JAX-RS/Gin/Echo/Chi/Fiber/axum/actix/Next.js) and client requests (fetch/axios/requests/httpx/http/restTemplate/webClient/reqwest). Use bridge=true to match clients to servers across language boundaries; method=/prefix= to filter; server_only/client_only to halve output.
219
222
 
220
223
  EXTRACTING CODE (use instead of reading entire files):
221
224
  - fn <name>: Extract one or more functions. Comma-separated for bulk extraction (e.g. "parse,format,validate"). Use file to disambiguate.
@@ -234,12 +237,17 @@ REFACTORING:
234
237
  - verify <name>: Check all call sites match function signature (argument count). Run before adding/removing parameters to catch breakage early.
235
238
  - plan <name>: Preview refactoring: before/after signatures and call sites needing updates. Use add_param (with optional default_value), remove_param, or rename_to. Pair with verify.
236
239
  - diff_impact: Which functions changed in git diff and who calls them. Use to understand impact of recent changes before committing or reviewing. Use base, staged, or file params to scope.
240
+ - check: Pre-commit lint of pending changes against the index. Composes diff_impact + verify + affected_tests; flags ADDED functions with zero callers (ORPHAN), BROKEN_IMPORT, signature drift across call sites, and recommends which tests to run. Use base= to compare against a branch, staged=true for staged changes only.
241
+
242
+ DIAGNOSTICS:
243
+ - doctor: Index health/coverage report — file/symbol counts, language breakdown, dynamic-import / eval / reflection blind spots, parse failures, and a verdict (HIGH/MEDIUM/LOW trust). Use deep=true to also sample resolution coverage and bucket edges by confidence. Use in= to scope to a subtree.
237
244
 
238
245
  OTHER:
239
246
  - typedef <name>: Find type definitions matching a name: interfaces, enums, structs, traits, type aliases. See field shapes, required methods, or enum values.
240
247
  - stacktrace: Parse a stack trace, show source context per frame. Requires stack param. Handles JS, Python, Go, Rust, Java formats.
241
248
  - api: Public API surface of project or file: all exported/public symbols with signatures. Use to understand what a library exposes. Pass file to scope to one file. Python needs __all__; use toc instead.
242
- - stats: Quick project stats: file counts, symbol counts, lines of code by language and symbol type. Use functions=true for per-function line counts sorted by size (complexity audit).` + generateMcpParamSection();
249
+ - stats: Quick project stats: file counts, symbol counts, lines of code by language and symbol type. Use functions=true for per-function line counts sorted by size (complexity audit). Set hot=true with top=N for the most-called functions (project orientation primitive).
250
+ - audit_async: Find async calls inside async functions that are likely missing await (probable bugs). JS/TS/Python only. Filter with file/exclude/limit.` + generateMcpParamSection();
243
251
 
244
252
  server.registerTool(
245
253
  'ucn',
@@ -252,42 +260,50 @@ server.registerTool(
252
260
  file: z.string().optional().describe('File path (imports/exporters/graph/file_exports/lines/api/diff_impact) or filter pattern for disambiguation (e.g. "parser", "src/core")'),
253
261
  exclude: z.string().optional().describe('Comma-separated patterns to exclude (e.g. "test,mock,vendor")'),
254
262
  include_tests: z.boolean().optional().describe('Include test files in results (excluded by default)'),
263
+ exclude_tests: z.boolean().optional().describe('Exclude test files from results. Used by entrypoints (where tests are included by default).'),
255
264
  include_methods: z.boolean().optional().describe('Include obj.method() calls (default: true for about/trace)'),
256
265
  include_uncertain: z.boolean().optional().describe('Include uncertain/ambiguous matches'),
257
- min_confidence: z.number().optional().describe('Minimum confidence threshold (0.0-1.0) to filter caller/callee edges'),
266
+ min_confidence: z.number().min(0).max(1).optional().describe('Minimum confidence threshold (0.0-1.0) to filter caller/callee edges'),
258
267
  show_confidence: z.boolean().optional().describe('Show confidence scores per edge (default: true). Set false to hide.'),
268
+ hide_confidence: z.boolean().optional().describe('Hide confidence scores per edge (alias of show_confidence=false).'),
269
+ unreachable_only: z.boolean().optional().describe('Show only callers/callees that are unreachable from any detected entry point (about, context, impact).'),
259
270
  with_types: z.boolean().optional().describe('Include related type definitions in output'),
260
271
  detailed: z.boolean().optional().describe('Show full symbol listing per file'),
261
272
  exact: z.boolean().optional().describe('Exact name match only (no substring matching)'),
262
273
  in: z.string().optional().describe('Only search in this directory path (e.g. "src/core")'),
263
- top: z.number().optional().describe('Max results to show (default: 10)'),
264
- depth: z.number().optional().describe('Max depth (default: 3 for trace, 2 for graph); expands all children'),
274
+ top: z.number().int().positive().max(10000).optional().describe('Max results to show (default: 10). Must be a positive integer.'),
275
+ depth: z.number().int().nonnegative().max(100).optional().describe('Max depth (default: 3 for trace, 2 for graph); expands all children. Non-negative integer.'),
265
276
  code_only: z.boolean().optional().describe('Exclude matches in comments and strings'),
266
- context: z.number().optional().describe('Lines of context around each match'),
277
+ context: z.number().int().nonnegative().max(1000).optional().describe('Lines of context around each match. Non-negative integer.'),
267
278
  include_exported: z.boolean().optional().describe('Include exported symbols in deadcode results'),
268
279
  include_decorated: z.boolean().optional().describe('Include decorated/annotated symbols in deadcode results'),
269
280
  calls_only: z.boolean().optional().describe('Only direct calls and test-case matches (tests command)'),
270
- max_lines: z.number().optional().describe('Max source lines for class (large classes show summary by default)'),
281
+ max_lines: z.number().int().positive().max(1000000).optional().describe('Max source lines for class (large classes show summary by default). Must be a positive integer.'),
271
282
  direction: z.enum(['imports', 'importers', 'both']).optional().describe('Graph direction: imports (what this file uses), importers (who uses this file), both (default: both)'),
272
283
  term: z.string().optional().describe('Search term (regex by default; set regex=false to force plain text)'),
273
284
  regex: z.boolean().optional().describe('Treat search term as a regex pattern (default: true). Set false to force plain text escaping.'),
274
285
  functions: z.boolean().optional().describe('Include per-function line counts in stats output, sorted by size (complexity audit)'),
286
+ hot: z.boolean().optional().describe('Include top N most-called functions in stats output (orientation primitive). Pair with top=N (default 10).'),
287
+ diverse: z.boolean().optional().describe('For example: cluster call sites by argument shape and return one representative per cluster. Pair with top=N (default 3).'),
288
+ git: z.boolean().optional().describe('Attach git enrichment (last modified, author, recent change count last 30d) to about/brief output. Returns gracefully when not a git repo.'),
275
289
  add_param: z.string().optional().describe('Parameter name to add (plan command)'),
276
290
  remove_param: z.string().optional().describe('Parameter name to remove (plan command)'),
277
291
  rename_to: z.string().optional().describe('New function name (plan command)'),
278
292
  default_value: z.string().optional().describe('Default value for added parameter (plan command)'),
279
293
  stack: z.string().optional().describe('The stack trace text to parse (stacktrace command)'),
280
- item: z.number().optional().describe('Item number from context output to expand (e.g. 1, 2, 3)'),
294
+ item: z.number().int().positive().max(1000000).optional().describe('Item number from context output to expand (e.g. 1, 2, 3). Must be a positive integer.'),
281
295
  range: z.string().optional().describe('Line range to extract, e.g. "10-20" or "15" (lines command)'),
282
296
  base: z.string().optional().describe('Git ref to diff against (default: HEAD). E.g. "HEAD~3", "main", a commit SHA'),
283
297
  staged: z.boolean().optional().describe('Analyze staged changes (diff_impact command)'),
298
+ deep: z.boolean().optional().describe('Run a deeper analysis (doctor: sample resolution coverage)'),
299
+ compact: z.boolean().optional().describe('Compact one-line-per-item output for about/context (saves tokens)'),
284
300
  case_sensitive: z.boolean().optional().describe('Case-sensitive search (default: false, case-insensitive)'),
285
301
  all: z.boolean().optional().describe('Show all results (expand truncated sections). Applies to about, toc, related, trace, and others.'),
286
302
  top_level: z.boolean().optional().describe('Show only top-level functions in toc (exclude nested/indented)'),
287
303
  class_name: z.string().optional().describe('Class name to scope method analysis (e.g. "MarketDataFetcher" for close)'),
288
- limit: z.number().optional().describe('Max results to return (default: 500). Caps find, usages, search, deadcode, api, toc --detailed.'),
289
- max_files: z.number().optional().describe('Max files to index (default: 10000). Use for very large codebases.'),
290
- max_chars: z.number().optional().describe('Max output chars before truncation. Targeted commands (about, context, smart, etc.): 10K default. Broad commands (toc, entrypoints, deadcode, etc.): 3K default. Max: 100K. Use all=true to bypass all caps.'),
304
+ limit: z.number().int().positive().max(1000000).optional().describe('Max results to return (default: 500). Caps find, usages, search, deadcode, api, toc --detailed. Must be a positive integer.'),
305
+ max_files: z.number().int().positive().max(10000000).optional().describe('Max files to index (default: 10000). Use for very large codebases. Must be a positive integer.'),
306
+ max_chars: z.number().int().positive().max(10000000).optional().describe('Max output chars before truncation. Targeted commands (about, context, smart, etc.): 10K default. Broad commands (toc, entrypoints, deadcode, etc.): 3K default. Max: 100K. Use all=true to bypass all caps.'),
291
307
  // Structural search flags (search command)
292
308
  type: z.string().optional().describe('Symbol type filter for structural search: function, class, call, method, type. Triggers index-based search.'),
293
309
  param: z.string().optional().describe('Filter by parameter name or type (structural search). E.g. "Request", "ctx".'),
@@ -297,7 +313,15 @@ server.registerTool(
297
313
  exported: z.boolean().optional().describe('Only exported/public symbols (structural search).'),
298
314
  unused: z.boolean().optional().describe('Only symbols with zero callers (structural search).'),
299
315
  framework: z.string().optional().describe('Filter entrypoints by framework (e.g. "express", "spring", "flask"). Comma-separated for multiple.'),
300
- follow_symlinks: z.boolean().optional().describe('Follow symlinks during file discovery (default: true)')
316
+ follow_symlinks: z.boolean().optional().describe('Follow symlinks during file discovery (default: true)'),
317
+ // endpoints command
318
+ bridge: z.boolean().optional().describe('Match server routes to client requests (endpoints command).'),
319
+ server_only: z.boolean().optional().describe('Only list server routes (endpoints command).'),
320
+ client_only: z.boolean().optional().describe('Only list client requests (endpoints command).'),
321
+ unmatched: z.boolean().optional().describe('Only show unmatched routes/requests (endpoints command).'),
322
+ method: z.string().optional().describe('Filter by HTTP method (e.g. "GET", "POST") — endpoints command.'),
323
+ prefix: z.string().optional().describe('Filter routes/requests by path prefix (endpoints command).'),
324
+ hide_uncertain: z.boolean().optional().describe('Hide uncertain (interpolated-path) bridges (endpoints command).')
301
325
 
302
326
  })
303
327
  },
@@ -309,10 +333,20 @@ server.registerTool(
309
333
  const { command: _c, project_dir: _p, ...rawParams } = args;
310
334
  const ep = normalizeParams(rawParams);
311
335
 
336
+ // Translate hide_confidence → showConfidence:false (canonical inverse).
337
+ if (ep.hideConfidence === true && ep.showConfidence === undefined) {
338
+ ep.showConfidence = false;
339
+ }
340
+ delete ep.hideConfidence;
341
+
312
342
  // Strip params not applicable to this command (prevents silent no-ops).
313
343
  // Global/core params are always allowed — only optional flags are filtered.
344
+ // FLAG_APPLICABILITY is keyed by canonical (camelCase) names, but `command`
345
+ // is the MCP (snake_case) name — resolve to canonical first to avoid
346
+ // silently skipping multi-word commands (circular_deps, diff_impact, etc.).
314
347
  const strippedParams = [];
315
- const applicable = FLAG_APPLICABILITY[command];
348
+ const canonicalCommand = resolveCommand(command, 'mcp') || command;
349
+ const applicable = FLAG_APPLICABILITY[canonicalCommand];
316
350
  if (applicable) {
317
351
  // Truly global options — apply to all commands (build/display control).
318
352
  // Command-specific params (name, term, stack, range, etc.) are in FLAG_APPLICABILITY.
@@ -450,6 +484,27 @@ server.registerTool(
450
484
  return tr(relText);
451
485
  }
452
486
 
487
+ case 'brief': {
488
+ index = getIndex(project_dir, ep);
489
+ const { ok, result, error } = execute(index, 'brief', ep);
490
+ if (!ok) return te(error);
491
+ return tr(output.formatBrief(result));
492
+ }
493
+
494
+ case 'doctor': {
495
+ index = getIndex(project_dir, ep);
496
+ const { ok, result, error } = execute(index, 'doctor', ep);
497
+ if (!ok) return te(error);
498
+ return tr(output.formatDoctor(result));
499
+ }
500
+
501
+ case 'check': {
502
+ index = getIndex(project_dir, ep);
503
+ const { ok, result, error } = execute(index, 'check', ep);
504
+ if (!ok) return te(error);
505
+ return tr(output.formatCheck(result));
506
+ }
507
+
453
508
  // ── Finding Code ────────────────────────────────────────────
454
509
 
455
510
  case 'find': {
@@ -536,6 +591,15 @@ server.registerTool(
536
591
  return tr(epText);
537
592
  }
538
593
 
594
+ case 'endpoints': {
595
+ index = getIndex(project_dir, ep);
596
+ const { ok, result, error, note } = execute(index, 'endpoints', ep);
597
+ if (!ok) return te(error);
598
+ let endText = output.formatEndpoints(result, { bridge: result._bridge, unmatched: result._unmatched });
599
+ if (note) endText += '\n\n' + note;
600
+ return tr(endText);
601
+ }
602
+
539
603
  // ── File Dependencies ───────────────────────────────────────
540
604
 
541
605
  case 'imports': {
@@ -637,6 +701,15 @@ server.registerTool(
637
701
  return tr(statsText);
638
702
  }
639
703
 
704
+ case 'audit_async': {
705
+ index = getIndex(project_dir, ep);
706
+ const { ok, result, error, note } = execute(index, 'auditAsync', ep);
707
+ if (!ok) return te(error);
708
+ let text = output.formatAuditAsync(result);
709
+ if (note) text += '\n\n' + note;
710
+ return tr(text);
711
+ }
712
+
640
713
  // ── Extracting Code (via execute) ────────────────────────────
641
714
 
642
715
  case 'fn': {
@@ -700,7 +773,9 @@ server.registerTool(
700
773
  // getIndex() only saves after build (when callsCache is empty).
701
774
  // Commands like context/about/impact populate callsCache lazily,
702
775
  // so we save here to avoid re-parsing all files on every MCP session.
703
- if (index && index.callsCacheDirty) {
776
+ // MED-1: also persist when reachability was computed in-process so
777
+ // long-lived MCP servers carry the BFS result forward to disk.
778
+ if (index && (index.callsCacheDirty || index.reachabilityDirty)) {
704
779
  try { index.saveCache(); } catch (_) { /* best-effort */ }
705
780
  index.callsCacheDirty = false;
706
781
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "ucn",
3
- "version": "3.8.23",
3
+ "version": "3.8.26",
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",
@@ -28,6 +28,14 @@
28
28
  "impact-analysis",
29
29
  "dead-code",
30
30
  "deadcode",
31
+ "endpoints",
32
+ "polyglot",
33
+ "audit",
34
+ "async",
35
+ "api-bridge",
36
+ "http",
37
+ "route",
38
+ "fetch",
31
39
  "agent-skill",
32
40
  "skill",
33
41
  "cli",